feature - added one time payment processing

This commit is contained in:
James Wyndham 2024-02-22 16:18:15 +08:00
parent fb64f6bcd6
commit 28e5d2c9eb
10 changed files with 250 additions and 217 deletions

Binary file not shown.

View File

@ -391,7 +391,7 @@ func main() {
form = forms.NewRecordUpsert(app, record)
}
form.LoadData(map[string]any{
data := map[string]any{
"price_id": price.ID,
"product_id": price.Product.ID,
"active": price.Active,
@ -399,15 +399,20 @@ func main() {
"description": price.Nickname,
"type": price.Type,
"unit_amount": price.UnitAmount,
"interval": price.Recurring.Interval,
"interval_count": price.Recurring.IntervalCount,
"trial_period_days": price.Recurring.TrialPeriodDays,
"metadata": price.Metadata,
})
}
// Check if Recurring is not nil before accessing its fields
if price.Recurring != nil {
data["interval"] = price.Recurring.Interval
data["interval_count"] = price.Recurring.IntervalCount
data["trial_period_days"] = price.Recurring.TrialPeriodDays
}
form.LoadData(data)
// validate and submit (internally it calls app.Dao().SaveRecord(record) in a transaction)
if err := form.Submit(); err != nil {
return err
return c.JSON(http.StatusBadRequest, map[string]string{"failure": "failed to submit to pocketbase"})
}
case "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted":
var subscription stripe.Subscription

View File

@ -2,7 +2,6 @@
import { Product, Price } from "@/types";
import pb from "@/lib/pocketbase";
import { getAuthCookie } from "@/app/(auth)/actions";
export async function apiPrices() {
try {
@ -30,6 +29,7 @@ export async function apiPrices() {
product.metadata.benefits = product?.metadata?.benefits ? JSON.parse(product.metadata.benefits as any) : [];
const pricesOfProduct = prices.filter(price => price.product_id === product.product_id);
for (const priceOfProduct of pricesOfProduct){
product.type = priceOfProduct.type;
if (priceOfProduct.interval === "year"){
product.yearlyPrice = priceOfProduct;
} else {

View File

@ -1,35 +1,13 @@
"use client";
import React, { useEffect } from "react";
import React from "react";
import PageHeader from "@/sections/PageHeader";
import { Price, Product, SourceModal } from "@/types";
import { Check } from "@styled-icons/entypo/Check";
import { ChangeEventHandler, useState } from "react";
import { apiPrices } from "./actions";
import Newsletter from "@/sections/Newsletter/Newsletter";
import { createCheckoutSession, isAuthenticated } from "@/app/(auth)/actions";
import { toast } from "react-toastify";
import { useRouter } from "next/navigation";
import Background from "@/components/Utilities/Background";
import Footer from "@/components/Footer";
import Payment from "@/sections/Payment";
export default function PricingPage() {
const [isAnnual, setIsAnnual] = useState(false);
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(true);
const handleToggle = () => {
setIsAnnual((prev) => !prev);
};
useEffect(() => {
setIsLoading(true);
(async () => {
const resposeProducts: Product[] = await apiPrices();
setProducts(resposeProducts);
setIsLoading(false);
})();
}, []);
return (
<main
id="content"
@ -49,177 +27,10 @@ export default function PricingPage() {
</>
}
/>
<div className="flex items-center justify-center mt-10">
<PriceToggle isAnnual={isAnnual} onChange={handleToggle} />
</div>
<div className="max-w-6xl mx-auto mb-24 h-full">
<div
className={`w-screen lg:w-full flex gap-x-4 lg:justify-center gap-y-8 px-6 pt-12 overflow-x-scroll`}
>
{isLoading ? (
<>
<PriceCard loading={isLoading} />
<PriceCard loading={isLoading} />
<PriceCard loading={isLoading} />
</>
) : (
products.map((x, i) => (
<PriceCard key={i} product={x} isAnnual={isAnnual} />
))
)}
</div>
</div>
<Payment type="one_time" />
<Newsletter />
<Footer />
</Background>
</main>
);
}
function PriceCard({
product,
isAnnual,
loading,
}: {
product?: Product;
isAnnual?: boolean;
loading?: boolean;
}) {
const router = useRouter();
const openSignUpModalOnPriceClick = (price: Price) => {
const signUpModal = document.getElementById("sign-up-modal");
if (!signUpModal) return;
signUpModal.setAttribute("price_id", price.price_id);
signUpModal.setAttribute("name", SourceModal.SignUpViaPurchase);
signUpModal.click();
};
const generateCheckoutPage = async (price: Price) => {
try {
const checkoutSessionResponse = await createCheckoutSession(
price.price_id
);
router.push(checkoutSessionResponse.url);
} catch (error) {
if (error instanceof Error) {
toast.error(error.message, {
position: "bottom-left",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "colored",
});
}
}
};
const submitForm = async (price: Price) => {
const userIsAuthenticated = await isAuthenticated();
if (userIsAuthenticated) {
await generateCheckoutPage(price);
} else {
localStorage.setItem("price", JSON.stringify(price));
openSignUpModalOnPriceClick(price);
}
};
return (
<div
className={`relative w-64 sm:w-80 bg-base-100 rounded-lg p-6 ${
loading ? "animate-pulse h-[20rem]" : ""
}`}
>
<div className="flex flex-col h-full">
<div className="mb-12 relative">
{false && (
<p className="absolute top-[-10px] left-[50%] -translate-x-1/2 font-architects-daughter text-xl text-primary text-center">
Popular
</p>
)}
<h1 className="text-center text-3xl font-inter font-bold pt-6 text-base-content ">
{product?.name}
</h1>
<h3 className="text-center pt-4 text-base-content ">
{product?.description}
</h3>
</div>
<div className="pb-12">
<ul className="flex flex-col gap-y-3 mx-12">
{product?.metadata?.benefits?.map((x, i) => (
<li key={i} className="flex items-center gap-x-4 flex-nowrap">
<Check className=" self-start" color="#FF0DCA" size={24} />
<p className="w-40 text-base-content overflow-clip text-ellipsis">
{x}
</p>
</li>
))}
</ul>
</div>
<div className="flex flex-col mx-auto mt-auto">
{!loading && (
<div className="flex flex-row mx-auto gap-x-2 justify-center items-center mb-2">
<h1 className="text-base-content text-4xl font-bold">
$
{isAnnual
? (product?.yearlyPrice?.unit_amount ?? 0) / 100
: (product?.monthlyPrice?.unit_amount ?? 0) / 100}
</h1>
<p className="w-16 leading-5 text-sm text-base-content ">
per user per {isAnnual ? "year" : "month"}
</p>
</div>
)}
{product && (
<button
onClick={() =>
submitForm(
isAnnual ? product.yearlyPrice : product.monthlyPrice
)
}
className="btn btn-primary rounded-full bg-gradient-to-r from-primary to-secondary"
>
Try it!
</button>
)}
</div>
</div>
</div>
);
}
function PriceToggle({
isAnnual,
onChange,
}: {
isAnnual: boolean;
onChange?: ChangeEventHandler<HTMLInputElement>;
}) {
return (
<>
<label className="shadow-card relative inline-flex cursor-pointer select-none items-center justify-center rounded-lg bg-base-100 p-1">
<input
type="checkbox"
className="sr-only"
checked={isAnnual}
onChange={onChange}
/>
<span
className={`flex items-center space-x-[6px] rounded py-2 px-[18px] text-sm font-medium ${
!isAnnual ? " bg-secondary" : "text-secondary-content"
}`}
>
Monthly Billing
</span>
<span
className={`flex items-center space-x-[6px] rounded py-2 px-[18px] text-sm font-medium ${
isAnnual ? " bg-secondary" : "text-secondary-content"
}`}
>
Yearly Billing
</span>
</label>
</>
);
}

View File

@ -174,7 +174,7 @@ export async function logout() {
redirect('/');
}
export async function createCheckoutSession(price_id: string) {
export async function createCheckoutSession(price_id: string, type: string) {
const pocketbaseUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL_STRING;
if (!pocketbaseUrl) {
throw Error('Connection Timeout');
@ -200,7 +200,7 @@ export async function createCheckoutSession(price_id: string) {
body: JSON.stringify({
price: {
id: price_id,
type: "recurring"
type: type
},
quantity: 1
}),

View File

@ -20,10 +20,11 @@ function ModalSignUp() {
resolver: yupResolver(signUpValidationSchema),
});
const router = useRouter();
const generateCheckoutPage = async (price: Price) => {
const generateCheckoutPage = async (price: Price, type: string) => {
try {
const checkoutSessionResponse = await createCheckoutSession(
price.price_id
price.price_id,
type
);
console.log(checkoutSessionResponse);
router.push(checkoutSessionResponse.url);
@ -66,8 +67,11 @@ function ModalSignUp() {
) {
reset();
document.getElementById("sign-up-modal")?.click();
const type = document
.getElementById("sign-up-modal")
?.getAttribute("type");
const price = localStorage.getItem("price");
price && generateCheckoutPage(JSON.parse(price));
price && generateCheckoutPage(JSON.parse(price), type ?? "");
}
} catch (error) {
console.log("heyaa");

View File

@ -0,0 +1,117 @@
import { Price, Product, SourceModal } from "@/types";
import { Check } from "@styled-icons/entypo/Check";
import { createCheckoutSession, isAuthenticated } from "@/app/(auth)/actions";
import { toast } from "react-toastify";
import { useRouter } from "next/navigation";
export default function PriceCard({
product,
isAnnual,
loading,
}: {
product?: Product;
isAnnual?: boolean;
loading?: boolean;
}) {
const router = useRouter();
const openSignUpModalOnPriceClick = (price: Price, type: string) => {
const signUpModal = document.getElementById("sign-up-modal");
if (!signUpModal) return;
signUpModal.setAttribute("price_id", price.price_id);
signUpModal.setAttribute("type", type);
signUpModal.setAttribute("name", SourceModal.SignUpViaPurchase);
signUpModal.click();
};
const generateCheckoutPage = async (price: Price, type: string) => {
try {
const checkoutSessionResponse = await createCheckoutSession(
price.price_id,
type
);
router.push(checkoutSessionResponse.url);
} catch (error) {
if (error instanceof Error) {
toast.error(error.message, {
position: "bottom-left",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "colored",
});
}
}
};
const submitForm = async (product: Product) => {
const userIsAuthenticated = await isAuthenticated();
const price = isAnnual ? product.yearlyPrice : product.monthlyPrice;
if (userIsAuthenticated) {
await generateCheckoutPage(price, product.type);
} else {
localStorage.setItem("price", JSON.stringify(price));
openSignUpModalOnPriceClick(price, product.type);
}
};
return (
<div
className={`relative w-64 sm:w-80 bg-base-100 rounded-lg p-6 ${
loading ? "animate-pulse h-[20rem]" : ""
}`}
>
<div className="flex flex-col h-full">
<div className="mb-12 relative">
{false && (
<p className="absolute top-[-10px] left-[50%] -translate-x-1/2 font-architects-daughter text-xl text-primary text-center">
Popular
</p>
)}
<h1 className="text-center text-3xl font-inter font-bold pt-6 text-base-content ">
{product?.name}
</h1>
<h3 className="text-center pt-4 text-base-content ">
{product?.description}
</h3>
</div>
<div className="pb-12">
<ul className="flex flex-col gap-y-3 mx-12">
{product?.metadata?.benefits?.map((x, i) => (
<li key={i} className="flex items-center gap-x-4 flex-nowrap">
<Check className=" self-start" color="#FF0DCA" size={24} />
<p className="w-40 text-base-content overflow-clip text-ellipsis">
{x}
</p>
</li>
))}
</ul>
</div>
<div className="flex flex-col mx-auto mt-auto">
{!loading && (
<div className="flex flex-row mx-auto gap-x-2 justify-center items-center mb-2">
<h1 className="text-base-content text-4xl font-bold">
$
{isAnnual
? (product?.yearlyPrice?.unit_amount ?? 0) / 100
: (product?.monthlyPrice?.unit_amount ?? 0) / 100}
</h1>
<p className="w-16 leading-5 text-sm text-base-content ">
per user per {isAnnual ? "year" : "month"}
</p>
</div>
)}
{product && (
<button
onClick={() => submitForm(product)}
className="btn btn-primary rounded-full bg-gradient-to-r from-primary to-secondary"
>
Try it!
</button>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
import { ChangeEventHandler } from "react";
export default function PriceToggle({
isAnnual,
onChange,
}: {
isAnnual: boolean;
onChange?: ChangeEventHandler<HTMLInputElement>;
}) {
return (
<>
<label className="shadow-card relative inline-flex cursor-pointer select-none items-center justify-center rounded-lg bg-base-100 p-1">
<input
type="checkbox"
className="sr-only"
checked={isAnnual}
onChange={onChange}
/>
<span
className={`flex items-center space-x-[6px] rounded py-2 px-[18px] text-sm font-medium ${
!isAnnual ? " bg-secondary" : "text-secondary-content"
}`}
>
Monthly Billing
</span>
<span
className={`flex items-center space-x-[6px] rounded py-2 px-[18px] text-sm font-medium ${
isAnnual ? " bg-secondary" : "text-secondary-content"
}`}
>
Yearly Billing
</span>
</label>
</>
);
}

View File

@ -0,0 +1,59 @@
"use client";
import React, { useEffect } from "react";
import { Product } from "@/types";
import PriceCard from "@/components/PriceCard";
import { useState } from "react";
import PriceToggle from "@/components/PriceToggle";
import { apiPrices } from "../app/(admin)/pricing/actions";
const Payment = ({
type = "one_time",
}: {
type?: "one_time" | "reoccuring";
}) => {
const [isAnnual, setIsAnnual] = useState(false);
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(true);
const handleToggle = () => {
setIsAnnual((prev) => !prev);
};
useEffect(() => {
setIsLoading(true);
(async () => {
const resposeProducts: Product[] = await apiPrices();
setProducts(resposeProducts);
setIsLoading(false);
})();
}, []);
return (
<>
<div className="flex items-center justify-center mt-10">
<PriceToggle isAnnual={isAnnual} onChange={handleToggle} />
</div>
<div className="max-w-6xl mx-auto mb-24 h-full">
<div
className={`w-screen lg:w-full flex gap-x-4 lg:justify-center gap-y-8 px-6 pt-12 overflow-x-scroll`}
>
{isLoading ? (
<>
<PriceCard loading={isLoading} />
<PriceCard loading={isLoading} />
<PriceCard loading={isLoading} />
</>
) : (
products
.filter((x) =>
type == "one_time" ? x.type == "one_time" : x.type != "one_time"
)
.map((x, i) => (
<PriceCard key={i} product={x} isAnnual={isAnnual} />
))
)}
</div>
</div>
</>
);
};
export default Payment;

View File

@ -29,6 +29,7 @@ export type Product = {
product_order: number;
yearlyPrice: Price;
monthlyPrice: Price;
type: string;
}
export type Price = {