forked from mrwyndham/fastpocket
feature - added one time payment processing
This commit is contained in:
parent
fb64f6bcd6
commit
28e5d2c9eb
Binary file not shown.
|
@ -391,23 +391,28 @@ func main() {
|
|||
form = forms.NewRecordUpsert(app, record)
|
||||
}
|
||||
|
||||
form.LoadData(map[string]any{
|
||||
"price_id": price.ID,
|
||||
"product_id": price.Product.ID,
|
||||
"active": price.Active,
|
||||
"currency": price.Currency,
|
||||
"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,
|
||||
})
|
||||
data := map[string]any{
|
||||
"price_id": price.ID,
|
||||
"product_id": price.Product.ID,
|
||||
"active": price.Active,
|
||||
"currency": price.Currency,
|
||||
"description": price.Nickname,
|
||||
"type": price.Type,
|
||||
"unit_amount": price.UnitAmount,
|
||||
"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
|
||||
|
|
|
@ -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,11 +29,12 @@ 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){
|
||||
if (priceOfProduct.interval === "year"){
|
||||
product.yearlyPrice = priceOfProduct;
|
||||
} else {
|
||||
product.monthlyPrice = priceOfProduct;
|
||||
}
|
||||
product.type = priceOfProduct.type;
|
||||
if (priceOfProduct.interval === "year"){
|
||||
product.yearlyPrice = priceOfProduct;
|
||||
} else {
|
||||
product.monthlyPrice = priceOfProduct;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}),
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -29,6 +29,7 @@ export type Product = {
|
|||
product_order: number;
|
||||
yearlyPrice: Price;
|
||||
monthlyPrice: Price;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export type Price = {
|
||||
|
|
Loading…
Reference in New Issue