feat(dashboard-v2): add Stripe integration
This commit is contained in:
parent
916a420b72
commit
5a2a2b6508
|
@ -1,5 +1,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { SWRConfig } from "swr";
|
import { SWRConfig } from "swr";
|
||||||
|
import { Elements } from "@stripe/react-stripe-js";
|
||||||
|
import { loadStripe } from "@stripe/stripe-js";
|
||||||
import "@fontsource/sora/300.css"; // light
|
import "@fontsource/sora/300.css"; // light
|
||||||
import "@fontsource/sora/400.css"; // normal
|
import "@fontsource/sora/400.css"; // normal
|
||||||
import "@fontsource/sora/500.css"; // medium
|
import "@fontsource/sora/500.css"; // medium
|
||||||
|
@ -11,15 +13,19 @@ import swrConfig from "./src/lib/swrConfig";
|
||||||
import { MODAL_ROOT_ID } from "./src/components/Modal";
|
import { MODAL_ROOT_ID } from "./src/components/Modal";
|
||||||
import { PortalSettingsProvider } from "./src/contexts/portal-settings";
|
import { PortalSettingsProvider } from "./src/contexts/portal-settings";
|
||||||
|
|
||||||
|
const stripePromise = loadStripe(process.env.GATSBY_STRIPE_PUBLISHABLE_KEY);
|
||||||
|
|
||||||
export function wrapPageElement({ element, props }) {
|
export function wrapPageElement({ element, props }) {
|
||||||
const Layout = element.type.Layout ?? React.Fragment;
|
const Layout = element.type.Layout ?? React.Fragment;
|
||||||
return (
|
return (
|
||||||
<PortalSettingsProvider>
|
<PortalSettingsProvider>
|
||||||
<SWRConfig value={swrConfig}>
|
<SWRConfig value={swrConfig}>
|
||||||
<Layout {...props}>
|
<Elements stripe={stripePromise}>
|
||||||
{element}
|
<Layout {...props}>
|
||||||
<div id={MODAL_ROOT_ID} />
|
{element}
|
||||||
</Layout>
|
<div id={MODAL_ROOT_ID} />
|
||||||
|
</Layout>
|
||||||
|
</Elements>
|
||||||
</SWRConfig>
|
</SWRConfig>
|
||||||
</PortalSettingsProvider>
|
</PortalSettingsProvider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { SWRConfig } from "swr";
|
import { SWRConfig } from "swr";
|
||||||
|
import { Elements } from "@stripe/react-stripe-js";
|
||||||
|
import { loadStripe } from "@stripe/stripe-js";
|
||||||
import "@fontsource/sora/300.css"; // light
|
import "@fontsource/sora/300.css"; // light
|
||||||
import "@fontsource/sora/400.css"; // normal
|
import "@fontsource/sora/400.css"; // normal
|
||||||
import "@fontsource/sora/500.css"; // medium
|
import "@fontsource/sora/500.css"; // medium
|
||||||
|
@ -11,15 +13,19 @@ import swrConfig from "./src/lib/swrConfig";
|
||||||
import { MODAL_ROOT_ID } from "./src/components/Modal";
|
import { MODAL_ROOT_ID } from "./src/components/Modal";
|
||||||
import { PortalSettingsProvider } from "./src/contexts/portal-settings";
|
import { PortalSettingsProvider } from "./src/contexts/portal-settings";
|
||||||
|
|
||||||
|
const stripePromise = loadStripe(process.env.GATSBY_STRIPE_PUBLISHABLE_KEY);
|
||||||
|
|
||||||
export function wrapPageElement({ element, props }) {
|
export function wrapPageElement({ element, props }) {
|
||||||
const Layout = element.type.Layout ?? React.Fragment;
|
const Layout = element.type.Layout ?? React.Fragment;
|
||||||
return (
|
return (
|
||||||
<PortalSettingsProvider>
|
<PortalSettingsProvider>
|
||||||
<SWRConfig value={swrConfig}>
|
<SWRConfig value={swrConfig}>
|
||||||
<Layout {...props}>
|
<Elements stripe={stripePromise}>
|
||||||
{element}
|
<Layout {...props}>
|
||||||
<div id={MODAL_ROOT_ID} />
|
{element}
|
||||||
</Layout>
|
<div id={MODAL_ROOT_ID} />
|
||||||
|
</Layout>
|
||||||
|
</Elements>
|
||||||
</SWRConfig>
|
</SWRConfig>
|
||||||
</PortalSettingsProvider>
|
</PortalSettingsProvider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -22,6 +22,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/sora": "^4.5.3",
|
"@fontsource/sora": "^4.5.3",
|
||||||
"@fontsource/source-sans-pro": "^4.5.3",
|
"@fontsource/source-sans-pro": "^4.5.3",
|
||||||
|
"@stripe/react-stripe-js": "^1.7.1",
|
||||||
|
"@stripe/stripe-js": "^1.27.0",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"copy-text-to-clipboard": "^3.0.1",
|
"copy-text-to-clipboard": "^3.0.1",
|
||||||
"dayjs": "^1.10.8",
|
"dayjs": "^1.10.8",
|
||||||
|
|
|
@ -19,14 +19,16 @@ const aggregatePlansAndLimits = (plans, limits, { includeFreePlan }) => {
|
||||||
|
|
||||||
// Decorate each plan with its corresponding limits data, if available.
|
// Decorate each plan with its corresponding limits data, if available.
|
||||||
if (limits?.length) {
|
if (limits?.length) {
|
||||||
return limits.map((limitsDescriptor, index) => {
|
return limits
|
||||||
const asssociatedPlan = sortedPlans.find((plan) => plan.tier === index) || {};
|
.map((limitsDescriptor, index) => {
|
||||||
|
const asssociatedPlan = sortedPlans.find((plan) => plan.tier === index) || {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...asssociatedPlan,
|
...asssociatedPlan,
|
||||||
limits: limitsDescriptor || null,
|
limits: limitsDescriptor || null,
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
|
.slice(includeFreePlan ? 1 : 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we don't have the limits data yet, set just return the plans.
|
// If we don't have the limits data yet, set just return the plans.
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import { useStripe } from "@stripe/react-stripe-js";
|
||||||
|
import cn from "classnames";
|
||||||
|
|
||||||
import { useUser } from "../contexts/user";
|
import { useUser } from "../contexts/user";
|
||||||
import { PlansProvider } from "../contexts/plans/PlansProvider";
|
import { PlansProvider } from "../contexts/plans/PlansProvider";
|
||||||
|
@ -13,7 +15,9 @@ import { usePortalSettings } from "../contexts/portal-settings";
|
||||||
import { Alert } from "../components/Alert";
|
import { Alert } from "../components/Alert";
|
||||||
import HighlightedLink from "../components/HighlightedLink";
|
import HighlightedLink from "../components/HighlightedLink";
|
||||||
import { Metadata } from "../components/Metadata";
|
import { Metadata } from "../components/Metadata";
|
||||||
|
import accountsService from "../services/accountsService";
|
||||||
import humanBytes from "../lib/humanBytes";
|
import humanBytes from "../lib/humanBytes";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
|
|
||||||
const PAID_PORTAL_BREAKPOINTS = [
|
const PAID_PORTAL_BREAKPOINTS = [
|
||||||
{
|
{
|
||||||
|
@ -77,6 +81,11 @@ const PlansSlider = () => {
|
||||||
const { user, error: userError } = useUser();
|
const { user, error: userError } = useUser();
|
||||||
const { plans, loading, activePlan, error: plansError } = useActivePlan(user);
|
const { plans, loading, activePlan, error: plansError } = useActivePlan(user);
|
||||||
const { settings } = usePortalSettings();
|
const { settings } = usePortalSettings();
|
||||||
|
const [showPaymentError, setShowPaymentError] = React.useState(true);
|
||||||
|
const stripe = useStripe();
|
||||||
|
// This will be the base plan that we compare upload/download speeds against.
|
||||||
|
// On will either be the user's active plan or lowest of available tiers.
|
||||||
|
const basePlan = activePlan || plans[0];
|
||||||
|
|
||||||
if (userError || plansError) {
|
if (userError || plansError) {
|
||||||
return (
|
return (
|
||||||
|
@ -87,6 +96,22 @@ const PlansSlider = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSubscribe = async (selectedPlan) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = await accountsService
|
||||||
|
.post("stripe/checkout", {
|
||||||
|
json: {
|
||||||
|
price: selectedPlan.stripe,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.json();
|
||||||
|
await stripe.redirectToCheckout({ sessionId });
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
setShowPaymentError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full mb-24">
|
<div className="w-full mb-24">
|
||||||
<Metadata>
|
<Metadata>
|
||||||
|
@ -108,40 +133,90 @@ const PlansSlider = () => {
|
||||||
<Slider
|
<Slider
|
||||||
slides={plans.map((plan) => {
|
slides={plans.map((plan) => {
|
||||||
const isHigherThanCurrent = plan.tier > activePlan?.tier;
|
const isHigherThanCurrent = plan.tier > activePlan?.tier;
|
||||||
|
const isCurrentPlanPaid = activePlan?.tier > 1;
|
||||||
const isCurrent = plan.tier === activePlan?.tier;
|
const isCurrent = plan.tier === activePlan?.tier;
|
||||||
|
const isLower = plan.tier < activePlan?.tier;
|
||||||
|
const speed = plan.limits.uploadBandwidth;
|
||||||
|
const currentSpeed = basePlan?.limits?.uploadBandwidth;
|
||||||
|
const speedChange = speed > currentSpeed ? speed / currentSpeed : currentSpeed / speed;
|
||||||
|
const hasActivePlan = Boolean(activePlan);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="min-h-[620px] px-6 py-10 flex flex-col">
|
<Panel
|
||||||
|
className={cn("min-h-[620px] px-6 py-10 flex flex-col relative h-full shadow-md", {
|
||||||
|
"border border-primary": isCurrent,
|
||||||
|
})}
|
||||||
|
wrapperClassName="h-full"
|
||||||
|
>
|
||||||
|
{isCurrent && (
|
||||||
|
<div className="absolute top-0 left-0 w-full h-6 bg-white px-6 rounded-t">
|
||||||
|
<span className="font-sans uppercase font-semibold text-xs bg-palette-100 px-2 py-1.5 rounded-b-md">
|
||||||
|
Current plan
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<h3>{plan.name}</h3>
|
<h3>{plan.name}</h3>
|
||||||
<Description>{plan.description}</Description>
|
<Description>{plan.description}</Description>
|
||||||
<Price price={plan.price} />
|
<Price price={plan.price} />
|
||||||
|
|
||||||
<div className="text-center my-6">
|
<div className="text-center my-6">
|
||||||
<Button $primary={isHigherThanCurrent} disabled={isCurrent}>
|
{(!hasActivePlan || isHigherThanCurrent) &&
|
||||||
{isHigherThanCurrent && "Upgrade"}
|
(isCurrentPlanPaid ? (
|
||||||
{isCurrent && "Current"}
|
<Button $primary as="a" href="/api/stripe/billing">
|
||||||
{!isHigherThanCurrent && !isCurrent && "Choose"}
|
Upgrade
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button $primary onClick={() => handleSubscribe(plan)}>
|
||||||
|
Upgrade
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{isCurrent && <Button disabled>Current</Button>}
|
||||||
|
{isLower && (
|
||||||
|
<Button as="a" href="/api/stripe/billing">
|
||||||
|
Choose
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{plan.limits && (
|
{plan.limits && (
|
||||||
<ul className="-ml-2">
|
<ul className="-ml-2">
|
||||||
<PlanSummaryItem>
|
<PlanSummaryItem>
|
||||||
Pin up to {storage(plan.limits.storageLimit)} of censorship-resistant storage
|
Pin up to {storage(plan.limits.storageLimit)} on decentralized storage
|
||||||
</PlanSummaryItem>
|
</PlanSummaryItem>
|
||||||
<PlanSummaryItem>
|
<PlanSummaryItem>
|
||||||
Support for up to {localizedNumber(plan.limits.maxNumberUploads)} files
|
Support for up to {localizedNumber(plan.limits.maxNumberUploads)} files
|
||||||
</PlanSummaryItem>
|
</PlanSummaryItem>
|
||||||
<PlanSummaryItem>{bandwidth(plan.limits.uploadBandwidth)} upload bandwidth</PlanSummaryItem>
|
<PlanSummaryItem>
|
||||||
<PlanSummaryItem>{bandwidth(plan.limits.downloadBandwidth)} download bandwidth</PlanSummaryItem>
|
{speed === currentSpeed
|
||||||
|
? `${bandwidth(plan.limits.uploadBandwidth)} upload and ${bandwidth(
|
||||||
|
plan.limits.downloadBandwidth
|
||||||
|
)} download`
|
||||||
|
: `${speedChange}X ${
|
||||||
|
speed > currentSpeed ? "faster" : "slower"
|
||||||
|
} upload and download speeds (${bandwidth(plan.limits.uploadBandwidth)} and ${bandwidth(
|
||||||
|
plan.limits.downloadBandwidth
|
||||||
|
)})`}
|
||||||
|
</PlanSummaryItem>
|
||||||
|
<PlanSummaryItem>
|
||||||
|
{plan.limits.maxUploadSize === plan.limits.storageLimit
|
||||||
|
? "No limit to file upload size"
|
||||||
|
: `Upload files up to ${storage(plan.limits.maxUploadSize)}`}
|
||||||
|
</PlanSummaryItem>
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
breakpoints={settings.isSubscriptionRequired ? PAID_PORTAL_BREAKPOINTS : FREE_PORTAL_BREAKPOINTS}
|
breakpoints={settings.isSubscriptionRequired ? PAID_PORTAL_BREAKPOINTS : FREE_PORTAL_BREAKPOINTS}
|
||||||
className="px-8 sm:px-4 md:px-0 lg:px-0"
|
className="px-8 sm:px-4 md:px-0 lg:px-0 mt-10"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showPaymentError && (
|
||||||
|
<Modal onClose={() => setShowPaymentError(false)}>
|
||||||
|
<h3>Oops! 😔</h3>
|
||||||
|
<p className="font-semibold">There was an error contacting our payments provider</p>
|
||||||
|
<p>Please try again later</p>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -3107,6 +3107,18 @@
|
||||||
resolve-from "^5.0.0"
|
resolve-from "^5.0.0"
|
||||||
store2 "^2.12.0"
|
store2 "^2.12.0"
|
||||||
|
|
||||||
|
"@stripe/react-stripe-js@^1.7.1":
|
||||||
|
version "1.7.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-1.7.1.tgz#6e1db8f4a0eaf2193b153173d4aa7c38b681310d"
|
||||||
|
integrity sha512-GiUPoMo0xVvmpRD6JR9JAhAZ0W3ZpnYZNi0KE+91+tzrSFVpChKZbeSsJ5InlZhHFk9NckJCt1wOYBTqNsvt3A==
|
||||||
|
dependencies:
|
||||||
|
prop-types "^15.7.2"
|
||||||
|
|
||||||
|
"@stripe/stripe-js@^1.27.0":
|
||||||
|
version "1.27.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.27.0.tgz#ab0c82fa89fd40260de4414f69868b769e810550"
|
||||||
|
integrity sha512-SEiybUBu+tlsFKuzdFFydxxjkbrdzHo0tz/naYC5Dt9or/Ux2gcKJBPYQ4RmqQCNHFxgyNj6UYsclywwhe2inQ==
|
||||||
|
|
||||||
"@szmarczak/http-timer@^1.1.2":
|
"@szmarczak/http-timer@^1.1.2":
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421"
|
resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421"
|
||||||
|
|
Reference in New Issue