stripe
This commit is contained in:
parent
d5fca433fb
commit
368d9247fd
|
@ -112,6 +112,7 @@ services:
|
||||||
- NEXT_PUBLIC_SKYNET_DASHBOARD_URL=${SKYNET_DASHBOARD_URL}
|
- NEXT_PUBLIC_SKYNET_DASHBOARD_URL=${SKYNET_DASHBOARD_URL}
|
||||||
- NEXT_PUBLIC_KRATOS_BROWSER_URL=${SKYNET_DASHBOARD_URL}/.ory/kratos/public
|
- NEXT_PUBLIC_KRATOS_BROWSER_URL=${SKYNET_DASHBOARD_URL}/.ory/kratos/public
|
||||||
- NEXT_PUBLIC_KRATOS_PUBLIC_URL=${SKYNET_DASHBOARD_URL}/.ory/kratos/public
|
- NEXT_PUBLIC_KRATOS_PUBLIC_URL=${SKYNET_DASHBOARD_URL}/.ory/kratos/public
|
||||||
|
- NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY}
|
||||||
networks:
|
networks:
|
||||||
shared:
|
shared:
|
||||||
ipv4_address: 10.10.10.85
|
ipv4_address: 10.10.10.85
|
||||||
|
|
14
package.json
14
package.json
|
@ -1,6 +1,12 @@
|
||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
]
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/forms": "^0.2.1",
|
||||||
|
"autoprefixer": "^10.2.4",
|
||||||
|
"postcss": "^8.2.6",
|
||||||
|
"tailwindcss": "^2.0.3"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"skynet-js": "^3.0.0",
|
"skynet-js": "^3.0.0",
|
||||||
"square": "^8.1.1",
|
"square": "^8.1.1",
|
||||||
|
"stripe": "^8.137.0",
|
||||||
"superagent": "^6.1.0",
|
"superagent": "^6.1.0",
|
||||||
"swr": "^0.4.1",
|
"swr": "^0.4.1",
|
||||||
"tailwindcss": "^2.0.3",
|
"tailwindcss": "^2.0.3",
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Client, Environment } from "square";
|
||||||
|
import { StatusCodes } from "http-status-codes";
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
environment: Environment.Sandbox,
|
||||||
|
accessToken: process.env.SQUARE_ACCESS_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
GET: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = "NBE7TRXZPGZXNBD64JB6DR5AGR"; // req.headers["x-user"];
|
||||||
|
|
||||||
|
// create subscriptions search query
|
||||||
|
const query = { filter: { customerIds: [user] } };
|
||||||
|
|
||||||
|
// query subscriptions with given search criteria
|
||||||
|
const { result: subscriptionsResponse } = await client.subscriptionsApi.searchSubscriptions({ query });
|
||||||
|
const { subscriptions } = subscriptionsResponse;
|
||||||
|
|
||||||
|
// get active subscription
|
||||||
|
const subscription = subscriptions.find(({ status }) => status === "ACTIVE");
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return res.status(StatusCodes.NO_CONTENT).end(); // no active subscription found
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("....", subscription);
|
||||||
|
|
||||||
|
return res.json(subscription);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
console.log(error?.errors);
|
||||||
|
|
||||||
|
return res.status(StatusCodes.BAD_REQUEST).end(); // todo: error handling
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (req, res) => {
|
||||||
|
if (req.method in api) {
|
||||||
|
return api[req.method](req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(StatusCodes.NOT_FOUND).end();
|
||||||
|
};
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { Client, Environment } from "square";
|
||||||
|
import { StatusCodes } from "http-status-codes";
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
environment: Environment.Sandbox,
|
||||||
|
accessToken: process.env.SQUARE_ACCESS_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelSubscription = async (id) => {
|
||||||
|
const { result: subscriptionsResponse } = await client.subscriptionsApi.cancelSubscription(id);
|
||||||
|
const { subscription } = subscriptionsResponse;
|
||||||
|
|
||||||
|
return subscription;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActiveSubscription = async (customerId) => {
|
||||||
|
// create subscriptions search query
|
||||||
|
const query = { filter: { customerIds: [customerId] } };
|
||||||
|
|
||||||
|
// query subscriptions with given search criteria
|
||||||
|
const { result: subscriptionsResponse } = await client.subscriptionsApi.searchSubscriptions({ query });
|
||||||
|
const { subscriptions } = subscriptionsResponse;
|
||||||
|
|
||||||
|
// get active subscription with a set cancellation date
|
||||||
|
return subscriptions.find(({ status, canceledDate }) => status === "ACTIVE" && !canceledDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
POST: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = "NBE7TRXZPGZXNBD64JB6DR5AGR"; // req.headers["x-user"];
|
||||||
|
const subscription = await getActiveSubscription(user);
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return res.status(StatusCodes.BAD_REQUEST).end(); // no active subscription found
|
||||||
|
}
|
||||||
|
|
||||||
|
const canceledSubscription = await cancelSubscription(subscription.id);
|
||||||
|
|
||||||
|
return res.json(canceledSubscription);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error.errors);
|
||||||
|
|
||||||
|
return res.status(StatusCodes.BAD_REQUEST).end(); // todo: error handling
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (req, res) => {
|
||||||
|
if (req.method in api) {
|
||||||
|
return api[req.method](req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(StatusCodes.NOT_FOUND).end();
|
||||||
|
};
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { Client, Environment } from "square";
|
||||||
|
import { StatusCodes } from "http-status-codes";
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
environment: Environment.Sandbox,
|
||||||
|
accessToken: process.env.SQUARE_ACCESS_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSubscription = async (id, body) => {
|
||||||
|
const { result: subscriptionsResponse } = await client.subscriptionsApi.updateSubscription(id, body);
|
||||||
|
const { subscription } = subscriptionsResponse;
|
||||||
|
|
||||||
|
return subscription;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActiveCanceledSubscription = async (customerId) => {
|
||||||
|
// create subscriptions search query
|
||||||
|
const query = { filter: { customerIds: [customerId] } };
|
||||||
|
|
||||||
|
// query subscriptions with given search criteria
|
||||||
|
const { result: subscriptionsResponse } = await client.subscriptionsApi.searchSubscriptions({ query });
|
||||||
|
const { subscriptions } = subscriptionsResponse;
|
||||||
|
|
||||||
|
// get active subscription with a set cancellation date
|
||||||
|
return subscriptions.find(({ status, canceledDate }) => status === "ACTIVE" && canceledDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
POST: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = "NBE7TRXZPGZXNBD64JB6DR5AGR"; // req.headers["x-user"];
|
||||||
|
const subscription = await getActiveCanceledSubscription(user);
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return res.status(StatusCodes.BAD_REQUEST).end(); // no active subscription with cancel date found
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the subscription setting empty canceledDate
|
||||||
|
const updatedSubscription = await updateSubscription(subscription.id, {
|
||||||
|
subscription: { ...subscription, canceledDate: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(updatedSubscription);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error.errors);
|
||||||
|
|
||||||
|
return res.status(StatusCodes.BAD_REQUEST).end(); // todo: error handling
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (req, res) => {
|
||||||
|
if (req.method in api) {
|
||||||
|
return api[req.method](req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(StatusCodes.NOT_FOUND).end();
|
||||||
|
};
|
|
@ -0,0 +1,16 @@
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { StatusCodes } from "http-status-codes";
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stripeCustomerId = "cus_J09ECKPgFEPXoq";
|
||||||
|
const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId);
|
||||||
|
const { subscriptions } = stripeCustomer;
|
||||||
|
|
||||||
|
res.json(subscriptions);
|
||||||
|
} catch ({ message }) {
|
||||||
|
res.status(StatusCodes.BAD_REQUEST).json({ error: { message } });
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,41 @@
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { StatusCodes } from "http-status-codes";
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return res.status(StatusCodes.NOT_FOUND).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { price } = req.body;
|
||||||
|
|
||||||
|
if (!price) {
|
||||||
|
return res.status(StatusCodes.BAD_REQUEST).json({ error: { message: "Missing 'price' attribute" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new Checkout Session for the order
|
||||||
|
// Other optional params include:
|
||||||
|
// [billing_address_collection] - to display billing address details on the page
|
||||||
|
// [customer] - if you have an existing Stripe Customer ID
|
||||||
|
// [customer_email] - lets you prefill the email input in the form
|
||||||
|
// For full details see https://stripe.com/docs/api/checkout/sessions/create
|
||||||
|
try {
|
||||||
|
const stripeCustomerId = "cus_J09ECKPgFEPXoq";
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
mode: "subscription",
|
||||||
|
payment_method_types: ["card"],
|
||||||
|
line_items: [{ price, quantity: 1 }],
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
// ?session_id={CHECKOUT_SESSION_ID} means the redirect will have the session ID set as a query param
|
||||||
|
success_url: `${process.env.SKYNET_DASHBOARD_URL}/payments?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${process.env.SKYNET_DASHBOARD_URL}/payments`,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(session);
|
||||||
|
|
||||||
|
res.json({ sessionId: session.id });
|
||||||
|
} catch ({ message }) {
|
||||||
|
res.status(StatusCodes.BAD_REQUEST).json({ error: { message } });
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,18 @@
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { StatusCodes } from "http-status-codes";
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stripeCustomerId = "cus_J09ECKPgFEPXoq";
|
||||||
|
const session = await stripe.billingPortal.sessions.create({
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
return_url: `${process.env.SKYNET_DASHBOARD_URL}/payments`,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.redirect(session.url);
|
||||||
|
} catch ({ message }) {
|
||||||
|
res.status(StatusCodes.BAD_REQUEST).json({ error: { message } });
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,263 @@
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import Layout from "../components/Layout";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import ky from "ky/umd";
|
||||||
|
import { CardElement } from "@stripe/react-stripe-js";
|
||||||
|
|
||||||
|
const plans = [
|
||||||
|
{ id: "initial_free", name: "Free", price: 0, description: "Unlimited bandwidth with throttled speed" },
|
||||||
|
{ id: "initial_plus", name: "Skynet Plus", price: 5, description: "1 TB premium bandwidth with full speed" },
|
||||||
|
{ id: "initial_pro", name: "Skynet Pro", price: 20, description: "5 TB premium bandwidth with full speed" },
|
||||||
|
{ id: "initial_extreme", name: "Skynet Extreme", price: 80, description: "20 TB premium bandwidth with full speed" },
|
||||||
|
];
|
||||||
|
const currentlyActivePlan = "initial_free";
|
||||||
|
|
||||||
|
const fetcher = (url) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
export default function Payments() {
|
||||||
|
const { data: invoices } = useSWR("/api/square/invoices", fetcher);
|
||||||
|
const { data: subscription, mutate: mutateSubscription } = useSWR("/api/square/subscription", fetcher);
|
||||||
|
const handleSubscriptionCancel = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const subscription = await ky.post("/api/square/subscription/cancel").json();
|
||||||
|
|
||||||
|
mutateSubscription(subscription, false);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleSubscriptionRestore = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const subscription = await ky.post("/api/square/subscription/restore").json();
|
||||||
|
|
||||||
|
mutateSubscription(subscription, false);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Payments">
|
||||||
|
<div className="bg-white rounded-lg shadow px-5 py-6 sm:px-6">
|
||||||
|
<div className="space-y-6 sm:px-6 lg:px-0 lg:col-span-9">
|
||||||
|
{/* This example requires Tailwind CSS v2.0+ */}
|
||||||
|
<dl className="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-3">
|
||||||
|
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div className="px-4 py-5 sm:p-6">
|
||||||
|
<dt className="text-sm font-medium text-gray-500 truncate">Current plan</dt>
|
||||||
|
<dd className="mt-1 text-3xl font-semibold text-gray-900">Free</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div className="px-4 py-5 sm:p-6">
|
||||||
|
<dt className="text-sm font-medium text-gray-500 truncate">Current payment</dt>
|
||||||
|
<dd className="mt-1 text-3xl font-semibold text-gray-900">—</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div className="px-4 py-5 sm:p-6">
|
||||||
|
<dt className="text-sm font-medium text-gray-500 truncate">Plan usage this month</dt>
|
||||||
|
<dd className="mt-1 text-3xl font-semibold text-gray-900">24.57%</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<CardElement
|
||||||
|
options={{
|
||||||
|
style: {
|
||||||
|
base: {
|
||||||
|
fontSize: "16px",
|
||||||
|
color: "#424770",
|
||||||
|
"::placeholder": {
|
||||||
|
color: "#aab7c4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invalid: {
|
||||||
|
color: "#9e2146",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Plan */}
|
||||||
|
<section aria-labelledby="plan_heading">
|
||||||
|
<form action="#" method="POST">
|
||||||
|
<div className="shadow sm:rounded-md sm:overflow-hidden">
|
||||||
|
<div className="bg-white py-6 px-4 space-y-6 sm:p-6">
|
||||||
|
<div className="-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap">
|
||||||
|
<div className="ml-4 mt-2">
|
||||||
|
<h3 id="plan_heading" className="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
Plan
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<fieldset>
|
||||||
|
<legend className="sr-only">Pricing plans</legend>
|
||||||
|
<ul className="relative bg-white rounded-md -space-y-px">
|
||||||
|
{plans.map((plan, index) => (
|
||||||
|
<li key={plan.id}>
|
||||||
|
{/* On: "bg-orange-50 border-orange-200 z-10", Off: "border-gray-200" */}
|
||||||
|
<div
|
||||||
|
className={`relative border ${index === 0 ? "rounded-tl-md rounded-tr-md" : ""} ${
|
||||||
|
index === plans.length - 1 ? "rounded-bl-md rounded-br-md" : ""
|
||||||
|
} p-4 flex flex-col md:pl-4 md:pr-6 md:grid md:grid-cols-3`}
|
||||||
|
>
|
||||||
|
<label className="flex items-center text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
name="pricing_plan"
|
||||||
|
type="radio"
|
||||||
|
className="h-4 w-4 text-orange-500 cursor-pointer focus:ring-gray-900 border-gray-300"
|
||||||
|
aria-describedby="plan-option-pricing-0 plan-option-limit-0"
|
||||||
|
defaultChecked
|
||||||
|
/>
|
||||||
|
<span className="ml-3 font-medium text-gray-900">{plan.name}</span>
|
||||||
|
</label>
|
||||||
|
<p id="plan-option-pricing-0" className="ml-6 pl-1 text-sm md:ml-0 md:pl-0 md:text-center">
|
||||||
|
{/* On: "text-orange-900", Off: "text-gray-900" */}
|
||||||
|
<span className="font-medium">{plan.price ? `$${plan.price} / mo` : "no cost"}</span>
|
||||||
|
{/* On: "text-orange-700", Off: "text-gray-500" */}
|
||||||
|
{/* <span>($290 / yr)</span> */}
|
||||||
|
</p>
|
||||||
|
{/* On: "text-orange-700", Off: "text-gray-500" */}
|
||||||
|
<p id="plan-option-limit-0" className="ml-6 pl-1 text-sm md:ml-0 md:pl-0 md:text-right">
|
||||||
|
{plan.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</fieldset>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="text-sm font-medium text-gray-900">Currently active plan:</span>
|
||||||
|
<span className="text-sm ml-3">{subscription ? subscription.planId : "Free"}</span>
|
||||||
|
{subscription && (
|
||||||
|
<span className="text-sm text-gray-500 ml-3">
|
||||||
|
paid until {subscription.paidUntilDate} -{" "}
|
||||||
|
{subscription.canceledDate ? (
|
||||||
|
<>
|
||||||
|
cancelled on {subscription.canceledDate} -{" "}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={handleSubscriptionRestore}
|
||||||
|
className="text-sm text-green-600 hover:text-green-900"
|
||||||
|
>
|
||||||
|
restore subscription
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={handleSubscriptionCancel}
|
||||||
|
className="text-sm text-green-600 hover:text-green-900"
|
||||||
|
>
|
||||||
|
cancel subscription
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 bg-gray-50 text-right sm:px-6">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-gray-800 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900"
|
||||||
|
>
|
||||||
|
Subscribe to selected plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{/* Billing history */}
|
||||||
|
<section aria-labelledby="billing_history_heading">
|
||||||
|
<div className="bg-white pt-6 shadow sm:rounded-md sm:overflow-hidden">
|
||||||
|
<div className="px-4 sm:px-6">
|
||||||
|
<h2 id="billing_history_heading" className="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
Billing history
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex flex-col">
|
||||||
|
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||||
|
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||||
|
<div className="overflow-hidden border-t border-gray-200">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Invoice
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
{/*
|
||||||
|
`relative` is added here due to a weird bug in Safari that causes `sr-only` headings to introduce overflow on the body on mobile.
|
||||||
|
*/}
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="relative px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
<span className="sr-only">View receipt</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{invoices &&
|
||||||
|
invoices.map((invoice) => (
|
||||||
|
<tr key={invoice.id}>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
{dayjs(invoice.createdAt).format("DD/MM/YYYY")}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{invoice.title}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{invoice.status}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<a
|
||||||
|
href={invoice.publicUrl}
|
||||||
|
className="text-green-600 hover:text-green-900"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
View invoice
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,19 +1,59 @@
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import Layout from "../components/Layout";
|
import Layout from "../components/Layout";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import ky from "ky/umd";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
const plans = [
|
const plans = [
|
||||||
{ id: "initial_free", name: "Free", price: 0, description: "Unlimited bandwidth with throttled speed" },
|
{ id: "initial_free", stripe: null, name: "Free", price: 0, description: "Unlimited bandwidth with throttled speed" },
|
||||||
{ id: "initial_plus", name: "Skynet Plus", price: 5, description: "1 TB premium bandwidth with full speed" },
|
{
|
||||||
{ id: "initial_pro", name: "Skynet Pro", price: 20, description: "5 TB premium bandwidth with full speed" },
|
id: "initial_plus",
|
||||||
{ id: "initial_extreme", name: "Skynet Extreme", price: 80, description: "20 TB premium bandwidth with full speed" },
|
stripe: "price_1IO6FpIzjULiPWN6XHIG5mU9",
|
||||||
|
name: "Skynet Plus",
|
||||||
|
price: 5,
|
||||||
|
description: "1 TB premium bandwidth with full speed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "initial_pro",
|
||||||
|
stripe: "price_1IO6FpIzjULiPWN6xYjmUuGb",
|
||||||
|
name: "Skynet Pro",
|
||||||
|
price: 20,
|
||||||
|
description: "5 TB premium bandwidth with full speed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "initial_extreme",
|
||||||
|
stripe: "price_1IO6FpIzjULiPWN636iFN02j",
|
||||||
|
name: "Skynet Extreme",
|
||||||
|
price: 80,
|
||||||
|
description: "20 TB premium bandwidth with full speed",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const currentlyActivePlan = "initial_free";
|
const stripeCustomerId = "cus_J09ECKPgFEPXoq";
|
||||||
|
const activePlanId = "initial_free";
|
||||||
|
|
||||||
const fetcher = (url) => fetch(url).then((r) => r.json());
|
const fetcher = (url) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
|
const ActiveBadge = () => {
|
||||||
|
return (
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs bg-green-100 text-green-800 ml-3">
|
||||||
|
active
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function Payments() {
|
export default function Payments() {
|
||||||
const { data: invoices, error } = useSWR("/api/square/invoices", fetcher);
|
const [selectedPlanId, setSelectedPlanId] = useState("initial_free");
|
||||||
|
const selectedPlan = plans.find(({ id }) => selectedPlanId === id);
|
||||||
|
const handleSubscribe = async () => {
|
||||||
|
try {
|
||||||
|
const price = selectedPlan.stripe;
|
||||||
|
const { sessionId } = await ky.post("/api/stripe/createCheckoutSession", { json: { price } }).json();
|
||||||
|
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
|
||||||
|
await stripe.redirectToCheckout({ sessionId });
|
||||||
|
} catch (error) {
|
||||||
|
// todo: handle error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="Payments">
|
<Layout title="Payments">
|
||||||
|
@ -70,9 +110,11 @@ export default function Payments() {
|
||||||
type="radio"
|
type="radio"
|
||||||
className="h-4 w-4 text-orange-500 cursor-pointer focus:ring-gray-900 border-gray-300"
|
className="h-4 w-4 text-orange-500 cursor-pointer focus:ring-gray-900 border-gray-300"
|
||||||
aria-describedby="plan-option-pricing-0 plan-option-limit-0"
|
aria-describedby="plan-option-pricing-0 plan-option-limit-0"
|
||||||
defaultChecked
|
checked={plan.id === selectedPlanId}
|
||||||
|
onChange={() => console.log(plan.id) || setSelectedPlanId(plan.id)}
|
||||||
/>
|
/>
|
||||||
<span className="ml-3 font-medium text-gray-900">{plan.name}</span>
|
<span className="ml-3 font-medium text-gray-900">{plan.name}</span>
|
||||||
|
{activePlanId === plan.id && <ActiveBadge />}
|
||||||
</label>
|
</label>
|
||||||
<p id="plan-option-pricing-0" className="ml-6 pl-1 text-sm md:ml-0 md:pl-0 md:text-center">
|
<p id="plan-option-pricing-0" className="ml-6 pl-1 text-sm md:ml-0 md:pl-0 md:text-center">
|
||||||
{/* On: "text-orange-900", Off: "text-gray-900" */}
|
{/* On: "text-orange-900", Off: "text-gray-900" */}
|
||||||
|
@ -89,114 +131,26 @@ export default function Payments() {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-sm font-medium text-gray-900">Currently active plan:</span>
|
|
||||||
<span className="text-sm ml-3">Free</span>
|
|
||||||
<span className="text-sm text-gray-500 ml-3">
|
|
||||||
(next payment $5 on 10/10/2020 -{" "}
|
|
||||||
<a
|
|
||||||
href="/api/square/subscriptions/cancel"
|
|
||||||
className="text-sm text-green-600 hover:text-green-900"
|
|
||||||
>
|
|
||||||
cancel
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-3 bg-gray-50 text-right sm:px-6">
|
<div className="px-4 py-3 bg-gray-50 text-right sm:px-6">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
className="bg-gray-800 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900"
|
onClick={handleSubscribe}
|
||||||
|
disabled={activePlanId === selectedPlanId}
|
||||||
|
className="bg-green-800 disabled:bg-gray-300 disabled:text-gray-400 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-green-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900"
|
||||||
>
|
>
|
||||||
Subscribe to selected plan
|
Subscribe
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 text-center my-3">
|
||||||
|
To manage your active subscription, payment methods and view your billing history, go to{" "}
|
||||||
|
<a href="/api/stripe/customerPortal" className="text-green-600 hover:text-green-900">
|
||||||
|
Stripe Customer Portal
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{/* Billing history */}
|
|
||||||
<section aria-labelledby="billing_history_heading">
|
|
||||||
<div className="bg-white pt-6 shadow sm:rounded-md sm:overflow-hidden">
|
|
||||||
<div className="px-4 sm:px-6">
|
|
||||||
<h2 id="billing_history_heading" className="text-lg leading-6 font-medium text-gray-900">
|
|
||||||
Billing history
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 flex flex-col">
|
|
||||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
|
||||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
|
||||||
<div className="overflow-hidden border-t border-gray-200">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Date
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Invoice
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Description
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
{/*
|
|
||||||
`relative` is added here due to a weird bug in Safari that causes `sr-only` headings to introduce overflow on the body on mobile.
|
|
||||||
*/}
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
className="relative px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
<span className="sr-only">View receipt</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{invoices &&
|
|
||||||
invoices.map((invoice) => (
|
|
||||||
<tr key={invoice.id}>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
||||||
{dayjs(invoice.createdAt).format("DD/MM/YYYY")}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
{invoice.invoiceNumber}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{invoice.title}</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{invoice.status}</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<a
|
|
||||||
href={invoice.publicUrl}
|
|
||||||
className="text-green-600 hover:text-green-900"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
View invoice
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
const colors = require("tailwindcss/colors");
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
purge: ["./src/**/*.js"],
|
purge: ["./src/**/*.js"],
|
||||||
darkMode: false, // or 'media' or 'class'
|
darkMode: false, // or 'media' or 'class'
|
||||||
|
@ -8,15 +6,13 @@ module.exports = {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["Metropolis", "Helvetica", "Arial", "Sans-Serif"],
|
sans: ["Metropolis", "Helvetica", "Arial", "Sans-Serif"],
|
||||||
},
|
},
|
||||||
colors: {
|
|
||||||
orange: colors.orange,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
backgroundColor: ["disabled"],
|
||||||
|
textColor: ["disabled"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [require("@tailwindcss/forms")],
|
||||||
// require("@tailwindcss/forms")
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
Reference in New Issue