mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-23 15:09:33 +01:00
web: migrate to paddle billing + new plans
This commit is contained in:
1374
apps/web/package-lock.json
generated
1374
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@
|
|||||||
"@notesnook/themes-server": "file:../../servers/themes",
|
"@notesnook/themes-server": "file:../../servers/themes",
|
||||||
"@notesnook/ui": "file:../../packages/ui",
|
"@notesnook/ui": "file:../../packages/ui",
|
||||||
"@notesnook/web-clipper": "file:../../extensions/web-clipper",
|
"@notesnook/web-clipper": "file:../../extensions/web-clipper",
|
||||||
|
"@paddle/paddle-js": "^1.4.2",
|
||||||
"@react-pdf-viewer/core": "^3.12.0",
|
"@react-pdf-viewer/core": "^3.12.0",
|
||||||
"@react-pdf-viewer/toolbar": "^3.12.0",
|
"@react-pdf-viewer/toolbar": "^3.12.0",
|
||||||
"@rehookify/datepicker": "^6.6.7",
|
"@rehookify/datepicker": "^6.6.7",
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ export type Routes = keyof typeof routes;
|
|||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
"/checkout": {
|
"/checkout": {
|
||||||
component: () => import("./views/checkout"),
|
component: () => import("./views/checkout")
|
||||||
|
},
|
||||||
|
"/payments": {
|
||||||
|
component: () => import("./views/payments"),
|
||||||
props: {}
|
props: {}
|
||||||
},
|
},
|
||||||
"/account/recovery": {
|
"/account/recovery": {
|
||||||
@@ -96,6 +99,7 @@ const routes = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const sessionExpiryExceptions: Routes[] = [
|
const sessionExpiryExceptions: Routes[] = [
|
||||||
|
"/payments",
|
||||||
"/recover",
|
"/recover",
|
||||||
"/account/recovery",
|
"/account/recovery",
|
||||||
"/sessionexpired",
|
"/sessionexpired",
|
||||||
|
|||||||
@@ -39,14 +39,23 @@ async function initializeDatabase(persistence: DatabasePersistence) {
|
|||||||
await useKeyStore.getState().setValue("databaseKey", databaseKey);
|
await useKeyStore.getState().setValue("databaseKey", databaseKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// db.host({
|
||||||
|
// API_HOST: "https://api.notesnook.com",
|
||||||
|
// AUTH_HOST: "https://auth.streetwriters.co",
|
||||||
|
// SSE_HOST: "https://events.streetwriters.co",
|
||||||
|
// ISSUES_HOST: "https://issues.streetwriters.co",
|
||||||
|
// MONOGRAPH_HOST: "https://monogr.ph",
|
||||||
|
// SUBSCRIPTIONS_HOST: "https://subscriptions.streetwriters.co",
|
||||||
|
// ...Config.get("serverUrls", {})
|
||||||
|
// });
|
||||||
|
const base = `http://localhost`;
|
||||||
db.host({
|
db.host({
|
||||||
API_HOST: "https://api.notesnook.com",
|
API_HOST: `${base}:5264`,
|
||||||
AUTH_HOST: "https://auth.streetwriters.co",
|
AUTH_HOST: `${base}:8264`,
|
||||||
SSE_HOST: "https://events.streetwriters.co",
|
SSE_HOST: `${base}:7264`,
|
||||||
ISSUES_HOST: "https://issues.streetwriters.co",
|
ISSUES_HOST: `${base}:2624`,
|
||||||
MONOGRAPH_HOST: "https://monogr.ph",
|
SUBSCRIPTIONS_HOST: `${base}:9264`,
|
||||||
SUBSCRIPTIONS_HOST: "https://subscriptions.streetwriters.co",
|
MONOGRAPH_HOST: `${base}:6264`
|
||||||
...Config.get("serverUrls", {})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const storage = new NNStorage(
|
const storage = new NNStorage(
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ import { CREATE_BUTTON_MAP } from "../../common";
|
|||||||
import { useStore as useNotebookStore } from "../../stores/notebook-store";
|
import { useStore as useNotebookStore } from "../../stores/notebook-store";
|
||||||
import { useStore as useTagStore } from "../../stores/tag-store";
|
import { useStore as useTagStore } from "../../stores/tag-store";
|
||||||
import { showSortMenu } from "../group-header";
|
import { showSortMenu } from "../group-header";
|
||||||
|
import { BuyDialog } from "../../dialogs/buy-dialog";
|
||||||
|
|
||||||
type Route = {
|
type Route = {
|
||||||
id: "notes" | "favorites" | "reminders" | "monographs" | "trash" | "archive";
|
id: "notes" | "favorites" | "reminders" | "monographs" | "trash" | "archive";
|
||||||
@@ -792,6 +793,7 @@ function NavigationDropdown() {
|
|||||||
title: strings.upgradeToPro(),
|
title: strings.upgradeToPro(),
|
||||||
icon: Pro.path,
|
icon: Pro.path,
|
||||||
key: "upgrade",
|
key: "upgrade",
|
||||||
|
onClick: () => BuyDialog.show({}),
|
||||||
isHidden: notLoggedIn || isPro
|
isHidden: notLoggedIn || isPro
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,9 +29,13 @@ import Field from "../../components/field";
|
|||||||
import { hardNavigate } from "../../navigation";
|
import { hardNavigate } from "../../navigation";
|
||||||
import { Features } from "./features";
|
import { Features } from "./features";
|
||||||
import { PaddleCheckout } from "./paddle";
|
import { PaddleCheckout } from "./paddle";
|
||||||
import { Period, Plan, PricingInfo } from "./types";
|
import { Period, Plan, PlanId, Price, PricingInfo } from "./types";
|
||||||
import { PLAN_METADATA, usePlans } from "./plans";
|
import { usePlans } from "./plans";
|
||||||
import { formatPeriod, getFullPeriod, PlansList } from "./plan-list";
|
import {
|
||||||
|
formatRecurringPeriodShort,
|
||||||
|
getFullPeriod,
|
||||||
|
PlansList
|
||||||
|
} from "./plan-list";
|
||||||
import { showToast } from "../../utils/toast";
|
import { showToast } from "../../utils/toast";
|
||||||
import { TaskManager } from "../../common/task-manager";
|
import { TaskManager } from "../../common/task-manager";
|
||||||
import { db } from "../../common/db";
|
import { db } from "../../common/db";
|
||||||
@@ -48,7 +52,8 @@ import { strings } from "@notesnook/intl";
|
|||||||
|
|
||||||
type BuyDialogProps = BaseDialogProps<false> & {
|
type BuyDialogProps = BaseDialogProps<false> & {
|
||||||
couponCode?: string;
|
couponCode?: string;
|
||||||
plan?: "monthly" | "yearly" | "education";
|
plan?: PlanId;
|
||||||
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BuyDialog = DialogManager.register(function BuyDialog(
|
export const BuyDialog = DialogManager.register(function BuyDialog(
|
||||||
@@ -110,7 +115,7 @@ export const BuyDialog = DialogManager.register(function BuyDialog(
|
|||||||
>
|
>
|
||||||
<CheckoutSideBar
|
<CheckoutSideBar
|
||||||
onClose={() => onClose(false)}
|
onClose={() => onClose(false)}
|
||||||
initialPlan={plan}
|
initialPlan={plan || "free"}
|
||||||
user={user}
|
user={user}
|
||||||
/>
|
/>
|
||||||
</ScopedThemeProvider>
|
</ScopedThemeProvider>
|
||||||
@@ -121,7 +126,7 @@ export const BuyDialog = DialogManager.register(function BuyDialog(
|
|||||||
});
|
});
|
||||||
|
|
||||||
type SideBarProps = {
|
type SideBarProps = {
|
||||||
initialPlan?: Period;
|
initialPlan: PlanId;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
user?: User;
|
user?: User;
|
||||||
};
|
};
|
||||||
@@ -130,6 +135,7 @@ export function CheckoutSideBar(props: SideBarProps) {
|
|||||||
const [showPlans, setShowPlans] = useState(false);
|
const [showPlans, setShowPlans] = useState(false);
|
||||||
const onPlanSelected = useCheckoutStore((state) => state.selectPlan);
|
const onPlanSelected = useCheckoutStore((state) => state.selectPlan);
|
||||||
const selectedPlan = useCheckoutStore((state) => state.selectedPlan);
|
const selectedPlan = useCheckoutStore((state) => state.selectedPlan);
|
||||||
|
const selectedPrice = useCheckoutStore((state) => state.selectedPrice);
|
||||||
const pricingInfo = useCheckoutStore((state) => state.pricingInfo);
|
const pricingInfo = useCheckoutStore((state) => state.pricingInfo);
|
||||||
const couponCode = useCheckoutStore((store) => store.couponCode);
|
const couponCode = useCheckoutStore((store) => store.couponCode);
|
||||||
const onApplyCoupon = useCheckoutStore((store) => store.applyCoupon);
|
const onApplyCoupon = useCheckoutStore((store) => store.applyCoupon);
|
||||||
@@ -137,10 +143,11 @@ export function CheckoutSideBar(props: SideBarProps) {
|
|||||||
|
|
||||||
if (isCheckoutCompleted) return <CheckoutCompleted onClose={onClose} />;
|
if (isCheckoutCompleted) return <CheckoutCompleted onClose={onClose} />;
|
||||||
|
|
||||||
if (user && selectedPlan)
|
if (user && selectedPlan && selectedPrice)
|
||||||
return (
|
return (
|
||||||
<SelectedPlan
|
<SelectedPlan
|
||||||
plan={selectedPlan}
|
plan={selectedPlan}
|
||||||
|
price={selectedPrice}
|
||||||
pricingInfo={pricingInfo}
|
pricingInfo={pricingInfo}
|
||||||
onChangePlan={() => {
|
onChangePlan={() => {
|
||||||
onApplyCoupon(undefined);
|
onApplyCoupon(undefined);
|
||||||
@@ -161,13 +168,14 @@ export function CheckoutSideBar(props: SideBarProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user && (showPlans || !!initialPlan))
|
if (user)
|
||||||
return (
|
return (
|
||||||
<PlansList
|
<PlansList
|
||||||
|
selectedPlan={selectedPlan?.id || initialPlan || "free"}
|
||||||
onPlansLoaded={(plans) => {
|
onPlansLoaded={(plans) => {
|
||||||
if (!initialPlan || showPlans) return;
|
// if (!initialPlan || showPlans) return;
|
||||||
const plan = plans.find((p) => p.period === initialPlan);
|
// const plan = plans.find((p) => p.id === initialPlan);
|
||||||
onPlanSelected(plan);
|
// onPlanSelected(plan);
|
||||||
}}
|
}}
|
||||||
onPlanSelected={onPlanSelected}
|
onPlanSelected={onPlanSelected}
|
||||||
/>
|
/>
|
||||||
@@ -207,25 +215,23 @@ export function CheckoutDetails({
|
|||||||
user?: { id: string; email: string };
|
user?: { id: string; email: string };
|
||||||
}) {
|
}) {
|
||||||
const selectedPlan = useCheckoutStore((state) => state.selectedPlan);
|
const selectedPlan = useCheckoutStore((state) => state.selectedPlan);
|
||||||
|
const selectedPrice = useCheckoutStore((state) => state.selectedPrice);
|
||||||
const onPriceUpdated = useCheckoutStore((state) => state.updatePrice);
|
const onPriceUpdated = useCheckoutStore((state) => state.updatePrice);
|
||||||
const completeCheckout = useCheckoutStore((state) => state.completeCheckout);
|
const completeCheckout = useCheckoutStore((state) => state.completeCheckout);
|
||||||
const isCheckoutCompleted = useCheckoutStore((store) => store.isCompleted);
|
const isCheckoutCompleted = useCheckoutStore((store) => store.isCompleted);
|
||||||
const couponCode = useCheckoutStore((store) => store.couponCode);
|
const couponCode = useCheckoutStore((store) => store.couponCode);
|
||||||
const setIsApplyingCoupon = useCheckoutStore(
|
|
||||||
(store) => store.setIsApplyingCoupon
|
|
||||||
);
|
|
||||||
const theme = useThemeStore((store) => store.colorScheme);
|
const theme = useThemeStore((store) => store.colorScheme);
|
||||||
if (isCheckoutCompleted) return null;
|
if (isCheckoutCompleted) return null;
|
||||||
|
|
||||||
if (selectedPlan && user)
|
if (selectedPlan && user && selectedPrice)
|
||||||
return (
|
return (
|
||||||
<PaddleCheckout
|
<PaddleCheckout
|
||||||
plan={selectedPlan}
|
plan={selectedPlan}
|
||||||
|
price={selectedPrice}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
user={user}
|
user={user}
|
||||||
coupon={couponCode}
|
coupon={couponCode}
|
||||||
onCompleted={completeCheckout}
|
onCompleted={completeCheckout}
|
||||||
onCouponApplied={() => setIsApplyingCoupon(true)}
|
|
||||||
onPriceUpdated={(pricingInfo) => {
|
onPriceUpdated={(pricingInfo) => {
|
||||||
onPriceUpdated(pricingInfo);
|
onPriceUpdated(pricingInfo);
|
||||||
// console.log(
|
// console.log(
|
||||||
@@ -269,9 +275,9 @@ function TrialOrUpgrade(props: TrialOrUpgradeProps) {
|
|||||||
<Loading sx={{ mt: 4 }} />
|
<Loading sx={{ mt: 4 }} />
|
||||||
) : (
|
) : (
|
||||||
<Text variant={"body"} mt={4} sx={{ fontSize: "title" }}>
|
<Text variant={"body"} mt={4} sx={{ fontSize: "title" }}>
|
||||||
Starting from {getCurrencySymbol(plan.currency)}
|
{/* Starting from {getCurrencySymbol(plan.currency)}
|
||||||
{plan.price.gross}
|
{plan.price.gross}
|
||||||
{formatPeriod(plan.period)}
|
{formatPeriod(plan.period)} */}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{isMacStoreApp() ? (
|
{isMacStoreApp() ? (
|
||||||
@@ -404,12 +410,12 @@ export function CheckoutCompleted(props: {
|
|||||||
|
|
||||||
type SelectedPlanProps = {
|
type SelectedPlanProps = {
|
||||||
plan: Plan;
|
plan: Plan;
|
||||||
|
price: Price;
|
||||||
pricingInfo: PricingInfo | undefined;
|
pricingInfo: PricingInfo | undefined;
|
||||||
onChangePlan?: () => void;
|
onChangePlan?: () => void;
|
||||||
};
|
};
|
||||||
function SelectedPlan(props: SelectedPlanProps) {
|
function SelectedPlan(props: SelectedPlanProps) {
|
||||||
const { plan, pricingInfo, onChangePlan } = props;
|
const { plan, price, pricingInfo, onChangePlan } = props;
|
||||||
const metadata = PLAN_METADATA[plan.period];
|
|
||||||
const [isApplyingCoupon, setIsApplyingCoupon] = useCheckoutStore((store) => [
|
const [isApplyingCoupon, setIsApplyingCoupon] = useCheckoutStore((store) => [
|
||||||
store.isApplyingCoupon,
|
store.isApplyingCoupon,
|
||||||
store.setIsApplyingCoupon
|
store.setIsApplyingCoupon
|
||||||
@@ -448,15 +454,15 @@ function SelectedPlan(props: SelectedPlanProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{plan.period === "monthly" ? (
|
{price.period === "monthly" ? (
|
||||||
<Image
|
<Image
|
||||||
src={WorkAnywhere}
|
src={WorkAnywhere}
|
||||||
style={{ flexShrink: 0, width: 180, height: 180 }}
|
style={{ flexShrink: 0, width: 120, height: 120 }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
src={WorkLate}
|
src={WorkLate}
|
||||||
style={{ flexShrink: 0, width: 180, height: 180 }}
|
style={{ flexShrink: 0, width: 120, height: 120 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Text variant="heading" mt={4} sx={{ textAlign: "center" }}>
|
<Text variant="heading" mt={4} sx={{ textAlign: "center" }}>
|
||||||
@@ -468,9 +474,9 @@ function SelectedPlan(props: SelectedPlanProps) {
|
|||||||
mt={1}
|
mt={1}
|
||||||
sx={{ fontSize: "subheading", textAlign: "center" }}
|
sx={{ fontSize: "subheading", textAlign: "center" }}
|
||||||
>
|
>
|
||||||
{metadata.title}
|
{plan.title}
|
||||||
</Text>
|
</Text>
|
||||||
{plan.period === "education" && (
|
{plan.id === "education" && (
|
||||||
<Link
|
<Link
|
||||||
href="https://notesnook.com/education"
|
href="https://notesnook.com/education"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -531,6 +537,7 @@ function SelectedPlan(props: SelectedPlanProps) {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
mt={4}
|
mt={4}
|
||||||
px={4}
|
px={4}
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
onClick={onChangePlan}
|
onClick={onChangePlan}
|
||||||
>
|
>
|
||||||
Change plan
|
Change plan
|
||||||
@@ -549,43 +556,30 @@ type CheckoutPricingProps = {
|
|||||||
};
|
};
|
||||||
export function CheckoutPricing(props: CheckoutPricingProps) {
|
export function CheckoutPricing(props: CheckoutPricingProps) {
|
||||||
const { pricingInfo } = props;
|
const { pricingInfo } = props;
|
||||||
const { currency, price, discount, period, recurringPrice } = pricingInfo;
|
const { price, discount, period, recurringPrice } = pricingInfo;
|
||||||
const fields = [
|
const fields = [
|
||||||
{
|
{
|
||||||
key: "subtotal",
|
key: "subtotal",
|
||||||
label: "Subtotal",
|
label: "Subtotal",
|
||||||
value: formatPrice(currency, price.net.toFixed(2), null)
|
value: price.subtotal
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "tax",
|
key: "tax",
|
||||||
label: "Sales tax",
|
label: "Sales tax",
|
||||||
color: "red",
|
color: "red",
|
||||||
value: formatPrice(currency, price.tax.toFixed(2), null)
|
value: price.tax
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "discount",
|
key: "discount",
|
||||||
label: "Discount",
|
label: "Discount",
|
||||||
color: "accent",
|
color: "green",
|
||||||
value: formatPrice(
|
value: price.discount
|
||||||
currency,
|
|
||||||
discount.amount.toFixed(2),
|
|
||||||
null,
|
|
||||||
discount.amount > 0
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const isDiscounted = discount.recurring || discount.amount <= 0;
|
const isRecurringDiscount = !discount || discount.recurring;
|
||||||
const currentTotal = formatPrice(
|
const currentTotal = price.total;
|
||||||
currency,
|
const recurringTotal = recurringPrice ? recurringPrice.total : undefined;
|
||||||
(price.gross - discount.amount).toFixed(2),
|
|
||||||
isDiscounted ? period : undefined
|
|
||||||
);
|
|
||||||
const recurringTotal = formatPrice(
|
|
||||||
currency,
|
|
||||||
recurringPrice.gross.toFixed(2),
|
|
||||||
period
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{fields.map((field) => (
|
{fields.map((field) => (
|
||||||
@@ -627,19 +621,12 @@ export function CheckoutPricing(props: CheckoutPricingProps) {
|
|||||||
>
|
>
|
||||||
{currentTotal}
|
{currentTotal}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text as="div" sx={{ fontSize: "body", color: "paragraph" }}>
|
||||||
as="div"
|
{recurringTotal
|
||||||
sx={{
|
? isRecurringDiscount
|
||||||
fontSize: "body",
|
? "forever"
|
||||||
color: "paragraph-secondary",
|
: `first ${getFullPeriod(period)} then ${recurringTotal}`
|
||||||
fontWeight: "body"
|
: "for one year"}
|
||||||
}}
|
|
||||||
>
|
|
||||||
{period === "education" && discount.amount > 0
|
|
||||||
? "for one year"
|
|
||||||
: isDiscounted
|
|
||||||
? "forever"
|
|
||||||
: `first ${getFullPeriod(period)} then ${recurringTotal}`}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -653,7 +640,7 @@ function formatPrice(
|
|||||||
period?: Period | null,
|
period?: Period | null,
|
||||||
negative = false
|
negative = false
|
||||||
) {
|
) {
|
||||||
const formattedPeriod = period ? formatPeriod(period) : "";
|
const formattedPeriod = period ? formatRecurringPeriodShort(period) : "";
|
||||||
const currencySymbol = getCurrencySymbol(currency);
|
const currencySymbol = getCurrencySymbol(currency);
|
||||||
const prefix = negative ? "-" : "";
|
const prefix = negative ? "-" : "";
|
||||||
return `${prefix}${currencySymbol}${price}${formattedPeriod}`;
|
return `${prefix}${currencySymbol}${price}${formattedPeriod}`;
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ import {
|
|||||||
ICurrencySymbols
|
ICurrencySymbols
|
||||||
} from "@brixtol/currency-symbols";
|
} from "@brixtol/currency-symbols";
|
||||||
|
|
||||||
|
export const IS_DEV = import.meta.env.DEV || IS_TESTING;
|
||||||
export function getCurrencySymbol(currency: string) {
|
export function getCurrencySymbol(currency: string) {
|
||||||
return _getSymbol(currency as keyof ICurrencySymbols) || currency;
|
return _getSymbol(currency as keyof ICurrencySymbols) || currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAmount(amount: string) {
|
||||||
|
const matches = /(.+?)([\d.]+)/.exec(amount);
|
||||||
|
if (!matches || matches.length < 3) return;
|
||||||
|
return {
|
||||||
|
formatted: amount,
|
||||||
|
symbol: matches[1],
|
||||||
|
amount: parseFloat(matches[2])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,187 +20,141 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { Flex } from "@theme-ui/components";
|
import { Flex } from "@theme-ui/components";
|
||||||
import { Loader } from "../../components/loader";
|
import { Loader } from "../../components/loader";
|
||||||
import {
|
import { PaddleEvent, Period, Plan, Price, PricingInfo } from "./types";
|
||||||
CheckoutData,
|
|
||||||
CheckoutDataResponse,
|
|
||||||
CheckoutPrices,
|
|
||||||
PaddleEvent,
|
|
||||||
PaddleEvents,
|
|
||||||
Plan,
|
|
||||||
Price,
|
|
||||||
PricingInfo
|
|
||||||
} from "./types";
|
|
||||||
import { ScrollContainer } from "@notesnook/ui";
|
import { ScrollContainer } from "@notesnook/ui";
|
||||||
import useMobile from "../../hooks/use-mobile";
|
import useMobile from "../../hooks/use-mobile";
|
||||||
import { logger } from "../../utils/logger";
|
import {
|
||||||
|
AvailablePaymentMethod,
|
||||||
|
CheckoutEventNames,
|
||||||
|
CheckoutOpenLineItem,
|
||||||
|
CurrencyCode,
|
||||||
|
Totals,
|
||||||
|
Product,
|
||||||
|
CheckoutEventsCustomerAddress,
|
||||||
|
CheckoutEventsData
|
||||||
|
} from "@paddle/paddle-js";
|
||||||
import { isFeatureSupported } from "../../utils/feature-check";
|
import { isFeatureSupported } from "../../utils/feature-check";
|
||||||
import { isDev } from "./plans";
|
import { IS_DEV, parseAmount } from "./helpers";
|
||||||
|
import { CheckoutCustomerUserInfo } from "@paddle/paddle-js/types/checkout/customer";
|
||||||
|
import { logger } from "../../utils/logger";
|
||||||
|
|
||||||
const VENDOR_ID = isDev ? 1506 : 128190;
|
export const SELLER_ID = IS_DEV ? 1506 : 128190;
|
||||||
const PADDLE_ORIGIN = isDev
|
export const CLIENT_PADDLE_TOKEN = IS_DEV
|
||||||
|
? "test_e29ab18724934c1d35a05a7d2cb"
|
||||||
|
: "live_251f65dc0ac5ac364e44817fe92";
|
||||||
|
const PADDLE_ORIGIN = IS_DEV
|
||||||
? "https://sandbox-buy.paddle.com"
|
? "https://sandbox-buy.paddle.com"
|
||||||
: "https://buy.paddle.com";
|
: "https://buy.paddle.com";
|
||||||
const SUBSCRIPTION_MANAGEMENT_URL = isDev
|
const CHECKOUT_SERVICE = IS_DEV
|
||||||
? "https://sandbox-subscription-management.paddle.com"
|
|
||||||
: "https://subscription-management.paddle.com";
|
|
||||||
const CHECKOUT_SERVICE_ORIGIN = isDev
|
|
||||||
? "https://sandbox-checkout-service.paddle.com"
|
? "https://sandbox-checkout-service.paddle.com"
|
||||||
: "https://checkout-service.paddle.com";
|
: "https://checkout-service.paddle.com";
|
||||||
|
const PADDLE_API = IS_DEV
|
||||||
|
? "https://sandbox-api.paddle.com"
|
||||||
|
: "https://api.paddle.com";
|
||||||
|
|
||||||
const SUBSCRIBED_EVENTS: PaddleEvents[] = [
|
const SUBSCRIBED_EVENTS: CheckoutEventNames[] = [
|
||||||
PaddleEvents["Checkout.Loaded"],
|
CheckoutEventNames.CHECKOUT_LOADED,
|
||||||
PaddleEvents["Checkout.Coupon.Applied"],
|
CheckoutEventNames.CHECKOUT_COMPLETED,
|
||||||
PaddleEvents["Checkout.Coupon.Remove"],
|
CheckoutEventNames.CHECKOUT_CUSTOMER_UPDATED
|
||||||
PaddleEvents["Checkout.Location.Submit"],
|
|
||||||
PaddleEvents["Checkout.Complete"],
|
|
||||||
PaddleEvents["Checkout.Customer.Details"]
|
|
||||||
];
|
];
|
||||||
|
|
||||||
type PaddleCheckoutProps = {
|
type PaddleCheckoutProps = {
|
||||||
user: { id: string; email: string };
|
user: { id: string; email: string };
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
plan: Plan;
|
plan: Plan;
|
||||||
|
price: Price;
|
||||||
onPriceUpdated?: (pricingInfo: PricingInfo) => void;
|
onPriceUpdated?: (pricingInfo: PricingInfo) => void;
|
||||||
onCouponApplied?: () => void;
|
|
||||||
onCompleted?: () => void;
|
onCompleted?: () => void;
|
||||||
coupon?: string;
|
coupon?: string;
|
||||||
};
|
};
|
||||||
export function PaddleCheckout(props: PaddleCheckoutProps) {
|
export function PaddleCheckout(props: PaddleCheckoutProps) {
|
||||||
const {
|
const { plan, price, onPriceUpdated, coupon, onCompleted, user, theme } =
|
||||||
plan,
|
props;
|
||||||
onPriceUpdated,
|
|
||||||
coupon,
|
|
||||||
theme,
|
|
||||||
onCouponApplied,
|
|
||||||
onCompleted,
|
|
||||||
user
|
|
||||||
} = props;
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const appliedCouponCode = useRef<string>();
|
const appliedCouponCode = useRef<string>();
|
||||||
const checkoutId = useRef<string>();
|
const checkoutDataRef = useRef<CheckoutEventsData>();
|
||||||
const checkoutRef = useRef<HTMLIFrameElement>(null);
|
const checkoutRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
const addressRef = useRef<CheckoutEventsCustomerAddress | undefined>();
|
||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
|
|
||||||
const reloadCheckout = useCallback(() => {
|
const reloadCheckout = useCallback(() => {
|
||||||
if (!checkoutRef.current) return;
|
if (!checkoutRef.current || !checkoutDataRef.current) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
checkoutRef.current.src = `${PADDLE_ORIGIN}/checkout/?checkout_id=${
|
checkoutRef.current.src = "about:blank";
|
||||||
checkoutId.current
|
checkoutRef.current.src = getCheckoutURL(checkoutDataRef.current.id, theme);
|
||||||
}&display_mode=inline&apple_pay_enabled=${isFeatureSupported(
|
}, [theme]);
|
||||||
"applePaySupported"
|
|
||||||
)}`;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updatePrice = useCallback(
|
|
||||||
async (checkoutId: string, isInvalidCoupon?: boolean) => {
|
|
||||||
const checkoutData = await getCheckoutData(checkoutId);
|
|
||||||
if (!checkoutData) return;
|
|
||||||
const pricingInfo = getPricingInfo(plan, checkoutData);
|
|
||||||
pricingInfo.invalidCoupon = isInvalidCoupon;
|
|
||||||
if (onPriceUpdated) onPriceUpdated(pricingInfo);
|
|
||||||
return pricingInfo;
|
|
||||||
},
|
|
||||||
[onPriceUpdated, plan]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateCoupon = useCallback(
|
|
||||||
async (checkoutId: string) => {
|
|
||||||
if (
|
|
||||||
appliedCouponCode.current === coupon ||
|
|
||||||
!appliedCouponCode.current === !coupon
|
|
||||||
)
|
|
||||||
return false;
|
|
||||||
if (onCouponApplied) onCouponApplied();
|
|
||||||
const checkoutData = coupon
|
|
||||||
? await applyCoupon(checkoutId, coupon).catch(() => false)
|
|
||||||
: await removeCoupon(checkoutId).catch(() => false);
|
|
||||||
if (!checkoutData) {
|
|
||||||
await updatePrice(checkoutId, true).catch(() => false);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
appliedCouponCode.current = coupon;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
[coupon, updatePrice, onCouponApplied]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
createCheckout({ plan, theme, user, coupon }).then((checkoutData) => {
|
if (checkoutDataRef.current) {
|
||||||
if (!checkoutData) return;
|
if (
|
||||||
const pricingInfo = getPricingInfo(plan, checkoutData);
|
coupon &&
|
||||||
if (onPriceUpdated) onPriceUpdated(pricingInfo);
|
(!checkoutDataRef.current.discount ||
|
||||||
appliedCouponCode.current = pricingInfo.coupon;
|
checkoutDataRef.current.discount.code !== coupon)
|
||||||
checkoutId.current = checkoutData.public_checkout_id;
|
) {
|
||||||
reloadCheckout();
|
applyCoupon(checkoutDataRef.current.id, coupon).then(() =>
|
||||||
});
|
reloadCheckout()
|
||||||
}, [plan, theme, user]);
|
);
|
||||||
|
} else if (!coupon && checkoutDataRef.current.discount)
|
||||||
|
removeDiscount(
|
||||||
|
checkoutDataRef.current.id,
|
||||||
|
checkoutDataRef.current.discount.id
|
||||||
|
).then(() => reloadCheckout());
|
||||||
|
} else {
|
||||||
|
createCheckout({ theme, user, coupon, price }).then(
|
||||||
|
async (checkoutData) => {
|
||||||
|
if (!checkoutData) return;
|
||||||
|
const pricingInfo = await getPrice(price, checkoutData);
|
||||||
|
if (!pricingInfo) return;
|
||||||
|
|
||||||
|
addressRef.current = checkoutData.customer.address || undefined;
|
||||||
|
onPriceUpdated?.(pricingInfo);
|
||||||
|
appliedCouponCode.current = pricingInfo.coupon;
|
||||||
|
checkoutDataRef.current = checkoutData;
|
||||||
|
reloadCheckout();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [coupon, onPriceUpdated, price, reloadCheckout, theme, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function onMessage(ev: MessageEvent<PaddleEvent>) {
|
async function onMessage(ev: MessageEvent<PaddleEvent>) {
|
||||||
if (ev.origin !== PADDLE_ORIGIN) return;
|
if (ev.origin !== PADDLE_ORIGIN) return;
|
||||||
logger.debug("Paddle event received", { data: ev.data });
|
logger.debug("Paddle event received", { data: ev.data });
|
||||||
const { event, event_name, callback_data } = ev.data;
|
const { event_name, callback_data } = ev.data;
|
||||||
const { checkout } = callback_data || {};
|
|
||||||
|
|
||||||
if (event === PaddleEvents["Checkout.RemoveSpinner"]) setIsLoading(false);
|
if (event_name === CheckoutEventNames.CHECKOUT_FAILED) {
|
||||||
|
setIsLoading(false);
|
||||||
if (
|
|
||||||
!checkout ||
|
|
||||||
!checkout.id ||
|
|
||||||
(SUBSCRIBED_EVENTS.indexOf(event_name) === -1 &&
|
|
||||||
SUBSCRIBED_EVENTS.indexOf(event) === -1)
|
|
||||||
) {
|
|
||||||
logger.debug("Ignoring paddle event", { event_name, event });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event_name === PaddleEvents["Checkout.Complete"]) {
|
if (!callback_data.data || SUBSCRIBED_EVENTS.indexOf(event_name) === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event_name === CheckoutEventNames.CHECKOUT_COMPLETED) {
|
||||||
onCompleted && onCompleted();
|
onCompleted && onCompleted();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event_name === PaddleEvents["Checkout.Loaded"]) {
|
if (event_name === CheckoutEventNames.CHECKOUT_LOADED) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pricingInfo = getPricingInfo(plan, {
|
addressRef.current = callback_data.data.customer.address || undefined;
|
||||||
public_checkout_id: checkout.id,
|
const pricingInfo = await getPrice(price, callback_data.data);
|
||||||
ip_geo_country_code: callback_data?.user?.country || "US",
|
if (!pricingInfo) return;
|
||||||
items: [
|
|
||||||
{
|
pricingInfo.invalidCoupon =
|
||||||
prices: checkout.prices.customer.items,
|
!!props.coupon && !callback_data.data.discount;
|
||||||
recurring: {
|
|
||||||
prices: [checkout.recurring_prices.customer.items[0].recurring]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
if (onPriceUpdated) onPriceUpdated(pricingInfo);
|
if (onPriceUpdated) onPriceUpdated(pricingInfo);
|
||||||
appliedCouponCode.current = pricingInfo.coupon;
|
appliedCouponCode.current = pricingInfo.coupon;
|
||||||
checkoutId.current = checkout.id;
|
checkoutDataRef.current = callback_data.data || checkoutDataRef.current;
|
||||||
|
|
||||||
await updateCoupon(checkout.id);
|
|
||||||
}
|
}
|
||||||
window.addEventListener("message", onMessage, false);
|
window.addEventListener("message", onMessage, false);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("message", onMessage, false);
|
window.removeEventListener("message", onMessage, false);
|
||||||
};
|
};
|
||||||
}, [
|
}, [onPriceUpdated, plan, price, props.coupon, onCompleted]);
|
||||||
onPriceUpdated,
|
|
||||||
updatePrice,
|
|
||||||
plan,
|
|
||||||
onCompleted,
|
|
||||||
user.email,
|
|
||||||
reloadCheckout,
|
|
||||||
updateCoupon
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!checkoutId.current) return;
|
|
||||||
updateCoupon(checkoutId.current).then((result) => {
|
|
||||||
if (result) reloadCheckout();
|
|
||||||
});
|
|
||||||
}, [coupon]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollContainer
|
<ScrollContainer
|
||||||
@@ -229,7 +183,7 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
|
|||||||
scrolling="no"
|
scrolling="no"
|
||||||
frameBorder={"0"}
|
frameBorder={"0"}
|
||||||
ref={checkoutRef}
|
ref={checkoutRef}
|
||||||
allow={`payment ${PADDLE_ORIGIN} ${SUBSCRIPTION_MANAGEMENT_URL};`}
|
allow={`payment`} // ${PADDLE_ORIGIN} ${SUBSCRIPTION_MANAGEMENT_URL};`}
|
||||||
style={{
|
style={{
|
||||||
// padding: "0px 30px",
|
// padding: "0px 30px",
|
||||||
height: "1000px",
|
height: "1000px",
|
||||||
@@ -246,157 +200,252 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCheckoutURL(params: {
|
function getCheckoutURL(id: string, theme: PaddleCheckoutProps["theme"]) {
|
||||||
plan: PaddleCheckoutProps["plan"];
|
return `${PADDLE_ORIGIN}/checkout/${id}?display_mode=inline&variant=multi-page&display_mode_theme=${theme}&checkout_type=transaction-checkout&apple_pay_enabled=${isFeatureSupported(
|
||||||
theme: PaddleCheckoutProps["theme"];
|
"applePaySupported"
|
||||||
user: PaddleCheckoutProps["user"];
|
)}`;
|
||||||
}) {
|
|
||||||
const { plan, theme, user } = params;
|
|
||||||
const BASE_URL = `${CHECKOUT_SERVICE_ORIGIN}/create/v2/checkout/product/${plan.id}`;
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
queryParams.set("product", plan.id);
|
|
||||||
queryParams.set("vendor", VENDOR_ID.toString());
|
|
||||||
queryParams.set("passthrough", JSON.stringify({ userId: user.id }));
|
|
||||||
queryParams.set("guest_email", user.email);
|
|
||||||
queryParams.set("quantity_variable", "0");
|
|
||||||
queryParams.set("disable_logout", "true");
|
|
||||||
queryParams.set("display_mode_theme", theme);
|
|
||||||
queryParams.set("display_mode", "inline");
|
|
||||||
queryParams.set(
|
|
||||||
"apple_pay_enabled",
|
|
||||||
JSON.stringify(isFeatureSupported("applePaySupported"))
|
|
||||||
);
|
|
||||||
queryParams.set("paddlejs-version", "2.0.81");
|
|
||||||
queryParams.set("checkout_initiated", new Date().getTime().toString());
|
|
||||||
queryParams.set("popup", "true");
|
|
||||||
queryParams.set("paddle_js", "true");
|
|
||||||
queryParams.set("is_popup", "true");
|
|
||||||
queryParams.set("parent_url", window.location.origin);
|
|
||||||
queryParams.set("parentURL", window.location.origin);
|
|
||||||
queryParams.set("referring_domain", window.location.hostname);
|
|
||||||
queryParams.set("locale", "en");
|
|
||||||
const fullURL = `${BASE_URL}?${queryParams.toString()}`;
|
|
||||||
return fullURL;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPricingInfo(plan: Plan, checkoutData: CheckoutData): PricingInfo {
|
async function getPrice(price: Price, checkoutData: CheckoutEventsData) {
|
||||||
const { prices, recurring } = checkoutData.items[0];
|
const response = await fetch(`${PADDLE_API}/pricing-preview`, {
|
||||||
const price = prices[0];
|
method: "POST",
|
||||||
const recurringPrice = recurring.prices[0];
|
body: JSON.stringify({
|
||||||
const discount = price.discounts.length > 0 ? price.discounts[0] : undefined;
|
items: [
|
||||||
const isRecurringDiscount = recurringPrice.discounts.length > 0;
|
{
|
||||||
|
quantity: 1,
|
||||||
return {
|
price_id: price.id
|
||||||
country: checkoutData.ip_geo_country_code,
|
}
|
||||||
currency: price.currency,
|
],
|
||||||
discount: {
|
currency_code: checkoutData.currency_code,
|
||||||
amount: discount?.gross_discount || 0,
|
address: checkoutData.customer.address
|
||||||
recurring: isRecurringDiscount,
|
? {
|
||||||
code: discount?.code,
|
postal_code: checkoutData.customer.address?.postal_code,
|
||||||
type: "promo"
|
country_code: checkoutData.customer.address?.country_code
|
||||||
},
|
}
|
||||||
period: plan.period,
|
: undefined,
|
||||||
price: normalizeCheckoutPrice(price),
|
discount_id: checkoutData.discount?.id
|
||||||
recurringPrice: normalizeCheckoutPrice(recurringPrice),
|
}),
|
||||||
coupon: discount?.code
|
headers: {
|
||||||
};
|
"Content-Type": "application/json",
|
||||||
}
|
Accept: "application/json",
|
||||||
|
"Paddle-Clienttoken": CLIENT_PADDLE_TOKEN
|
||||||
function normalizeCheckoutPrice(prices: CheckoutPrices): Price {
|
|
||||||
const price = prices.unit_price;
|
|
||||||
return {
|
|
||||||
gross: price.gross,
|
|
||||||
net: price.net,
|
|
||||||
tax: price.tax,
|
|
||||||
currency: prices.currency
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyCoupon(
|
|
||||||
checkoutId: string,
|
|
||||||
couponCode: string
|
|
||||||
): Promise<CheckoutData | false> {
|
|
||||||
const url = ` ${CHECKOUT_SERVICE_ORIGIN}/checkout/${checkoutId}/coupon`;
|
|
||||||
const body = { data: { coupon_code: couponCode } };
|
|
||||||
const headers = new Headers();
|
|
||||||
headers.set("content-type", "application/json");
|
|
||||||
const response = await fetch(url, {
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
headers,
|
|
||||||
method: "POST"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) return false;
|
|
||||||
const json = (await response.json()) as CheckoutDataResponse;
|
|
||||||
return json.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitCustomerInfo(
|
|
||||||
checkoutId: string,
|
|
||||||
email: string,
|
|
||||||
country: string
|
|
||||||
): Promise<CheckoutData | false> {
|
|
||||||
if (IS_TESTING) return false;
|
|
||||||
|
|
||||||
const url = ` ${CHECKOUT_SERVICE_ORIGIN}/checkout/${checkoutId}/customer-info`;
|
|
||||||
const body = {
|
|
||||||
data: {
|
|
||||||
email,
|
|
||||||
country_code: country,
|
|
||||||
audience_optin: false,
|
|
||||||
postcode: "1123212"
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const json = (await response.json()) as PricePreviewResponse;
|
||||||
|
console.log(json);
|
||||||
|
return getPricingInfo(json.data, price.period, checkoutData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPricingInfo(
|
||||||
|
price: PricePreviewResponse["data"],
|
||||||
|
period: Period,
|
||||||
|
checkoutData: CheckoutEventsData
|
||||||
|
): PricingInfo {
|
||||||
|
const {
|
||||||
|
discounts,
|
||||||
|
formatted_totals: totals,
|
||||||
|
totals: _totals,
|
||||||
|
price: _price,
|
||||||
|
tax_rate
|
||||||
|
} = price.details.line_items[0];
|
||||||
|
const discount = discounts[0];
|
||||||
|
const isRecurring = !discount || discount.discount.recur;
|
||||||
|
const totalsWithoutDiscount: Totals = { ...totals };
|
||||||
|
if (discount) {
|
||||||
|
const taxRate = parseFloat(tax_rate);
|
||||||
|
const tax = parseInt(_totals.subtotal) * taxRate;
|
||||||
|
const total = parseInt(_totals.subtotal) + tax;
|
||||||
|
const { symbol = price.currency_code } = parseAmount(totals.subtotal) || {};
|
||||||
|
totalsWithoutDiscount.discount = `${symbol}0.00`;
|
||||||
|
totalsWithoutDiscount.subtotal = totals.subtotal;
|
||||||
|
totalsWithoutDiscount.tax = `${symbol}${(tax / 100).toFixed(2)}`;
|
||||||
|
totalsWithoutDiscount.total = `${symbol}${(total / 100).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
country: checkoutData.customer.address?.country_code || "US",
|
||||||
|
discount: discount
|
||||||
|
? {
|
||||||
|
recurring: isRecurring,
|
||||||
|
amount: 0,
|
||||||
|
type: "promo"
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
period,
|
||||||
|
price: {
|
||||||
|
id: _price.id,
|
||||||
|
period,
|
||||||
|
currency: checkoutData.currency_code,
|
||||||
|
...totals
|
||||||
|
},
|
||||||
|
recurringPrice: {
|
||||||
|
id: _price.id,
|
||||||
|
period,
|
||||||
|
currency: checkoutData.currency_code,
|
||||||
|
...totalsWithoutDiscount
|
||||||
|
},
|
||||||
|
coupon: checkoutData.discount?.code
|
||||||
};
|
};
|
||||||
const headers = new Headers();
|
|
||||||
headers.set("content-type", "application/json");
|
|
||||||
const response = await fetch(url, {
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
headers,
|
|
||||||
method: "POST"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) return false;
|
|
||||||
|
|
||||||
const json = (await response.json()) as CheckoutDataResponse;
|
|
||||||
return json.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeCoupon(checkoutId: string): Promise<CheckoutData | false> {
|
interface Discount {
|
||||||
const url = ` ${CHECKOUT_SERVICE_ORIGIN}/checkout/${checkoutId}/coupon`;
|
id: string;
|
||||||
|
status: "active" | "archived" | "expired" | "used";
|
||||||
const response = await fetch(url, {
|
description: string;
|
||||||
method: "DELETE"
|
enabled_for_checkout: boolean;
|
||||||
});
|
code: string | null;
|
||||||
if (!response.ok) return false;
|
type: "flat" | "flat_per_seat" | "percentage";
|
||||||
const json = (await response.json()) as CheckoutDataResponse;
|
amount: string;
|
||||||
return json.data;
|
currency_code: CurrencyCode | null;
|
||||||
|
recur: boolean;
|
||||||
|
maximum_recurring_intervals: number | null;
|
||||||
|
usage_limit: number | null;
|
||||||
|
restrict_to: string[] | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
times_used: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCheckoutData(
|
interface DiscountLineItem {
|
||||||
checkoutId: string
|
discount: Discount;
|
||||||
): Promise<CheckoutData | undefined> {
|
total: string;
|
||||||
const url = `${CHECKOUT_SERVICE_ORIGIN}/checkout/${checkoutId}`;
|
formatted_total: string;
|
||||||
const response = await fetch(url);
|
}
|
||||||
if (!response.ok) return undefined;
|
|
||||||
const json = (await response.json()) as CheckoutDataResponse;
|
interface LineItem {
|
||||||
return json.data;
|
price: Price;
|
||||||
|
quantity: number;
|
||||||
|
tax_rate: string;
|
||||||
|
unit_totals: Totals;
|
||||||
|
formatted_unit_totals: Totals;
|
||||||
|
totals: Totals;
|
||||||
|
formatted_totals: Totals;
|
||||||
|
product: Product;
|
||||||
|
discounts: DiscountLineItem[];
|
||||||
|
}
|
||||||
|
interface PricePreviewResponse {
|
||||||
|
data: {
|
||||||
|
customer_id: string | null;
|
||||||
|
address_id: string | null;
|
||||||
|
business_id: string | null;
|
||||||
|
currency_code: CurrencyCode;
|
||||||
|
address: {
|
||||||
|
country_code: string;
|
||||||
|
postal_code: string | null;
|
||||||
|
} | null;
|
||||||
|
customer_ip_address: string | null;
|
||||||
|
discount_id: string | null;
|
||||||
|
details: {
|
||||||
|
line_items: LineItem[];
|
||||||
|
};
|
||||||
|
availablePaymentMethods: AvailablePaymentMethod[];
|
||||||
|
};
|
||||||
|
// meta: {
|
||||||
|
// requestId: string;
|
||||||
|
// };
|
||||||
|
}
|
||||||
|
|
||||||
|
enum THEME {
|
||||||
|
LIGHT = "light",
|
||||||
|
DARK = "dark",
|
||||||
|
GREEN = "green"
|
||||||
|
}
|
||||||
|
enum DISPLAY_MODE {
|
||||||
|
OVERLAY = "overlay",
|
||||||
|
INLINE = "inline",
|
||||||
|
WIDE_OVERLAY = "wide-overlay"
|
||||||
|
}
|
||||||
|
interface CheckoutOutputAttributesProps {
|
||||||
|
customer?: CheckoutCustomerUserInfo & {
|
||||||
|
address?: Partial<CheckoutEventsCustomerAddress>;
|
||||||
|
};
|
||||||
|
custom_data?: string;
|
||||||
|
items?: InternalCheckoutOpenLineItem[];
|
||||||
|
customer_auth_token?: string;
|
||||||
|
discount_code?: string;
|
||||||
|
discount_id?: string;
|
||||||
|
transaction_id?: string;
|
||||||
|
settings?: {
|
||||||
|
locale?: string;
|
||||||
|
theme?: THEME;
|
||||||
|
success_url?: string;
|
||||||
|
allow_logout?: boolean;
|
||||||
|
show_add_discounts?: boolean;
|
||||||
|
allow_discount_removal?: boolean;
|
||||||
|
show_add_tax_id?: boolean;
|
||||||
|
frame_target?: string;
|
||||||
|
frame_initial_height?: number;
|
||||||
|
frame_style?: string;
|
||||||
|
display_mode?: DISPLAY_MODE;
|
||||||
|
source_page?: string;
|
||||||
|
allowed_payment_methods?: AvailablePaymentMethod[];
|
||||||
|
};
|
||||||
|
seller_id?: number | null;
|
||||||
|
client_token?: string;
|
||||||
|
apple_pay_enabled?: boolean;
|
||||||
|
checkout_initiated?: number;
|
||||||
|
"paddlejs-version"?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type InternalCheckoutOpenLineItem = Omit<CheckoutOpenLineItem, "priceId"> & {
|
||||||
|
price_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCheckoutSettings(
|
||||||
|
theme: PaddleCheckoutProps["theme"]
|
||||||
|
): CheckoutOutputAttributesProps["settings"] {
|
||||||
|
return {
|
||||||
|
allow_discount_removal: true,
|
||||||
|
allowed_payment_methods: [
|
||||||
|
"apple_pay",
|
||||||
|
"card",
|
||||||
|
"google_pay",
|
||||||
|
"paypal",
|
||||||
|
"alipay",
|
||||||
|
"bancontact",
|
||||||
|
"ideal"
|
||||||
|
],
|
||||||
|
display_mode: DISPLAY_MODE.INLINE,
|
||||||
|
allow_logout: false,
|
||||||
|
show_add_discounts: true,
|
||||||
|
theme: theme === "dark" ? THEME.DARK : THEME.LIGHT,
|
||||||
|
source_page: window.location.href
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CheckoutDataResponse {
|
||||||
|
data: CheckoutEventsData & { ip_geo_country_code: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCheckout(props: {
|
async function createCheckout(props: {
|
||||||
plan: PaddleCheckoutProps["plan"];
|
price: PaddleCheckoutProps["price"];
|
||||||
user: PaddleCheckoutProps["user"];
|
user: PaddleCheckoutProps["user"];
|
||||||
theme: PaddleCheckoutProps["theme"];
|
theme: PaddleCheckoutProps["theme"];
|
||||||
coupon?: PaddleCheckoutProps["coupon"];
|
coupon?: PaddleCheckoutProps["coupon"];
|
||||||
}) {
|
}) {
|
||||||
const { plan, user, theme, coupon } = props;
|
const { user, theme, coupon, price } = props;
|
||||||
const url = getCheckoutURL({ plan, user, theme });
|
const response = await fetch(`${CHECKOUT_SERVICE}/transaction-checkout`, {
|
||||||
const response = await fetch(url);
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
custom_data: JSON.stringify({ userId: user.id }),
|
||||||
|
customer: { email: user.email },
|
||||||
|
items: [{ price_id: price.id, quantity: 1 }],
|
||||||
|
settings: getCheckoutSettings(theme)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"paddle-clienttoken": CLIENT_PADDLE_TOKEN
|
||||||
|
}
|
||||||
|
});
|
||||||
if (!response.ok) return false;
|
if (!response.ok) return false;
|
||||||
|
|
||||||
const json = (await response.json()) as CheckoutDataResponse;
|
const json = (await response.json()) as CheckoutDataResponse;
|
||||||
|
|
||||||
let checkoutData = json.data;
|
let checkoutData = json.data;
|
||||||
const checkoutId = checkoutData.public_checkout_id;
|
const checkoutId = checkoutData.id;
|
||||||
|
|
||||||
checkoutData = await submitCustomerInfo(
|
checkoutData = await submitCustomerInfo(
|
||||||
checkoutId,
|
checkoutId,
|
||||||
@@ -413,3 +462,66 @@ async function createCheckout(props: {
|
|||||||
|
|
||||||
return checkoutData;
|
return checkoutData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitCustomerInfo(
|
||||||
|
checkoutId: string,
|
||||||
|
email: string,
|
||||||
|
country: string
|
||||||
|
): Promise<CheckoutDataResponse["data"] | false> {
|
||||||
|
if (IS_TESTING) return false;
|
||||||
|
|
||||||
|
const url = `${CHECKOUT_SERVICE}/transaction-checkout/${checkoutId}/customer`;
|
||||||
|
const body = {
|
||||||
|
data: {
|
||||||
|
customer: {
|
||||||
|
email,
|
||||||
|
marketing_consent: false,
|
||||||
|
address: { country_code: country, postal_code: "1123212" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.set("content-type", "application/json");
|
||||||
|
const response = await fetch(url, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers,
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return false;
|
||||||
|
|
||||||
|
const json = (await response.json()) as CheckoutDataResponse;
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCoupon(
|
||||||
|
checkoutId: string,
|
||||||
|
couponCode: string
|
||||||
|
): Promise<CheckoutDataResponse["data"] | false> {
|
||||||
|
const url = `${CHECKOUT_SERVICE}/transaction-checkout/${checkoutId}/discount`;
|
||||||
|
const body = { data: { discount_code: couponCode } };
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.set("content-type", "application/json");
|
||||||
|
const response = await fetch(url, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers,
|
||||||
|
method: "PATCH"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const json = (await response.json()) as CheckoutDataResponse;
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDiscount(
|
||||||
|
checkoutId: string,
|
||||||
|
discountId: string
|
||||||
|
): Promise<CheckoutDataResponse["data"] | false> {
|
||||||
|
const url = `${CHECKOUT_SERVICE}/transaction-checkout/${checkoutId}/discount/${discountId}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const json = (await response.json()) as CheckoutDataResponse;
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,19 +20,36 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
import { Text, Flex, Button, Image } from "@theme-ui/components";
|
import { Text, Flex, Button, Image } from "@theme-ui/components";
|
||||||
import { Loading } from "../../components/icons";
|
import { Loading } from "../../components/icons";
|
||||||
import Nomad from "../../assets/nomad.svg?url";
|
import Nomad from "../../assets/nomad.svg?url";
|
||||||
import { Period, Plan } from "./types";
|
import { Period, Plan, PlanId, Price } from "./types";
|
||||||
import { PLAN_METADATA, usePlans } from "./plans";
|
import { usePlans } from "./plans";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getCurrencySymbol } from "./helpers";
|
import { getCurrencySymbol, parseAmount } from "./helpers";
|
||||||
|
import { strings } from "@notesnook/intl";
|
||||||
|
|
||||||
type PlansListProps = {
|
type PlansListProps = {
|
||||||
onPlanSelected: (plan: Plan) => void;
|
selectedPlan: PlanId;
|
||||||
|
onPlanSelected: (plan: Plan, price: Price) => void;
|
||||||
onPlansLoaded?: (plans: Plan[]) => void;
|
onPlansLoaded?: (plans: Plan[]) => void;
|
||||||
};
|
};
|
||||||
|
const periods: { id: Period; title: string }[] = [
|
||||||
|
{
|
||||||
|
title: strings.monthly(),
|
||||||
|
id: "monthly"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: strings.yearly(),
|
||||||
|
id: "yearly"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5-year",
|
||||||
|
title: "5 year"
|
||||||
|
}
|
||||||
|
];
|
||||||
export function PlansList(props: PlansListProps) {
|
export function PlansList(props: PlansListProps) {
|
||||||
const { onPlanSelected, onPlansLoaded } = props;
|
const { onPlanSelected, onPlansLoaded, selectedPlan } = props;
|
||||||
const { isLoading, plans, discount, country } = usePlans();
|
const { isLoading, plans, discount, country } = usePlans();
|
||||||
|
const [selectedPeriod, setPeriod] = useState<Period>("yearly");
|
||||||
|
console.log({ selectedPlan });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading || !onPlansLoaded) return;
|
if (isLoading || !onPlansLoaded) return;
|
||||||
onPlansLoaded(plans);
|
onPlansLoaded(plans);
|
||||||
@@ -40,7 +57,10 @@ export function PlansList(props: PlansListProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Image src={Nomad} style={{ flexShrink: 0, width: 200, height: 200 }} />
|
<Image
|
||||||
|
src={Nomad}
|
||||||
|
style={{ flexShrink: 0, width: 150, height: 150, marginTop: 20 }}
|
||||||
|
/>
|
||||||
<Text variant="heading" mt={4} sx={{ textAlign: "center" }}>
|
<Text variant="heading" mt={4} sx={{ textAlign: "center" }}>
|
||||||
Choose a plan
|
Choose a plan
|
||||||
</Text>
|
</Text>
|
||||||
@@ -54,32 +74,67 @@ export function PlansList(props: PlansListProps) {
|
|||||||
"Notesnook profits when you purchase a subscription — not by selling your data."
|
"Notesnook profits when you purchase a subscription — not by selling your data."
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
bg: "background-secondary",
|
||||||
|
borderRadius: "default",
|
||||||
|
overflow: "hidden",
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{periods.map((period) => (
|
||||||
|
<Button
|
||||||
|
key={period.id}
|
||||||
|
variant="secondary"
|
||||||
|
sx={{
|
||||||
|
bg:
|
||||||
|
selectedPeriod === period.id
|
||||||
|
? "background-selected"
|
||||||
|
: "transparent",
|
||||||
|
color:
|
||||||
|
selectedPeriod === period.id ? "accent-selected" : "paragraph",
|
||||||
|
borderRadius: 0,
|
||||||
|
py: 1
|
||||||
|
}}
|
||||||
|
onClick={() => setPeriod(period.id)}
|
||||||
|
>
|
||||||
|
{period.title}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
<Flex mt={2} sx={{ flexDirection: "column", alignSelf: "stretch" }}>
|
<Flex mt={2} sx={{ flexDirection: "column", alignSelf: "stretch" }}>
|
||||||
{plans.map((plan) => {
|
{plans.map((plan) => {
|
||||||
const metadata = PLAN_METADATA[plan.period];
|
const price = plan.prices.find((p) => p.period === selectedPeriod);
|
||||||
|
if (!price) return null;
|
||||||
|
// const metadata = PLAN_METADATA[plan.period];
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={metadata.title}
|
key={plan.title}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
data-test-id={`checkout-plan`}
|
data-test-id={`checkout-plan`}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
mt={1}
|
mt={1}
|
||||||
bg="transparent"
|
// bg="transparent"
|
||||||
// sx={
|
// sx={
|
||||||
// {
|
// {
|
||||||
// // bg: selectedPlan?.key === plan.key ? "border" : "transparent",
|
// // bg: selectedPlan?.key === plan.key ? "border" : "transparent",
|
||||||
// // border:
|
// border:
|
||||||
// // selectedPlan?.key === plan.key ? "1px solid var(--accent)" : "none",
|
// selectedPlan?.key === plan.key ? "1px solid var(--accent)" : "none",
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
onClick={() => onPlanSelected(plan)}
|
onClick={() => onPlanSelected(plan, price)}
|
||||||
sx={{
|
sx={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
textAlign: "start",
|
textAlign: "start",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
display: "flex"
|
display: "flex",
|
||||||
|
border:
|
||||||
|
selectedPlan === plan.id
|
||||||
|
? "1px solid var(--accent-selected)"
|
||||||
|
: "none",
|
||||||
|
borderRadius: "default"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
@@ -87,8 +142,8 @@ export function PlansList(props: PlansListProps) {
|
|||||||
sx={{ fontWeight: "normal" }}
|
sx={{ fontWeight: "normal" }}
|
||||||
data-test-id="title"
|
data-test-id="title"
|
||||||
>
|
>
|
||||||
{metadata.title}
|
{plan.title}
|
||||||
<br />
|
{/* <br />
|
||||||
<Text
|
<Text
|
||||||
variant="body"
|
variant="body"
|
||||||
sx={{
|
sx={{
|
||||||
@@ -97,9 +152,15 @@ export function PlansList(props: PlansListProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{metadata.subtitle}
|
{metadata.subtitle}
|
||||||
</Text>
|
</Text> */}
|
||||||
</Text>
|
</Text>
|
||||||
{isLoading ? <Loading /> : <RecurringPricing plan={plan} />}
|
{isLoading ? (
|
||||||
|
<Loading />
|
||||||
|
) : plan.recurring ? (
|
||||||
|
<RecurringPricing plan={plan} price={price} />
|
||||||
|
) : (
|
||||||
|
<OneTimePricing plan={plan} price={price} />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -108,17 +169,23 @@ export function PlansList(props: PlansListProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecurringPricingProps = {
|
type PricingProps = {
|
||||||
plan: Plan;
|
plan: Plan;
|
||||||
|
price: Price;
|
||||||
};
|
};
|
||||||
function RecurringPricing(props: RecurringPricingProps) {
|
function RecurringPricing(props: PricingProps) {
|
||||||
const { plan } = props;
|
const { plan, price } = props;
|
||||||
|
// const price = plan.prices.find((p) => p.period === period);
|
||||||
|
// if (!price) return null;
|
||||||
|
const monthPrice = plan.prices.find(
|
||||||
|
(p) => p.period === "monthly" && price.period !== p.period
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
sx={{ flexShrink: 0, fontSize: "subBody", textAlign: "end" }}
|
sx={{ flexShrink: 0, fontSize: "subBody", textAlign: "end" }}
|
||||||
variant="body"
|
variant="body"
|
||||||
>
|
>
|
||||||
{plan.originalPrice && plan.originalPrice.gross !== plan.price.gross ? (
|
{/* {plan.originalPrice && plan.originalPrice.gross !== plan.price.gross && (
|
||||||
<Text
|
<Text
|
||||||
sx={{
|
sx={{
|
||||||
textDecorationLine: "line-through",
|
textDecorationLine: "line-through",
|
||||||
@@ -129,23 +196,91 @@ function RecurringPricing(props: RecurringPricingProps) {
|
|||||||
{getCurrencySymbol(plan.currency)}
|
{getCurrencySymbol(plan.currency)}
|
||||||
{plan.originalPrice.gross}
|
{plan.originalPrice.gross}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
)} */}
|
||||||
<Text>
|
{/* {monthPrice && (
|
||||||
<Text as="span" sx={{ fontSize: "subtitle" }}>
|
<Text
|
||||||
{getCurrencySymbol(plan.currency)}
|
variant="subBody"
|
||||||
{plan.price.gross}
|
sx={{
|
||||||
|
textDecorationLine: "line-through",
|
||||||
|
color: "var(--paragraph-secondary)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getCurrencySymbol(price.currency)}
|
||||||
|
{monthPrice.gross}
|
||||||
</Text>
|
</Text>
|
||||||
{formatPeriod(plan.period)}
|
)} */}
|
||||||
|
<Text as="div" sx={{ fontSize: "subtitle", fontWeight: "bold" }}>
|
||||||
|
{monthPrice && monthPrice.subtotal < price.subtotal && (
|
||||||
|
<Text
|
||||||
|
variant="subBody"
|
||||||
|
sx={{
|
||||||
|
textDecorationLine: "line-through",
|
||||||
|
color: "var(--paragraph-secondary)",
|
||||||
|
fontWeight: "body"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getCurrencySymbol(price.currency)}
|
||||||
|
{monthPrice.subtotal}
|
||||||
|
</Text>
|
||||||
|
)}{" "}
|
||||||
|
{getCurrencySymbol(price.currency)}
|
||||||
|
{price.subtotal}
|
||||||
|
/month
|
||||||
|
</Text>
|
||||||
|
{parseAmount(price.subtotal)?.amount === 0 ? null : (
|
||||||
|
<Text as="div" variant="subBody">
|
||||||
|
billed {formatRecurringPeriod(price.period)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OneTimePricing(props: PricingProps) {
|
||||||
|
const { price } = props;
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
sx={{ flexShrink: 0, fontSize: "subBody", textAlign: "end" }}
|
||||||
|
variant="body"
|
||||||
|
>
|
||||||
|
<Text as="div" sx={{ fontSize: "subtitle", fontWeight: "bold" }}>
|
||||||
|
{getCurrencySymbol(price.currency)}
|
||||||
|
{price.subtotal}
|
||||||
|
</Text>
|
||||||
|
<Text as="div" variant="subBody">
|
||||||
|
{formatOneTimePeriod(price.period)}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatPeriod(period: Period) {
|
export function formatOneTimePeriod(period: Period) {
|
||||||
|
return period === "monthly"
|
||||||
|
? "for 1 month"
|
||||||
|
: period === "yearly"
|
||||||
|
? "for 1 year"
|
||||||
|
: period === "5-year"
|
||||||
|
? "for 5 years"
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRecurringPeriod(period: Period) {
|
||||||
|
return period === "monthly"
|
||||||
|
? "monthly"
|
||||||
|
: period === "yearly"
|
||||||
|
? "annually"
|
||||||
|
: period === "5-year"
|
||||||
|
? "every 5 years"
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRecurringPeriodShort(period: Period) {
|
||||||
return period === "monthly"
|
return period === "monthly"
|
||||||
? "/mo"
|
? "/mo"
|
||||||
: period === "yearly" || period === "education"
|
: period === "yearly"
|
||||||
? "/yr"
|
? "/yr"
|
||||||
|
: period === "5-year"
|
||||||
|
? "/5yr"
|
||||||
: "";
|
: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,58 +18,129 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Period, Plan } from "./types";
|
import { Period, Plan, Price } from "./types";
|
||||||
|
import { IS_DEV } from "./helpers";
|
||||||
|
|
||||||
type PlanMetadata = {
|
function createPrice(id: string, period: Period, subtotal: number): Price {
|
||||||
title: string;
|
return {
|
||||||
subtitle: string;
|
id,
|
||||||
};
|
period,
|
||||||
|
subtotal,
|
||||||
|
total: 0,
|
||||||
|
tax: 0,
|
||||||
|
currency: "USD"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const isDev = import.meta.env.DEV || IS_TESTING;
|
const FREE_PLAN: Plan = {
|
||||||
|
id: "free",
|
||||||
export const EDUCATION_PLAN: Plan = {
|
title: "Free",
|
||||||
id: isDev ? "50305" : "658759",
|
recurring: true,
|
||||||
period: "education",
|
prices: [
|
||||||
country: "US",
|
createPrice("monthly", "monthly", 0),
|
||||||
currency: "USD",
|
createPrice("yearly", "yearly", 0),
|
||||||
discount: { type: "regional", amount: 0, recurring: false },
|
createPrice("5-year", "5-year", 0)
|
||||||
price: { gross: 9.99, net: 0, tax: 0 },
|
]
|
||||||
originalPrice: { gross: 9.99, net: 0, tax: 0 }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_PLANS: Plan[] = [
|
export const DEFAULT_PLANS: Plan[] = [
|
||||||
|
FREE_PLAN,
|
||||||
{
|
{
|
||||||
period: "monthly",
|
id: "essential",
|
||||||
country: "PK",
|
title: "Essential",
|
||||||
currency: "USD",
|
recurring: true,
|
||||||
discount: { type: "regional", amount: 0, recurring: false },
|
prices: [
|
||||||
originalPrice: { gross: 4.49, net: 0, tax: 0 },
|
createPrice(
|
||||||
id: isDev ? "9822" : "648884",
|
IS_DEV
|
||||||
price: { gross: 4.49, net: 0, tax: 0 }
|
? "pri_01j00cf6v5kqqvchcpgapr7123"
|
||||||
|
: "pri_01j02dbe7btgk6ta3ctper2161",
|
||||||
|
"monthly",
|
||||||
|
1.99
|
||||||
|
),
|
||||||
|
createPrice(
|
||||||
|
IS_DEV
|
||||||
|
? "pri_01j00d1qq3bart3w1rvt0q8bkt"
|
||||||
|
: "pri_01j02dckdey85cgmrdknd2f4zx",
|
||||||
|
"yearly",
|
||||||
|
1.24
|
||||||
|
)
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
period: "yearly",
|
id: "pro",
|
||||||
country: "PK",
|
title: "Pro",
|
||||||
currency: "USD",
|
recurring: true,
|
||||||
discount: { type: "regional", amount: 0, recurring: false },
|
prices: [
|
||||||
id: isDev ? "50305" : "658759",
|
createPrice(
|
||||||
price: { gross: 49.99, net: 0, tax: 0 },
|
IS_DEV
|
||||||
originalPrice: { gross: 49.99, net: 0, tax: 0 }
|
? "pri_01j00fnbzth05aafjb05kcahvq"
|
||||||
|
: "pri_01h9qprh1xvvxbs8vcpcg7qacm",
|
||||||
|
"monthly",
|
||||||
|
6.49
|
||||||
|
),
|
||||||
|
createPrice(
|
||||||
|
IS_DEV
|
||||||
|
? "pri_01j00fpawjwkrqxy2faqhzts9m"
|
||||||
|
: "pri_01h9qpqyjwbm3m2xy7834t3azt",
|
||||||
|
"yearly",
|
||||||
|
5.49
|
||||||
|
),
|
||||||
|
createPrice(
|
||||||
|
IS_DEV
|
||||||
|
? "pri_01j00fr72gn40xzk9cdcfpzevw"
|
||||||
|
: "pri_01j02da6n9c1xmzq15kjhjxngn",
|
||||||
|
"5-year",
|
||||||
|
4.49
|
||||||
|
)
|
||||||
|
]
|
||||||
},
|
},
|
||||||
EDUCATION_PLAN
|
{
|
||||||
];
|
id: "believer",
|
||||||
|
title: "Believer",
|
||||||
export const PLAN_METADATA: Record<Period, PlanMetadata> = {
|
recurring: true,
|
||||||
monthly: { title: "Monthly", subtitle: `Pay once a month.` },
|
prices: [
|
||||||
yearly: { title: "Yearly", subtitle: `Pay once a year.` },
|
createPrice(
|
||||||
education: {
|
IS_DEV
|
||||||
|
? "pri_01j00fxsryh5jfyfjqq5tsx4c7"
|
||||||
|
: "pri_01j02ddzyc1m63s3b1kq6g4bnn",
|
||||||
|
"monthly",
|
||||||
|
7.49
|
||||||
|
),
|
||||||
|
createPrice(
|
||||||
|
IS_DEV
|
||||||
|
? "pri_01j00fzbz01rfn3f30crwxc7y9"
|
||||||
|
: "pri_01j02dezv9v5ncw3e16ncvz7x7",
|
||||||
|
"yearly",
|
||||||
|
6.49
|
||||||
|
),
|
||||||
|
createPrice(
|
||||||
|
IS_DEV
|
||||||
|
? "pri_01j00g0wpmj6m9vcvpjq97jwpp"
|
||||||
|
: "pri_01j02dfxz6y8hghfbr5p8cqtgb",
|
||||||
|
"5-year",
|
||||||
|
5.49
|
||||||
|
)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "education",
|
||||||
title: "Education",
|
title: "Education",
|
||||||
subtitle: "Special offer for students & teachers."
|
recurring: false,
|
||||||
|
prices: [
|
||||||
|
createPrice(
|
||||||
|
IS_DEV
|
||||||
|
? "pri_01j00g6asxjskghjcrbxpbd26e"
|
||||||
|
: "pri_01j02dh4mwkbsvpygyf1bd9whs",
|
||||||
|
"yearly",
|
||||||
|
19.99
|
||||||
|
)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
];
|
||||||
|
|
||||||
let CACHED_PLANS: Plan[];
|
let CACHED_PLANS: Plan[];
|
||||||
export async function getPlans(): Promise<Plan[] | null> {
|
export async function getPlans(): Promise<Plan[] | null> {
|
||||||
|
return DEFAULT_PLANS;
|
||||||
if (IS_TESTING || import.meta.env.DEV) return DEFAULT_PLANS;
|
if (IS_TESTING || import.meta.env.DEV) return DEFAULT_PLANS;
|
||||||
if (CACHED_PLANS) return CACHED_PLANS;
|
if (CACHED_PLANS) return CACHED_PLANS;
|
||||||
|
|
||||||
@@ -77,7 +148,7 @@ export async function getPlans(): Promise<Plan[] | null> {
|
|||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const plans = (await response.json()) as Plan[];
|
const plans = (await response.json()) as Plan[];
|
||||||
plans.push(EDUCATION_PLAN);
|
// plans.push(EDUCATION_PLAN);
|
||||||
CACHED_PLANS = plans;
|
CACHED_PLANS = plans;
|
||||||
return plans;
|
return plans;
|
||||||
}
|
}
|
||||||
@@ -94,8 +165,8 @@ export function usePlans() {
|
|||||||
const plans = await getPlans();
|
const plans = await getPlans();
|
||||||
if (!plans) return;
|
if (!plans) return;
|
||||||
setPlans(plans);
|
setPlans(plans);
|
||||||
setDiscount(Math.max(...plans.map((p) => p.discount?.amount || 0)));
|
// setDiscount(Math.max(...plans.map((p) => p.discount?.amount || 0)));
|
||||||
setCountry(plans[0].country);
|
// setCountry(plans[0].country);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Plan, PricingInfo } from "./types";
|
import { Plan, Price, PricingInfo } from "./types";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { create as produce } from "mutative";
|
import { create as produce } from "mutative";
|
||||||
|
|
||||||
@@ -25,7 +25,8 @@ interface ICheckoutStore {
|
|||||||
isCompleted: boolean;
|
isCompleted: boolean;
|
||||||
completeCheckout: () => void;
|
completeCheckout: () => void;
|
||||||
selectedPlan?: Plan;
|
selectedPlan?: Plan;
|
||||||
selectPlan: (plan?: Plan) => void;
|
selectedPrice?: Price;
|
||||||
|
selectPlan: (plan?: Plan, price?: Price) => void;
|
||||||
pricingInfo?: PricingInfo;
|
pricingInfo?: PricingInfo;
|
||||||
updatePrice: (pricingInfo?: PricingInfo) => void;
|
updatePrice: (pricingInfo?: PricingInfo) => void;
|
||||||
isApplyingCoupon: boolean;
|
isApplyingCoupon: boolean;
|
||||||
@@ -37,6 +38,7 @@ interface ICheckoutStore {
|
|||||||
export const useCheckoutStore = create<ICheckoutStore>((set) => ({
|
export const useCheckoutStore = create<ICheckoutStore>((set) => ({
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
selectedPlan: undefined,
|
selectedPlan: undefined,
|
||||||
|
selectedPrice: undefined,
|
||||||
pricingInfo: undefined,
|
pricingInfo: undefined,
|
||||||
couponCode: undefined,
|
couponCode: undefined,
|
||||||
isApplyingCoupon: false,
|
isApplyingCoupon: false,
|
||||||
@@ -46,10 +48,11 @@ export const useCheckoutStore = create<ICheckoutStore>((set) => ({
|
|||||||
state.isCompleted = true;
|
state.isCompleted = true;
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
selectPlan: (plan) =>
|
selectPlan: (plan, price) =>
|
||||||
set(
|
set(
|
||||||
produce((state: ICheckoutStore) => {
|
produce((state: ICheckoutStore) => {
|
||||||
state.selectedPlan = plan;
|
state.selectedPlan = plan;
|
||||||
|
state.selectedPrice = price;
|
||||||
state.pricingInfo = undefined;
|
state.pricingInfo = undefined;
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,73 +17,19 @@ You should have received a copy of the GNU General Public License
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type Period = "monthly" | "yearly" | "education";
|
import {
|
||||||
|
CheckoutEventNames,
|
||||||
|
CheckoutEventsCustomer,
|
||||||
|
CheckoutEventsDiscount,
|
||||||
|
CheckoutEventsItem,
|
||||||
|
PaddleEventData
|
||||||
|
} from "@paddle/paddle-js";
|
||||||
|
|
||||||
export enum PaddleEvents {
|
export type Period = "monthly" | "yearly" | "5-year";
|
||||||
/** Checkout has been initialized on the page **/
|
|
||||||
"Checkout.Loaded" = "Checkout.Loaded",
|
|
||||||
/** Checkout has been closed on the page. This is equivalent to when the "closeCallback" checkout parameter is fired . **/
|
|
||||||
"Checkout.Close" = "Checkout.Close",
|
|
||||||
/** Checkout has been completed successfully. This is equivalent to when the "successCallback" checkout parameter is fired . **/
|
|
||||||
"Checkout.Complete" = "Checkout.Complete",
|
|
||||||
/** User has opted into/out of marketing emails in the checkout **/
|
|
||||||
"Checkout.User.Subscribed" = "Checkout.User.Subscribed",
|
|
||||||
/** User has changed the quantity in the checkout **/
|
|
||||||
"Checkout.Quantity.Change" = "Checkout.Quantity.Change",
|
|
||||||
/** User has proceeded past the email checkout step **/
|
|
||||||
"Checkout.Login" = "Checkout.Login",
|
|
||||||
/** User selected 'Not you? Change' in bottom right of checkout **/
|
|
||||||
"Checkout.Logout" = "Checkout.Logout",
|
|
||||||
/** Payment method has been selected **/
|
|
||||||
"Checkout.PaymentMethodSelected" = "Checkout.PaymentMethodSelected",
|
|
||||||
/** User clicked 'Add Coupon' **/
|
|
||||||
"Checkout.Coupon.Add" = "Checkout.Coupon.Add",
|
|
||||||
/** User has submitted a coupon **/
|
|
||||||
"Checkout.Coupon.Submit" = "Checkout.Coupon.Submit",
|
|
||||||
/** User has cancelled the coupon page **/
|
|
||||||
"Checkout.Coupon.Cancel" = "Checkout.Coupon.Cancel",
|
|
||||||
/** Valid coupon applied to purchase **/
|
|
||||||
"Checkout.Coupon.Applied" = "Checkout.Coupon.Applied",
|
|
||||||
/** Coupon has been removed **/
|
|
||||||
"Checkout.Coupon.Remove" = "Checkout.Coupon.Remove",
|
|
||||||
/** Any generic checkout error, like an invalid VAT number or payment failure **/
|
|
||||||
"Checkout.Error" = "Checkout.Error",
|
|
||||||
/** User proceeded past the location page **/
|
|
||||||
"Checkout.Location.Submit" = "Checkout.Location.Submit",
|
|
||||||
/** Language has been changed in the bottom right **/
|
|
||||||
"Checkout.Language.Change" = "Checkout.Language.Change",
|
|
||||||
/** User clicked 'Add VAT Number' **/
|
|
||||||
"Checkout.Vat.Add" = "Checkout.Vat.Add",
|
|
||||||
/** VAT screen cancelled **/
|
|
||||||
"Checkout.Vat.Cancel" = "Checkout.Vat.Cancel",
|
|
||||||
/** VAT number was submitted **/
|
|
||||||
"Checkout.Vat.Submit" = "Checkout.Vat.Submit",
|
|
||||||
/** VAT number was accepted and applied **/
|
|
||||||
"Checkout.Vat.Applied" = "Checkout.Vat.Applied",
|
|
||||||
/** VAT number was removed **/
|
|
||||||
"Checkout.Vat.Remove" = "Checkout.Vat.Remove",
|
|
||||||
/** Wire transfer details have been completed **/
|
|
||||||
"Checkout.WireTransfer.Complete" = "Checkout.WireTransfer.Complete",
|
|
||||||
/** Payment has been completed successfully. **/
|
|
||||||
"Checkout.PaymentComplete" = "Checkout.PaymentComplete",
|
|
||||||
/** User has selected "Change Payment Method" when on the payment screen **/
|
|
||||||
"Checkout.PaymentMethodChange" = "Checkout.PaymentMethodChange",
|
|
||||||
/** User has selected "Change Payment Method" when on the Wire Transfer screen **/
|
|
||||||
"Checkout.WireTransfer.PaymentMethodChange" = "Checkout.WireTransfer.PaymentMethodChange",
|
|
||||||
|
|
||||||
"Checkout.Customer.Details" = "Checkout.Customer.Details",
|
// export interface CallbackData {
|
||||||
"Checkout.RemoveSpinner" = "Checkout.RemoveSpinner"
|
// checkout?: Checkout;
|
||||||
}
|
// }
|
||||||
|
|
||||||
export interface CallbackData {
|
|
||||||
checkout?: Checkout;
|
|
||||||
coupon?: { coupon_code: string };
|
|
||||||
user?: {
|
|
||||||
email: string;
|
|
||||||
id: string;
|
|
||||||
country: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Checkout {
|
export interface Checkout {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -101,29 +47,30 @@ export interface Checkout {
|
|||||||
|
|
||||||
export type PaddleEvent = {
|
export type PaddleEvent = {
|
||||||
action: "event";
|
action: "event";
|
||||||
event: PaddleEvents;
|
event_name: CheckoutEventNames;
|
||||||
event_name: PaddleEvents;
|
callback_data: PaddleEventData;
|
||||||
callback_data: CallbackData;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlanId = "free" | "essential" | "pro" | "believer" | "education";
|
||||||
export interface Plan {
|
export interface Plan {
|
||||||
id: string;
|
// period: Period;
|
||||||
period: Period;
|
id: PlanId;
|
||||||
price: Price;
|
title: string;
|
||||||
currency: string;
|
prices: Price[];
|
||||||
currencySymbol?: string;
|
recurring: boolean;
|
||||||
originalPrice: Price;
|
// currency: string;
|
||||||
discount?: Discount;
|
// originalPrice?: Price;
|
||||||
country: string;
|
// discount: number;
|
||||||
|
// country: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PricingInfo = {
|
export type PricingInfo = {
|
||||||
country: string;
|
country: string;
|
||||||
currency: string;
|
// currency: string;
|
||||||
price: Price;
|
price: Price;
|
||||||
period: Period;
|
period: Period;
|
||||||
recurringPrice: Price;
|
recurringPrice: Price;
|
||||||
discount: Discount;
|
discount?: Discount;
|
||||||
coupon?: string;
|
coupon?: string;
|
||||||
invalidCoupon?: boolean;
|
invalidCoupon?: boolean;
|
||||||
};
|
};
|
||||||
@@ -136,42 +83,13 @@ export type Discount = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface Price {
|
export interface Price {
|
||||||
gross: number;
|
id: string;
|
||||||
gross_after_discount?: number;
|
period: Period;
|
||||||
net: number;
|
subtotal: string;
|
||||||
net_after_discount?: number;
|
total: string;
|
||||||
tax: number;
|
tax: string;
|
||||||
tax_after_discount?: number;
|
discount?: string;
|
||||||
currency?: string;
|
currency: string;
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckoutDataResponse {
|
|
||||||
data: CheckoutData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckoutData {
|
|
||||||
public_checkout_id: string;
|
|
||||||
// type: string;
|
|
||||||
// uuid: string;
|
|
||||||
// vendor: Vendor;
|
|
||||||
// display_currency: string;
|
|
||||||
// charge_currency: string;
|
|
||||||
// customer: Customer;
|
|
||||||
items: Item[];
|
|
||||||
// available_payment_methods: unknown[];
|
|
||||||
// total: TotalPrice[];
|
|
||||||
// pending_payment: boolean;
|
|
||||||
// completed: boolean;
|
|
||||||
// payment_method_type: null;
|
|
||||||
// flagged_for_review: boolean;
|
|
||||||
ip_geo_country_code: string;
|
|
||||||
// tax: null;
|
|
||||||
// name: null;
|
|
||||||
// image_url: null;
|
|
||||||
// message: null;
|
|
||||||
// passthrough: string;
|
|
||||||
// redirect_url: null;
|
|
||||||
// created_at: Date;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Customer {
|
export interface Customer {
|
||||||
@@ -244,4 +162,22 @@ export type TotalPrice = CheckoutPrice & {
|
|||||||
export interface Vendor {
|
export interface Vendor {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
// type: string;
|
||||||
|
// status: string;
|
||||||
|
// transaction_id: string;
|
||||||
|
currency_code: string;
|
||||||
|
customer: CheckoutEventsCustomer;
|
||||||
|
// seller: Seller;
|
||||||
|
items: CheckoutEventsItem[];
|
||||||
|
// totals: DataRecurringTotals;
|
||||||
|
// recurring_totals: DataRecurringTotals;
|
||||||
|
// payments: Payments;
|
||||||
|
discount?: CheckoutEventsDiscount;
|
||||||
|
// is_free: boolean;
|
||||||
|
// ip_geo_country_code: string;
|
||||||
|
// custom_data: null;
|
||||||
|
// created_at: Date;
|
||||||
|
// environment: string;
|
||||||
|
// source_page: string;
|
||||||
|
// messages: any[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,19 +17,23 @@ You should have received a copy of the GNU General Public License
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Copy, Loading } from "../../../components/icons";
|
||||||
|
import { Box, Button, Link, Flex, Text } from "@theme-ui/components";
|
||||||
import { getFormattedDate } from "@notesnook/common";
|
import { getFormattedDate } from "@notesnook/common";
|
||||||
import { strings } from "@notesnook/intl";
|
import { strings } from "@notesnook/intl";
|
||||||
import { Box, Flex, Link, Text } from "@theme-ui/components";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { db } from "../../../common/db";
|
import { db } from "../../../common/db";
|
||||||
import { TransactionStatus, Transaction } from "@notesnook/core";
|
import { TransactionStatus, Transaction } from "@notesnook/core";
|
||||||
import { Loading } from "../../../components/icons";
|
import { writeToClipboard } from "../../../utils/clipboard";
|
||||||
|
import { showToast } from "../../../utils/toast";
|
||||||
|
import { TaskManager } from "../../../common/task-manager";
|
||||||
|
|
||||||
const TransactionStatusToText: Record<TransactionStatus, string> = {
|
const TransactionStatusToText: Record<TransactionStatus, string> = {
|
||||||
completed: "Completed",
|
completed: "Completed",
|
||||||
refunded: "Refunded",
|
billed: "Billed",
|
||||||
partially_refunded: "Partially refunded",
|
canceled: "Canceled",
|
||||||
disputed: "Disputed"
|
paid: "Paid",
|
||||||
|
past_due: "Past due"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BillingHistory() {
|
export function BillingHistory() {
|
||||||
@@ -87,11 +91,11 @@ export function BillingHistory() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
{ id: "date", title: strings.date(), width: "20%" },
|
{ id: "id", title: "ID", width: "5%" },
|
||||||
{ id: "orderId", title: strings.orderId(), width: "20%" },
|
{ id: "billedAt", title: "Billed at", width: "20%" },
|
||||||
{ id: "amount", title: strings.amount(), width: "20%" },
|
{ id: "amount", title: strings.amount(), width: "20%" },
|
||||||
{ id: "status", title: strings.status(), width: "20%" },
|
{ id: "status", title: strings.status(), width: "20%" },
|
||||||
{ id: "receipt", title: strings.receipt(), width: "20%" }
|
{ id: "invoice", title: "Invoice", width: "20%" }
|
||||||
].map((column) =>
|
].map((column) =>
|
||||||
!column.title ? (
|
!column.title ? (
|
||||||
<th key={column.id} />
|
<th key={column.id} />
|
||||||
@@ -119,29 +123,51 @@ export function BillingHistory() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{transactions.map((transaction) => (
|
{transactions.map((transaction) => (
|
||||||
<Box key={transaction.order_id} as="tr" sx={{ height: 30 }}>
|
<Box key={transaction.id} as="tr" sx={{ height: 30 }}>
|
||||||
<Text as="td" variant="body">
|
<Text as="td" variant="body">
|
||||||
{getFormattedDate(transaction.created_at, "date")}
|
<Copy
|
||||||
|
size={16}
|
||||||
|
onClick={() =>
|
||||||
|
writeToClipboard({ "text/plain": transaction.id })
|
||||||
|
}
|
||||||
|
sx={{ cursor: "pointer" }}
|
||||||
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
<Text as="td" variant="body">
|
<Text as="td" variant="body">
|
||||||
{transaction.order_id}
|
{getFormattedDate(transaction.billed_at, "date")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text as="td" variant="body">
|
<Text as="td" variant="body">
|
||||||
{transaction.amount} {transaction.currency}
|
{(transaction.details.totals.grand_total / 100).toFixed(2)}{" "}
|
||||||
|
{transaction.details.totals.currency_code}
|
||||||
</Text>
|
</Text>
|
||||||
<Text as="td" variant="body">
|
<Text as="td" variant="body">
|
||||||
{strings.transactionStatusToText(transaction.status)}
|
{strings.transactionStatusToText(transaction.status)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text as="td" variant="body">
|
<Text as="td" variant="body">
|
||||||
<Link
|
<Button
|
||||||
href={transaction.receipt_url}
|
variant="anchor"
|
||||||
target="_blank"
|
onClick={async () => {
|
||||||
rel="noreferer nofollow"
|
const url = await TaskManager.startTask({
|
||||||
variant="text.subBody"
|
type: "modal",
|
||||||
sx={{ color: "accent" }}
|
title: "Getting invoice",
|
||||||
|
subtitle: "This might take a minute or two.",
|
||||||
|
action() {
|
||||||
|
return db.subscriptions.invoice(transaction.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!url || url instanceof Error)
|
||||||
|
return showToast(
|
||||||
|
"error",
|
||||||
|
url instanceof Error
|
||||||
|
? `Failed to get invoice for this transaction: ${url.message}`
|
||||||
|
: "No invoice found for this transaction."
|
||||||
|
);
|
||||||
|
window.open(url, "_blank");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{strings.viewReceipt()}
|
Download
|
||||||
</Link>
|
</Button>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -151,28 +177,3 @@ export function BillingHistory() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
|
||||||
/* <Flex sx={{ flexDirection: "column", gap: 1, mt: 1 }}>
|
|
||||||
<Flex
|
|
||||||
key={transaction.order_id}
|
|
||||||
sx={{
|
|
||||||
justifyContent: "space-between",
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
":last-of-type": { borderBottom: "none" },
|
|
||||||
pb: 1
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Flex sx={{ flexDirection: "column" }}>
|
|
||||||
<Text variant="subtitle">Order #{transaction.order_id}</Text>
|
|
||||||
|
|
||||||
</Flex>
|
|
||||||
<Flex sx={{ flexDirection: "column", alignItems: "end" }}>
|
|
||||||
<Text variant="body">
|
|
||||||
{transaction.amount} {transaction.currency}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Flex> */
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,69 +18,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useStore as useUserStore } from "../../../stores/user-store";
|
import { useStore as useUserStore } from "../../../stores/user-store";
|
||||||
import { Button, Flex, Text } from "@theme-ui/components";
|
import { Flex, Text } from "@theme-ui/components";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import { SUBSCRIPTION_STATUS } from "../../../common/constants";
|
|
||||||
import { db } from "../../../common/db";
|
|
||||||
import { TaskManager } from "../../../common/task-manager";
|
|
||||||
import { showToast } from "../../../utils/toast";
|
|
||||||
import { Loading } from "../../../components/icons";
|
|
||||||
import { Features } from "../../buy-dialog/features";
|
|
||||||
import { ConfirmDialog } from "../../confirm";
|
|
||||||
import { BuyDialog } from "../../buy-dialog";
|
|
||||||
import { strings } from "@notesnook/intl";
|
import { strings } from "@notesnook/intl";
|
||||||
import { PromptDialog } from "../../prompt";
|
import { getSubscriptionInfo } from "./user-profile";
|
||||||
|
|
||||||
export function SubscriptionStatus() {
|
export function SubscriptionStatus() {
|
||||||
const user = useUserStore((store) => store.user);
|
const user = useUserStore((store) => store.user);
|
||||||
|
|
||||||
const [activateTrial, isActivatingTrial] = useAction(async () => {
|
const { title, autoRenew, expiryDate, trial, legacy } =
|
||||||
await db.user.activateTrial();
|
getSubscriptionInfo(user);
|
||||||
});
|
|
||||||
|
|
||||||
const provider =
|
const subtitle = autoRenew
|
||||||
strings.subscriptionProviderInfo[user?.subscription?.provider || 0];
|
? `Your subscription will auto renew on ${expiryDate}.`
|
||||||
const {
|
: `Your account will automatically downgrade to the Free plan on ${expiryDate}.`;
|
||||||
isTrial,
|
|
||||||
isBeta,
|
|
||||||
isPro,
|
|
||||||
isBasic,
|
|
||||||
isProCancelled,
|
|
||||||
isProExpired,
|
|
||||||
remainingDays
|
|
||||||
} = useMemo(() => {
|
|
||||||
const type = user?.subscription?.type;
|
|
||||||
const expiry = user?.subscription?.expiry;
|
|
||||||
if (!expiry) return { isBasic: true, remainingDays: 0 };
|
|
||||||
return {
|
|
||||||
remainingDays: dayjs(expiry).diff(dayjs(), "day"),
|
|
||||||
isTrial: type === SUBSCRIPTION_STATUS.TRIAL,
|
|
||||||
isBasic: type === SUBSCRIPTION_STATUS.BASIC,
|
|
||||||
isBeta: type === SUBSCRIPTION_STATUS.BETA,
|
|
||||||
isPro: type === SUBSCRIPTION_STATUS.PREMIUM,
|
|
||||||
isProCancelled: type === SUBSCRIPTION_STATUS.PREMIUM_CANCELED,
|
|
||||||
isProExpired: type === SUBSCRIPTION_STATUS.PREMIUM_EXPIRED
|
|
||||||
};
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const subtitle = useMemo(() => {
|
|
||||||
const expiryDate = dayjs(user?.subscription?.expiry).format("MMMM D, YYYY");
|
|
||||||
const startDate = dayjs(user?.subscription?.start).format("MMMM D, YYYY");
|
|
||||||
return isPro
|
|
||||||
? provider.type === "Streetwriters" || provider.type === "Gift card"
|
|
||||||
? `Ending on ${expiryDate}`
|
|
||||||
: `Next payment on ${expiryDate}.`
|
|
||||||
: isProCancelled
|
|
||||||
? `Ending on ${expiryDate}.`
|
|
||||||
: isProExpired
|
|
||||||
? "Your account will be downgraded to Basic in 3 days."
|
|
||||||
: isBeta
|
|
||||||
? `Beta member since ${startDate}`
|
|
||||||
: isTrial
|
|
||||||
? `Ending on ${expiryDate}`
|
|
||||||
: null;
|
|
||||||
}, [isPro, isProExpired, isProCancelled, isBeta, isTrial, user, provider]);
|
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
return (
|
return (
|
||||||
@@ -92,8 +42,8 @@ export function SubscriptionStatus() {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "start",
|
alignItems: "start",
|
||||||
bg: "var(--background-secondary)",
|
bg: "var(--background-secondary)",
|
||||||
p: 2,
|
p: 2
|
||||||
mb: isBasic ? 0 : 4
|
// mb: isBasic ? 0 : 4
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
@@ -113,144 +63,14 @@ export function SubscriptionStatus() {
|
|||||||
mt: 2
|
mt: 2
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{remainingDays > 0 && (isPro || isProCancelled)
|
{title}
|
||||||
? `Pro`
|
{legacy ? " (legacy)" : ""}
|
||||||
: remainingDays > 0 && isTrial
|
|
||||||
? "Trial"
|
|
||||||
: isBeta
|
|
||||||
? "Beta user"
|
|
||||||
: "Basic"}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="body">
|
<Text variant="body">
|
||||||
{remainingDays > 0 && (isPro || isProCancelled || isTrial || isBeta)
|
{trial ? "Your free trial is on-going." : subtitle}
|
||||||
? `Access to all Pro features including unlimited storage for attachments,
|
|
||||||
notebooks & tags.`
|
|
||||||
: "Access only to basic features including unlimited notes & end-to-end encrypted syncing to unlimited devices."}
|
|
||||||
</Text>
|
</Text>
|
||||||
{remainingDays > 0 && (isPro || isProCancelled) ? (
|
|
||||||
<Text sx={{ mt: 2 }} variant="subBody">
|
|
||||||
{subtitle}. {provider.desc()}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<Flex sx={{ gap: 1, mt: 2 }}>
|
|
||||||
{provider.type === "Web" && (isPro || isProCancelled) ? (
|
|
||||||
<>
|
|
||||||
{isPro && (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={async () => {
|
|
||||||
const cancelSubscription = await ConfirmDialog.show({
|
|
||||||
title: "Cancel subscription?",
|
|
||||||
message:
|
|
||||||
"Cancelling your subscription will automatically downgrade you to the Basic plan at the end of your billing period. You will have to resubscribe to continue using the Pro features.",
|
|
||||||
negativeButtonText: strings.no(),
|
|
||||||
positiveButtonText: strings.yes()
|
|
||||||
});
|
|
||||||
if (cancelSubscription) {
|
|
||||||
await TaskManager.startTask({
|
|
||||||
type: "modal",
|
|
||||||
title: "Cancelling your subscription",
|
|
||||||
subtitle: strings.pleaseWait() + "...",
|
|
||||||
action: () => db.subscriptions.cancel()
|
|
||||||
})
|
|
||||||
.catch((e) => showToast("error", e.message))
|
|
||||||
.then(() =>
|
|
||||||
showToast("success", strings.subCanceled())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{strings.cancelSub()}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={async () => {
|
|
||||||
const refundSubscription = await ConfirmDialog.show({
|
|
||||||
title: "Request refund?",
|
|
||||||
message:
|
|
||||||
"You will only be issued a refund if you are eligible as per our refund policy. Your account will be immediately downgraded to Basic and your funds will be transferred to your account within 24 hours.",
|
|
||||||
negativeButtonText: strings.no(),
|
|
||||||
positiveButtonText: strings.yes()
|
|
||||||
});
|
|
||||||
if (refundSubscription) {
|
|
||||||
await TaskManager.startTask({
|
|
||||||
type: "modal",
|
|
||||||
title: "Requesting refund for your subscription",
|
|
||||||
subtitle: strings.pleaseWait() + "...",
|
|
||||||
action: () => db.subscriptions.refund()
|
|
||||||
})
|
|
||||||
.catch((e) => showToast("error", e.message))
|
|
||||||
.then(() => showToast("success", strings.refundIssued()));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Request a refund
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{!isPro && (
|
|
||||||
<>
|
|
||||||
<Button variant="accent" onClick={() => BuyDialog.show({})}>
|
|
||||||
{isProCancelled ? strings.resubToPro() : strings.upgradeToPro()}
|
|
||||||
</Button>
|
|
||||||
{isBasic && (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={activateTrial}
|
|
||||||
sx={{ bg: "background" }}
|
|
||||||
>
|
|
||||||
{isActivatingTrial ? (
|
|
||||||
<Loading size={16} />
|
|
||||||
) : (
|
|
||||||
strings.tryFreeFor14Days()
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={async () => {
|
|
||||||
const giftCode = await PromptDialog.show({
|
|
||||||
title: strings.redeemGiftCode(),
|
|
||||||
description: strings.redeemGiftCodeDesc()
|
|
||||||
});
|
|
||||||
if (giftCode) {
|
|
||||||
await TaskManager.startTask({
|
|
||||||
type: "modal",
|
|
||||||
title: strings.redeemingGiftCode(),
|
|
||||||
subtitle: strings.pleaseWait() + "...",
|
|
||||||
action: () => db.subscriptions.redeemCode(giftCode)
|
|
||||||
}).catch((e) => showToast("error", e.message));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sx={{ bg: "background" }}
|
|
||||||
>
|
|
||||||
{strings.redeemGiftCode()}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
{isBasic ? <Features /> : null}
|
{/* {isBasic ? <Features /> : null} */}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAction(action: () => Promise<void>) {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const _action = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
await action();
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error) {
|
|
||||||
showToast("error", e.message);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [action]);
|
|
||||||
|
|
||||||
return [_action, isLoading] as const;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,19 +18,79 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Flex, Image, Text } from "@theme-ui/components";
|
import { Flex, Image, Text } from "@theme-ui/components";
|
||||||
import { Edit, User } from "../../../components/icons";
|
import { Edit, User as UserIcon } from "../../../components/icons";
|
||||||
import { useStore as useUserStore } from "../../../stores/user-store";
|
import { useStore as useUserStore } from "../../../stores/user-store";
|
||||||
import { useStore as useSettingStore } from "../../../stores/setting-store";
|
import { useStore as useSettingStore } from "../../../stores/setting-store";
|
||||||
import { getObjectIdTimestamp } from "@notesnook/core";
|
import { getObjectIdTimestamp } from "@notesnook/core";
|
||||||
import { getFormattedDate } from "@notesnook/common";
|
import { getFormattedDate } from "@notesnook/common";
|
||||||
import { SUBSCRIPTION_STATUS } from "../../../common/constants";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { db } from "../../../common/db";
|
import { db } from "../../../common/db";
|
||||||
import { showToast } from "../../../utils/toast";
|
import { showToast } from "../../../utils/toast";
|
||||||
import { EditProfilePictureDialog } from "../../edit-profile-picture-dialog";
|
import { EditProfilePictureDialog } from "../../edit-profile-picture-dialog";
|
||||||
import { PromptDialog } from "../../prompt";
|
import { PromptDialog } from "../../prompt";
|
||||||
import { strings } from "@notesnook/intl";
|
import { strings } from "@notesnook/intl";
|
||||||
|
import {
|
||||||
|
SubscriptionPlan,
|
||||||
|
SubscriptionProvider,
|
||||||
|
SubscriptionStatus,
|
||||||
|
SubscriptionType,
|
||||||
|
User
|
||||||
|
} from "@notesnook/core";
|
||||||
|
|
||||||
|
export function getSubscriptionInfo(user: User | undefined): {
|
||||||
|
title: string;
|
||||||
|
trial?: boolean;
|
||||||
|
paused?: boolean;
|
||||||
|
canceled?: boolean;
|
||||||
|
legacy?: boolean;
|
||||||
|
expiryDate?: string;
|
||||||
|
startDate?: string;
|
||||||
|
autoRenew?: boolean;
|
||||||
|
} {
|
||||||
|
const { type, expiry, plan, status, provider } = user?.subscription || {};
|
||||||
|
if (!expiry) return { title: "FREE" };
|
||||||
|
|
||||||
|
const legacy = !!type;
|
||||||
|
const trial =
|
||||||
|
status === SubscriptionStatus.TRIAL || type === SubscriptionType.TRIAL;
|
||||||
|
const title =
|
||||||
|
plan === SubscriptionPlan.BELIEVER
|
||||||
|
? "BELIEVER"
|
||||||
|
: plan === SubscriptionPlan.PRO ||
|
||||||
|
type === SubscriptionType.PREMIUM ||
|
||||||
|
type === SubscriptionType.PREMIUM_CANCELED
|
||||||
|
? "PRO"
|
||||||
|
: plan === SubscriptionPlan.ESSENTIAL
|
||||||
|
? "ESSENTIAL"
|
||||||
|
: plan === SubscriptionPlan.EDUCATION
|
||||||
|
? "EDUCATION"
|
||||||
|
: "FREE";
|
||||||
|
const autoRenew =
|
||||||
|
(status === SubscriptionStatus.ACTIVE ||
|
||||||
|
status === SubscriptionStatus.TRIAL) &&
|
||||||
|
provider !== SubscriptionProvider.STREETWRITERS;
|
||||||
|
const paused = status === SubscriptionStatus.PAUSED;
|
||||||
|
const canceled = status === SubscriptionStatus.CANCELED;
|
||||||
|
|
||||||
|
const expiryDate =
|
||||||
|
(!!user?.subscription?.expiry &&
|
||||||
|
getFormattedDate(user?.subscription?.expiry, "date-time")) ||
|
||||||
|
undefined;
|
||||||
|
const startDate =
|
||||||
|
(!!user?.subscription?.start &&
|
||||||
|
getFormattedDate(user?.subscription?.start, "date-time")) ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
legacy,
|
||||||
|
trial,
|
||||||
|
expiryDate,
|
||||||
|
startDate,
|
||||||
|
autoRenew,
|
||||||
|
paused,
|
||||||
|
canceled
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
minimal?: boolean;
|
minimal?: boolean;
|
||||||
@@ -40,28 +100,7 @@ export function UserProfile({ minimal }: Props) {
|
|||||||
const user = useUserStore((store) => store.user);
|
const user = useUserStore((store) => store.user);
|
||||||
const profile = useSettingStore((store) => store.profile);
|
const profile = useSettingStore((store) => store.profile);
|
||||||
|
|
||||||
const {
|
const { title, legacy, trial } = getSubscriptionInfo(user);
|
||||||
isTrial,
|
|
||||||
isBeta,
|
|
||||||
isPro,
|
|
||||||
isBasic,
|
|
||||||
isProCancelled,
|
|
||||||
isProExpired,
|
|
||||||
remainingDays
|
|
||||||
} = useMemo(() => {
|
|
||||||
const type = user?.subscription?.type;
|
|
||||||
const expiry = user?.subscription?.expiry;
|
|
||||||
if (!expiry) return { isBasic: true, remainingDays: 0 };
|
|
||||||
return {
|
|
||||||
remainingDays: dayjs(expiry).diff(dayjs(), "day"),
|
|
||||||
isTrial: type === SUBSCRIPTION_STATUS.TRIAL,
|
|
||||||
isBasic: type === SUBSCRIPTION_STATUS.BASIC,
|
|
||||||
isBeta: type === SUBSCRIPTION_STATUS.BETA,
|
|
||||||
isPro: type === SUBSCRIPTION_STATUS.PREMIUM,
|
|
||||||
isProCancelled: type === SUBSCRIPTION_STATUS.PREMIUM_CANCELED,
|
|
||||||
isProExpired: type === SUBSCRIPTION_STATUS.PREMIUM_EXPIRED
|
|
||||||
};
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
if (!user || !user.id)
|
if (!user || !user.id)
|
||||||
return (
|
return (
|
||||||
@@ -83,7 +122,7 @@ export function UserProfile({ minimal }: Props) {
|
|||||||
borderRadius: 80
|
borderRadius: 80
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<User size={minimal ? 15 : 20} />
|
<UserIcon size={minimal ? 15 : 20} />
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex sx={{ flexDirection: "column" }}>
|
<Flex sx={{ flexDirection: "column" }}>
|
||||||
<Text variant={minimal ? "body" : "subtitle"}>
|
<Text variant={minimal ? "body" : "subtitle"}>
|
||||||
@@ -126,7 +165,7 @@ export function UserProfile({ minimal }: Props) {
|
|||||||
src={profile.profilePicture}
|
src={profile.profilePicture}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<User size={minimal ? 20 : 24} />
|
<UserIcon size={minimal ? 20 : 24} />
|
||||||
)}
|
)}
|
||||||
<Flex
|
<Flex
|
||||||
id="profile-picture-edit"
|
id="profile-picture-edit"
|
||||||
@@ -160,13 +199,7 @@ export function UserProfile({ minimal }: Props) {
|
|||||||
color: "accent"
|
color: "accent"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{remainingDays > 0 && (isPro || isProCancelled)
|
{`${title}${trial ? " (trial)" : ""}${legacy ? " (legacy)" : ""}`}
|
||||||
? `PRO`
|
|
||||||
: remainingDays > 0 && isTrial
|
|
||||||
? "TRIAL"
|
|
||||||
: isBeta
|
|
||||||
? "BETA TESTER"
|
|
||||||
: "BASIC"}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text variant={minimal ? "body" : "subtitle"}>
|
<Text variant={minimal ? "body" : "subtitle"}>
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ import { BillingHistory } from "./components/billing-history";
|
|||||||
import { useStore as useUserStore } from "../../stores/user-store";
|
import { useStore as useUserStore } from "../../stores/user-store";
|
||||||
import { isUserSubscribed } from "../../hooks/use-is-user-premium";
|
import { isUserSubscribed } from "../../hooks/use-is-user-premium";
|
||||||
import { strings } from "@notesnook/intl";
|
import { strings } from "@notesnook/intl";
|
||||||
|
import {
|
||||||
|
SubscriptionPlan,
|
||||||
|
SubscriptionProvider,
|
||||||
|
SubscriptionStatus as SubscriptionStatusEnum
|
||||||
|
} from "@notesnook/core";
|
||||||
|
import { TaskManager } from "../../common/task-manager";
|
||||||
|
import { ConfirmDialog } from "../confirm";
|
||||||
|
|
||||||
export const SubscriptionSettings: SettingsGroup[] = [
|
export const SubscriptionSettings: SettingsGroup[] = [
|
||||||
{
|
{
|
||||||
@@ -32,13 +39,59 @@ export const SubscriptionSettings: SettingsGroup[] = [
|
|||||||
section: "subscription",
|
section: "subscription",
|
||||||
header: SubscriptionStatus,
|
header: SubscriptionStatus,
|
||||||
settings: [
|
settings: [
|
||||||
|
{
|
||||||
|
key: "auto-renew",
|
||||||
|
title: "Auto renew",
|
||||||
|
onStateChange: (listener) =>
|
||||||
|
useUserStore.subscribe((s) => s.user, listener),
|
||||||
|
description:
|
||||||
|
"Toggle auto renew to avoid any surprise charges. If you do not turn auto renew back on, you'll be automatically downgraded to the Free plan at the end of your billing period.",
|
||||||
|
isHidden: () => {
|
||||||
|
const user = useUserStore.getState().user;
|
||||||
|
const status = user?.subscription.status;
|
||||||
|
return (
|
||||||
|
user?.subscription.provider !== SubscriptionProvider.PADDLE ||
|
||||||
|
!isUserSubscribed(user) ||
|
||||||
|
status === SubscriptionStatusEnum.CANCELED ||
|
||||||
|
status === SubscriptionStatusEnum.EXPIRED
|
||||||
|
);
|
||||||
|
},
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: "toggle",
|
||||||
|
isToggled: () =>
|
||||||
|
useUserStore.getState().user?.subscription.status ===
|
||||||
|
SubscriptionStatusEnum.ACTIVE,
|
||||||
|
async toggle() {
|
||||||
|
try {
|
||||||
|
const user = useUserStore.getState().user;
|
||||||
|
const status = user?.subscription.status;
|
||||||
|
if (status === SubscriptionStatusEnum.ACTIVE)
|
||||||
|
await db.subscriptions.pause();
|
||||||
|
else await db.subscriptions.resume();
|
||||||
|
useUserStore.setState((state) => {
|
||||||
|
state.user!.subscription.status =
|
||||||
|
status === SubscriptionStatusEnum.ACTIVE
|
||||||
|
? SubscriptionStatusEnum.PAUSED
|
||||||
|
: SubscriptionStatusEnum.ACTIVE;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
showToast("error", (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "payment-method",
|
key: "payment-method",
|
||||||
title: strings.paymentMethod(),
|
title: strings.paymentMethod(),
|
||||||
description: strings.changePaymentMethodDescription(),
|
description: strings.changePaymentMethodDescription(),
|
||||||
isHidden: () => {
|
isHidden: () => {
|
||||||
const user = useUserStore.getState().user;
|
const user = useUserStore.getState().user;
|
||||||
return !isUserSubscribed(user) || user?.subscription.provider !== 3;
|
return (
|
||||||
|
user?.subscription.provider !== SubscriptionProvider.PADDLE ||
|
||||||
|
!isUserSubscribed(user)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
components: [
|
components: [
|
||||||
{
|
{
|
||||||
@@ -46,7 +99,12 @@ export const SubscriptionSettings: SettingsGroup[] = [
|
|||||||
title: strings.update(),
|
title: strings.update(),
|
||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
window.open(await db.subscriptions.updateUrl(), "_blank");
|
const urls = await db.subscriptions.urls();
|
||||||
|
if (!urls)
|
||||||
|
throw new Error(
|
||||||
|
"Failed to get subscription management urls. Please contact us at support@streetwriters.co so we can help you update your payment method."
|
||||||
|
);
|
||||||
|
window.open(urls?.update_payment_method, "_blank");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) showToast("error", e.message);
|
if (e instanceof Error) showToast("error", e.message);
|
||||||
}
|
}
|
||||||
@@ -55,12 +113,108 @@ export const SubscriptionSettings: SettingsGroup[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "cancel-subscription",
|
||||||
|
title: "Cancel subscription",
|
||||||
|
onStateChange: (listener) =>
|
||||||
|
useUserStore.subscribe((s) => s.user, listener),
|
||||||
|
description: `Cancel your subscription to stop all future charges permanently. You will automatically be downgraded to the Free plan at the end of your billing period.
|
||||||
|
|
||||||
|
Canceled subscriptions cannot be resumed/renewed which is why it is recommended that you disable auto renew instead.`,
|
||||||
|
isHidden: () => {
|
||||||
|
const user = useUserStore.getState().user;
|
||||||
|
const status = user?.subscription.status;
|
||||||
|
return (
|
||||||
|
user?.subscription.provider !== SubscriptionProvider.PADDLE ||
|
||||||
|
!isUserSubscribed(user) ||
|
||||||
|
status === SubscriptionStatusEnum.CANCELED ||
|
||||||
|
status === SubscriptionStatusEnum.EXPIRED
|
||||||
|
);
|
||||||
|
},
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
title: "Cancel",
|
||||||
|
async action() {
|
||||||
|
const cancelSubscription = await ConfirmDialog.show({
|
||||||
|
title: "Cancel subscription?",
|
||||||
|
message:
|
||||||
|
"Cancel your subscription to stop all future charges permanently. You will automatically be downgraded to the Free plan at the end of your billing period.",
|
||||||
|
negativeButtonText: "No",
|
||||||
|
positiveButtonText: "Yes"
|
||||||
|
});
|
||||||
|
if (cancelSubscription) {
|
||||||
|
await TaskManager.startTask({
|
||||||
|
type: "modal",
|
||||||
|
title: "Cancelling your subscription",
|
||||||
|
subtitle: "Please wait...",
|
||||||
|
action: () => db.subscriptions.cancel()
|
||||||
|
})
|
||||||
|
.catch((e) => showToast("error", e.message))
|
||||||
|
.then(() =>
|
||||||
|
showToast("success", "Your subscription has been canceled.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
variant: "error"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "refund-subscription",
|
||||||
|
title: "Refund subscription",
|
||||||
|
onStateChange: (listener) =>
|
||||||
|
useUserStore.subscribe((s) => s.user, listener),
|
||||||
|
description: `You will only be issued a refund if you are eligible as per our refund policy. Your account will immediately be downgraded to Basic and your funds will be transferred to your account within 24 hours.`,
|
||||||
|
isHidden: () => {
|
||||||
|
const user = useUserStore.getState().user;
|
||||||
|
return (
|
||||||
|
user?.subscription.provider !== SubscriptionProvider.PADDLE ||
|
||||||
|
!isUserSubscribed(user) ||
|
||||||
|
user.subscription.plan === SubscriptionPlan.EDUCATION
|
||||||
|
);
|
||||||
|
},
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
title: "Refund",
|
||||||
|
async action() {
|
||||||
|
const refundSubscription = await ConfirmDialog.show({
|
||||||
|
title: "Request refund?",
|
||||||
|
message:
|
||||||
|
"You will only be issued a refund if you are eligible as per our refund policy. Your account will immediately be downgraded to Basic and your funds will be transferred to your account within 24 hours.",
|
||||||
|
negativeButtonText: "No",
|
||||||
|
positiveButtonText: "Yes"
|
||||||
|
});
|
||||||
|
if (refundSubscription) {
|
||||||
|
await TaskManager.startTask({
|
||||||
|
type: "modal",
|
||||||
|
title: "Requesting refund for your subscription",
|
||||||
|
subtitle: "Please wait...",
|
||||||
|
action: () => db.subscriptions.refund()
|
||||||
|
})
|
||||||
|
.catch((e) => showToast("error", e.message))
|
||||||
|
.then(() =>
|
||||||
|
showToast(
|
||||||
|
"success",
|
||||||
|
"Your refund request has been sent. If you are eligible for a refund, you'll receive your funds within 24 hours. Please wait at least 24 hours before reaching out to us in case there is any problem."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
variant: "error"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "billing-history",
|
key: "billing-history",
|
||||||
title: strings.billingHistory(),
|
title: strings.billingHistory(),
|
||||||
isHidden: () => {
|
isHidden: () => {
|
||||||
const user = useUserStore.getState().user;
|
const user = useUserStore.getState().user;
|
||||||
return !isUserSubscribed(user) || user?.subscription.provider !== 3;
|
return (
|
||||||
|
user?.subscription.provider !== SubscriptionProvider.PADDLE ||
|
||||||
|
!isUserSubscribed(user)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
components: [{ type: "custom", component: BillingHistory }]
|
components: [{ type: "custom", component: BillingHistory }]
|
||||||
}
|
}
|
||||||
|
|||||||
4
apps/web/src/global.d.ts
vendored
4
apps/web/src/global.d.ts
vendored
@@ -34,6 +34,10 @@ declare global {
|
|||||||
var IS_THEME_BUILDER: boolean;
|
var IS_THEME_BUILDER: boolean;
|
||||||
var hasNativeTitlebar: boolean;
|
var hasNativeTitlebar: boolean;
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
ApplePaySession?: PaymentRequest;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthenticationExtensionsClientInputs {
|
interface AuthenticationExtensionsClientInputs {
|
||||||
prf?: {
|
prf?: {
|
||||||
eval: {
|
eval: {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { User } from "@notesnook/core";
|
import { SubscriptionPlan, SubscriptionType, User } from "@notesnook/core";
|
||||||
import { SUBSCRIPTION_STATUS } from "../common/constants";
|
import { SUBSCRIPTION_STATUS } from "../common/constants";
|
||||||
import {
|
import {
|
||||||
useStore as useUserStore,
|
useStore as useUserStore,
|
||||||
@@ -47,9 +47,10 @@ export function isUserSubscribed(user?: User) {
|
|||||||
if (!user) user = userstore.get().user;
|
if (!user) user = userstore.get().user;
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
|
|
||||||
const subStatus = user.subscription?.type;
|
const { type, plan } = user.subscription || {};
|
||||||
return (
|
return (
|
||||||
subStatus === SUBSCRIPTION_STATUS.PREMIUM ||
|
(type === SubscriptionType.PREMIUM ||
|
||||||
subStatus === SUBSCRIPTION_STATUS.PREMIUM_CANCELED
|
type === SubscriptionType.PREMIUM_CANCELED) &&
|
||||||
|
plan !== SubscriptionPlan.FREE
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
61
apps/web/src/views/payments.tsx
Normal file
61
apps/web/src/views/payments.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "../app.css";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Flex } from "@theme-ui/components";
|
||||||
|
import { hardNavigate, useQueryParams } from "../navigation";
|
||||||
|
import { initializePaddle } from "@paddle/paddle-js";
|
||||||
|
import { CLIENT_PADDLE_TOKEN } from "../dialogs/buy-dialog/paddle";
|
||||||
|
import { Loader } from "../components/loader";
|
||||||
|
import { IS_DEV } from "../dialogs/buy-dialog/helpers";
|
||||||
|
|
||||||
|
function Payments() {
|
||||||
|
const [{ _ptxn }] = useQueryParams();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!_ptxn) return hardNavigate("/notes");
|
||||||
|
(async function () {
|
||||||
|
const paddle = await initializePaddle({
|
||||||
|
token: CLIENT_PADDLE_TOKEN,
|
||||||
|
environment: IS_DEV ? "sandbox" : "production"
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
paddle?.Checkout.open({
|
||||||
|
transactionId: _ptxn,
|
||||||
|
settings: { displayMode: "overlay" }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}, [_ptxn]);
|
||||||
|
|
||||||
|
return isLoading ? (
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
height: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader title="Loading" />
|
||||||
|
</Flex>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
export default Payments;
|
||||||
Reference in New Issue
Block a user