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/ui": "file:../../packages/ui",
|
||||
"@notesnook/web-clipper": "file:../../extensions/web-clipper",
|
||||
"@paddle/paddle-js": "^1.4.2",
|
||||
"@react-pdf-viewer/core": "^3.12.0",
|
||||
"@react-pdf-viewer/toolbar": "^3.12.0",
|
||||
"@rehookify/datepicker": "^6.6.7",
|
||||
|
||||
@@ -53,7 +53,10 @@ export type Routes = keyof typeof routes;
|
||||
|
||||
const routes = {
|
||||
"/checkout": {
|
||||
component: () => import("./views/checkout"),
|
||||
component: () => import("./views/checkout")
|
||||
},
|
||||
"/payments": {
|
||||
component: () => import("./views/payments"),
|
||||
props: {}
|
||||
},
|
||||
"/account/recovery": {
|
||||
@@ -96,6 +99,7 @@ const routes = {
|
||||
} as const;
|
||||
|
||||
const sessionExpiryExceptions: Routes[] = [
|
||||
"/payments",
|
||||
"/recover",
|
||||
"/account/recovery",
|
||||
"/sessionexpired",
|
||||
|
||||
@@ -39,14 +39,23 @@ async function initializeDatabase(persistence: DatabasePersistence) {
|
||||
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({
|
||||
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", {})
|
||||
API_HOST: `${base}:5264`,
|
||||
AUTH_HOST: `${base}:8264`,
|
||||
SSE_HOST: `${base}:7264`,
|
||||
ISSUES_HOST: `${base}:2624`,
|
||||
SUBSCRIPTIONS_HOST: `${base}:9264`,
|
||||
MONOGRAPH_HOST: `${base}:6264`
|
||||
});
|
||||
|
||||
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 useTagStore } from "../../stores/tag-store";
|
||||
import { showSortMenu } from "../group-header";
|
||||
import { BuyDialog } from "../../dialogs/buy-dialog";
|
||||
|
||||
type Route = {
|
||||
id: "notes" | "favorites" | "reminders" | "monographs" | "trash" | "archive";
|
||||
@@ -792,6 +793,7 @@ function NavigationDropdown() {
|
||||
title: strings.upgradeToPro(),
|
||||
icon: Pro.path,
|
||||
key: "upgrade",
|
||||
onClick: () => BuyDialog.show({}),
|
||||
isHidden: notLoggedIn || isPro
|
||||
},
|
||||
{
|
||||
|
||||
@@ -29,9 +29,13 @@ import Field from "../../components/field";
|
||||
import { hardNavigate } from "../../navigation";
|
||||
import { Features } from "./features";
|
||||
import { PaddleCheckout } from "./paddle";
|
||||
import { Period, Plan, PricingInfo } from "./types";
|
||||
import { PLAN_METADATA, usePlans } from "./plans";
|
||||
import { formatPeriod, getFullPeriod, PlansList } from "./plan-list";
|
||||
import { Period, Plan, PlanId, Price, PricingInfo } from "./types";
|
||||
import { usePlans } from "./plans";
|
||||
import {
|
||||
formatRecurringPeriodShort,
|
||||
getFullPeriod,
|
||||
PlansList
|
||||
} from "./plan-list";
|
||||
import { showToast } from "../../utils/toast";
|
||||
import { TaskManager } from "../../common/task-manager";
|
||||
import { db } from "../../common/db";
|
||||
@@ -48,7 +52,8 @@ import { strings } from "@notesnook/intl";
|
||||
|
||||
type BuyDialogProps = BaseDialogProps<false> & {
|
||||
couponCode?: string;
|
||||
plan?: "monthly" | "yearly" | "education";
|
||||
plan?: PlanId;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const BuyDialog = DialogManager.register(function BuyDialog(
|
||||
@@ -110,7 +115,7 @@ export const BuyDialog = DialogManager.register(function BuyDialog(
|
||||
>
|
||||
<CheckoutSideBar
|
||||
onClose={() => onClose(false)}
|
||||
initialPlan={plan}
|
||||
initialPlan={plan || "free"}
|
||||
user={user}
|
||||
/>
|
||||
</ScopedThemeProvider>
|
||||
@@ -121,7 +126,7 @@ export const BuyDialog = DialogManager.register(function BuyDialog(
|
||||
});
|
||||
|
||||
type SideBarProps = {
|
||||
initialPlan?: Period;
|
||||
initialPlan: PlanId;
|
||||
onClose: () => void;
|
||||
user?: User;
|
||||
};
|
||||
@@ -130,6 +135,7 @@ export function CheckoutSideBar(props: SideBarProps) {
|
||||
const [showPlans, setShowPlans] = useState(false);
|
||||
const onPlanSelected = useCheckoutStore((state) => state.selectPlan);
|
||||
const selectedPlan = useCheckoutStore((state) => state.selectedPlan);
|
||||
const selectedPrice = useCheckoutStore((state) => state.selectedPrice);
|
||||
const pricingInfo = useCheckoutStore((state) => state.pricingInfo);
|
||||
const couponCode = useCheckoutStore((store) => store.couponCode);
|
||||
const onApplyCoupon = useCheckoutStore((store) => store.applyCoupon);
|
||||
@@ -137,10 +143,11 @@ export function CheckoutSideBar(props: SideBarProps) {
|
||||
|
||||
if (isCheckoutCompleted) return <CheckoutCompleted onClose={onClose} />;
|
||||
|
||||
if (user && selectedPlan)
|
||||
if (user && selectedPlan && selectedPrice)
|
||||
return (
|
||||
<SelectedPlan
|
||||
plan={selectedPlan}
|
||||
price={selectedPrice}
|
||||
pricingInfo={pricingInfo}
|
||||
onChangePlan={() => {
|
||||
onApplyCoupon(undefined);
|
||||
@@ -161,13 +168,14 @@ export function CheckoutSideBar(props: SideBarProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (user && (showPlans || !!initialPlan))
|
||||
if (user)
|
||||
return (
|
||||
<PlansList
|
||||
selectedPlan={selectedPlan?.id || initialPlan || "free"}
|
||||
onPlansLoaded={(plans) => {
|
||||
if (!initialPlan || showPlans) return;
|
||||
const plan = plans.find((p) => p.period === initialPlan);
|
||||
onPlanSelected(plan);
|
||||
// if (!initialPlan || showPlans) return;
|
||||
// const plan = plans.find((p) => p.id === initialPlan);
|
||||
// onPlanSelected(plan);
|
||||
}}
|
||||
onPlanSelected={onPlanSelected}
|
||||
/>
|
||||
@@ -207,25 +215,23 @@ export function CheckoutDetails({
|
||||
user?: { id: string; email: string };
|
||||
}) {
|
||||
const selectedPlan = useCheckoutStore((state) => state.selectedPlan);
|
||||
const selectedPrice = useCheckoutStore((state) => state.selectedPrice);
|
||||
const onPriceUpdated = useCheckoutStore((state) => state.updatePrice);
|
||||
const completeCheckout = useCheckoutStore((state) => state.completeCheckout);
|
||||
const isCheckoutCompleted = useCheckoutStore((store) => store.isCompleted);
|
||||
const couponCode = useCheckoutStore((store) => store.couponCode);
|
||||
const setIsApplyingCoupon = useCheckoutStore(
|
||||
(store) => store.setIsApplyingCoupon
|
||||
);
|
||||
const theme = useThemeStore((store) => store.colorScheme);
|
||||
if (isCheckoutCompleted) return null;
|
||||
|
||||
if (selectedPlan && user)
|
||||
if (selectedPlan && user && selectedPrice)
|
||||
return (
|
||||
<PaddleCheckout
|
||||
plan={selectedPlan}
|
||||
price={selectedPrice}
|
||||
theme={theme}
|
||||
user={user}
|
||||
coupon={couponCode}
|
||||
onCompleted={completeCheckout}
|
||||
onCouponApplied={() => setIsApplyingCoupon(true)}
|
||||
onPriceUpdated={(pricingInfo) => {
|
||||
onPriceUpdated(pricingInfo);
|
||||
// console.log(
|
||||
@@ -269,9 +275,9 @@ function TrialOrUpgrade(props: TrialOrUpgradeProps) {
|
||||
<Loading sx={{ mt: 4 }} />
|
||||
) : (
|
||||
<Text variant={"body"} mt={4} sx={{ fontSize: "title" }}>
|
||||
Starting from {getCurrencySymbol(plan.currency)}
|
||||
{/* Starting from {getCurrencySymbol(plan.currency)}
|
||||
{plan.price.gross}
|
||||
{formatPeriod(plan.period)}
|
||||
{formatPeriod(plan.period)} */}
|
||||
</Text>
|
||||
)}
|
||||
{isMacStoreApp() ? (
|
||||
@@ -404,12 +410,12 @@ export function CheckoutCompleted(props: {
|
||||
|
||||
type SelectedPlanProps = {
|
||||
plan: Plan;
|
||||
price: Price;
|
||||
pricingInfo: PricingInfo | undefined;
|
||||
onChangePlan?: () => void;
|
||||
};
|
||||
function SelectedPlan(props: SelectedPlanProps) {
|
||||
const { plan, pricingInfo, onChangePlan } = props;
|
||||
const metadata = PLAN_METADATA[plan.period];
|
||||
const { plan, price, pricingInfo, onChangePlan } = props;
|
||||
const [isApplyingCoupon, setIsApplyingCoupon] = useCheckoutStore((store) => [
|
||||
store.isApplyingCoupon,
|
||||
store.setIsApplyingCoupon
|
||||
@@ -448,15 +454,15 @@ function SelectedPlan(props: SelectedPlanProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{plan.period === "monthly" ? (
|
||||
{price.period === "monthly" ? (
|
||||
<Image
|
||||
src={WorkAnywhere}
|
||||
style={{ flexShrink: 0, width: 180, height: 180 }}
|
||||
style={{ flexShrink: 0, width: 120, height: 120 }}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={WorkLate}
|
||||
style={{ flexShrink: 0, width: 180, height: 180 }}
|
||||
style={{ flexShrink: 0, width: 120, height: 120 }}
|
||||
/>
|
||||
)}
|
||||
<Text variant="heading" mt={4} sx={{ textAlign: "center" }}>
|
||||
@@ -468,9 +474,9 @@ function SelectedPlan(props: SelectedPlanProps) {
|
||||
mt={1}
|
||||
sx={{ fontSize: "subheading", textAlign: "center" }}
|
||||
>
|
||||
{metadata.title}
|
||||
{plan.title}
|
||||
</Text>
|
||||
{plan.period === "education" && (
|
||||
{plan.id === "education" && (
|
||||
<Link
|
||||
href="https://notesnook.com/education"
|
||||
target="_blank"
|
||||
@@ -531,6 +537,7 @@ function SelectedPlan(props: SelectedPlanProps) {
|
||||
variant="secondary"
|
||||
mt={4}
|
||||
px={4}
|
||||
sx={{ flexShrink: 0 }}
|
||||
onClick={onChangePlan}
|
||||
>
|
||||
Change plan
|
||||
@@ -549,43 +556,30 @@ type CheckoutPricingProps = {
|
||||
};
|
||||
export function CheckoutPricing(props: CheckoutPricingProps) {
|
||||
const { pricingInfo } = props;
|
||||
const { currency, price, discount, period, recurringPrice } = pricingInfo;
|
||||
const { price, discount, period, recurringPrice } = pricingInfo;
|
||||
const fields = [
|
||||
{
|
||||
key: "subtotal",
|
||||
label: "Subtotal",
|
||||
value: formatPrice(currency, price.net.toFixed(2), null)
|
||||
value: price.subtotal
|
||||
},
|
||||
{
|
||||
key: "tax",
|
||||
label: "Sales tax",
|
||||
color: "red",
|
||||
value: formatPrice(currency, price.tax.toFixed(2), null)
|
||||
value: price.tax
|
||||
},
|
||||
{
|
||||
key: "discount",
|
||||
label: "Discount",
|
||||
color: "accent",
|
||||
value: formatPrice(
|
||||
currency,
|
||||
discount.amount.toFixed(2),
|
||||
null,
|
||||
discount.amount > 0
|
||||
)
|
||||
color: "green",
|
||||
value: price.discount
|
||||
}
|
||||
];
|
||||
|
||||
const isDiscounted = discount.recurring || discount.amount <= 0;
|
||||
const currentTotal = formatPrice(
|
||||
currency,
|
||||
(price.gross - discount.amount).toFixed(2),
|
||||
isDiscounted ? period : undefined
|
||||
);
|
||||
const recurringTotal = formatPrice(
|
||||
currency,
|
||||
recurringPrice.gross.toFixed(2),
|
||||
period
|
||||
);
|
||||
const isRecurringDiscount = !discount || discount.recurring;
|
||||
const currentTotal = price.total;
|
||||
const recurringTotal = recurringPrice ? recurringPrice.total : undefined;
|
||||
return (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
@@ -627,19 +621,12 @@ export function CheckoutPricing(props: CheckoutPricingProps) {
|
||||
>
|
||||
{currentTotal}
|
||||
</Text>
|
||||
<Text
|
||||
as="div"
|
||||
sx={{
|
||||
fontSize: "body",
|
||||
color: "paragraph-secondary",
|
||||
fontWeight: "body"
|
||||
}}
|
||||
>
|
||||
{period === "education" && discount.amount > 0
|
||||
? "for one year"
|
||||
: isDiscounted
|
||||
<Text as="div" sx={{ fontSize: "body", color: "paragraph" }}>
|
||||
{recurringTotal
|
||||
? isRecurringDiscount
|
||||
? "forever"
|
||||
: `first ${getFullPeriod(period)} then ${recurringTotal}`}
|
||||
: `first ${getFullPeriod(period)} then ${recurringTotal}`
|
||||
: "for one year"}
|
||||
</Text>
|
||||
</Text>
|
||||
</Flex>
|
||||
@@ -653,7 +640,7 @@ function formatPrice(
|
||||
period?: Period | null,
|
||||
negative = false
|
||||
) {
|
||||
const formattedPeriod = period ? formatPeriod(period) : "";
|
||||
const formattedPeriod = period ? formatRecurringPeriodShort(period) : "";
|
||||
const currencySymbol = getCurrencySymbol(currency);
|
||||
const prefix = negative ? "-" : "";
|
||||
return `${prefix}${currencySymbol}${price}${formattedPeriod}`;
|
||||
|
||||
@@ -22,6 +22,17 @@ import {
|
||||
ICurrencySymbols
|
||||
} from "@brixtol/currency-symbols";
|
||||
|
||||
export const IS_DEV = import.meta.env.DEV || IS_TESTING;
|
||||
export function getCurrencySymbol(currency: string) {
|
||||
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 { Flex } from "@theme-ui/components";
|
||||
import { Loader } from "../../components/loader";
|
||||
import {
|
||||
CheckoutData,
|
||||
CheckoutDataResponse,
|
||||
CheckoutPrices,
|
||||
PaddleEvent,
|
||||
PaddleEvents,
|
||||
Plan,
|
||||
Price,
|
||||
PricingInfo
|
||||
} from "./types";
|
||||
import { PaddleEvent, Period, Plan, Price, PricingInfo } from "./types";
|
||||
import { ScrollContainer } from "@notesnook/ui";
|
||||
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 { 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;
|
||||
const PADDLE_ORIGIN = isDev
|
||||
export const SELLER_ID = IS_DEV ? 1506 : 128190;
|
||||
export const CLIENT_PADDLE_TOKEN = IS_DEV
|
||||
? "test_e29ab18724934c1d35a05a7d2cb"
|
||||
: "live_251f65dc0ac5ac364e44817fe92";
|
||||
const PADDLE_ORIGIN = IS_DEV
|
||||
? "https://sandbox-buy.paddle.com"
|
||||
: "https://buy.paddle.com";
|
||||
const SUBSCRIPTION_MANAGEMENT_URL = isDev
|
||||
? "https://sandbox-subscription-management.paddle.com"
|
||||
: "https://subscription-management.paddle.com";
|
||||
const CHECKOUT_SERVICE_ORIGIN = isDev
|
||||
const CHECKOUT_SERVICE = IS_DEV
|
||||
? "https://sandbox-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[] = [
|
||||
PaddleEvents["Checkout.Loaded"],
|
||||
PaddleEvents["Checkout.Coupon.Applied"],
|
||||
PaddleEvents["Checkout.Coupon.Remove"],
|
||||
PaddleEvents["Checkout.Location.Submit"],
|
||||
PaddleEvents["Checkout.Complete"],
|
||||
PaddleEvents["Checkout.Customer.Details"]
|
||||
const SUBSCRIBED_EVENTS: CheckoutEventNames[] = [
|
||||
CheckoutEventNames.CHECKOUT_LOADED,
|
||||
CheckoutEventNames.CHECKOUT_COMPLETED,
|
||||
CheckoutEventNames.CHECKOUT_CUSTOMER_UPDATED
|
||||
];
|
||||
|
||||
type PaddleCheckoutProps = {
|
||||
user: { id: string; email: string };
|
||||
theme: "dark" | "light";
|
||||
plan: Plan;
|
||||
price: Price;
|
||||
onPriceUpdated?: (pricingInfo: PricingInfo) => void;
|
||||
onCouponApplied?: () => void;
|
||||
onCompleted?: () => void;
|
||||
coupon?: string;
|
||||
};
|
||||
export function PaddleCheckout(props: PaddleCheckoutProps) {
|
||||
const {
|
||||
plan,
|
||||
onPriceUpdated,
|
||||
coupon,
|
||||
theme,
|
||||
onCouponApplied,
|
||||
onCompleted,
|
||||
user
|
||||
} = props;
|
||||
const { plan, price, onPriceUpdated, coupon, onCompleted, user, theme } =
|
||||
props;
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const appliedCouponCode = useRef<string>();
|
||||
const checkoutId = useRef<string>();
|
||||
const checkoutDataRef = useRef<CheckoutEventsData>();
|
||||
const checkoutRef = useRef<HTMLIFrameElement>(null);
|
||||
const addressRef = useRef<CheckoutEventsCustomerAddress | undefined>();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const reloadCheckout = useCallback(() => {
|
||||
if (!checkoutRef.current) return;
|
||||
if (!checkoutRef.current || !checkoutDataRef.current) return;
|
||||
setIsLoading(true);
|
||||
checkoutRef.current.src = `${PADDLE_ORIGIN}/checkout/?checkout_id=${
|
||||
checkoutId.current
|
||||
}&display_mode=inline&apple_pay_enabled=${isFeatureSupported(
|
||||
"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]
|
||||
);
|
||||
checkoutRef.current.src = "about:blank";
|
||||
checkoutRef.current.src = getCheckoutURL(checkoutDataRef.current.id, theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
createCheckout({ plan, theme, user, coupon }).then((checkoutData) => {
|
||||
if (checkoutDataRef.current) {
|
||||
if (
|
||||
coupon &&
|
||||
(!checkoutDataRef.current.discount ||
|
||||
checkoutDataRef.current.discount.code !== coupon)
|
||||
) {
|
||||
applyCoupon(checkoutDataRef.current.id, coupon).then(() =>
|
||||
reloadCheckout()
|
||||
);
|
||||
} 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 = getPricingInfo(plan, checkoutData);
|
||||
if (onPriceUpdated) onPriceUpdated(pricingInfo);
|
||||
const pricingInfo = await getPrice(price, checkoutData);
|
||||
if (!pricingInfo) return;
|
||||
|
||||
addressRef.current = checkoutData.customer.address || undefined;
|
||||
onPriceUpdated?.(pricingInfo);
|
||||
appliedCouponCode.current = pricingInfo.coupon;
|
||||
checkoutId.current = checkoutData.public_checkout_id;
|
||||
checkoutDataRef.current = checkoutData;
|
||||
reloadCheckout();
|
||||
});
|
||||
}, [plan, theme, user]);
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [coupon, onPriceUpdated, price, reloadCheckout, theme, user]);
|
||||
|
||||
useEffect(() => {
|
||||
async function onMessage(ev: MessageEvent<PaddleEvent>) {
|
||||
if (ev.origin !== PADDLE_ORIGIN) return;
|
||||
logger.debug("Paddle event received", { data: ev.data });
|
||||
const { event, event_name, callback_data } = ev.data;
|
||||
const { checkout } = callback_data || {};
|
||||
const { event_name, callback_data } = ev.data;
|
||||
|
||||
if (event === PaddleEvents["Checkout.RemoveSpinner"]) 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 });
|
||||
if (event_name === CheckoutEventNames.CHECKOUT_FAILED) {
|
||||
setIsLoading(false);
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event_name === PaddleEvents["Checkout.Loaded"]) {
|
||||
if (event_name === CheckoutEventNames.CHECKOUT_LOADED) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
const pricingInfo = getPricingInfo(plan, {
|
||||
public_checkout_id: checkout.id,
|
||||
ip_geo_country_code: callback_data?.user?.country || "US",
|
||||
items: [
|
||||
{
|
||||
prices: checkout.prices.customer.items,
|
||||
recurring: {
|
||||
prices: [checkout.recurring_prices.customer.items[0].recurring]
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
addressRef.current = callback_data.data.customer.address || undefined;
|
||||
const pricingInfo = await getPrice(price, callback_data.data);
|
||||
if (!pricingInfo) return;
|
||||
|
||||
pricingInfo.invalidCoupon =
|
||||
!!props.coupon && !callback_data.data.discount;
|
||||
if (onPriceUpdated) onPriceUpdated(pricingInfo);
|
||||
appliedCouponCode.current = pricingInfo.coupon;
|
||||
checkoutId.current = checkout.id;
|
||||
|
||||
await updateCoupon(checkout.id);
|
||||
checkoutDataRef.current = callback_data.data || checkoutDataRef.current;
|
||||
}
|
||||
window.addEventListener("message", onMessage, false);
|
||||
return () => {
|
||||
window.removeEventListener("message", onMessage, false);
|
||||
};
|
||||
}, [
|
||||
onPriceUpdated,
|
||||
updatePrice,
|
||||
plan,
|
||||
onCompleted,
|
||||
user.email,
|
||||
reloadCheckout,
|
||||
updateCoupon
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkoutId.current) return;
|
||||
updateCoupon(checkoutId.current).then((result) => {
|
||||
if (result) reloadCheckout();
|
||||
});
|
||||
}, [coupon]);
|
||||
}, [onPriceUpdated, plan, price, props.coupon, onCompleted]);
|
||||
|
||||
return (
|
||||
<ScrollContainer
|
||||
@@ -229,7 +183,7 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
|
||||
scrolling="no"
|
||||
frameBorder={"0"}
|
||||
ref={checkoutRef}
|
||||
allow={`payment ${PADDLE_ORIGIN} ${SUBSCRIPTION_MANAGEMENT_URL};`}
|
||||
allow={`payment`} // ${PADDLE_ORIGIN} ${SUBSCRIPTION_MANAGEMENT_URL};`}
|
||||
style={{
|
||||
// padding: "0px 30px",
|
||||
height: "1000px",
|
||||
@@ -246,157 +200,252 @@ export function PaddleCheckout(props: PaddleCheckoutProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function getCheckoutURL(params: {
|
||||
plan: PaddleCheckoutProps["plan"];
|
||||
theme: PaddleCheckoutProps["theme"];
|
||||
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 getCheckoutURL(id: string, theme: PaddleCheckoutProps["theme"]) {
|
||||
return `${PADDLE_ORIGIN}/checkout/${id}?display_mode=inline&variant=multi-page&display_mode_theme=${theme}&checkout_type=transaction-checkout&apple_pay_enabled=${isFeatureSupported(
|
||||
"applePaySupported"
|
||||
)}`;
|
||||
}
|
||||
|
||||
function getPricingInfo(plan: Plan, checkoutData: CheckoutData): PricingInfo {
|
||||
const { prices, recurring } = checkoutData.items[0];
|
||||
const price = prices[0];
|
||||
const recurringPrice = recurring.prices[0];
|
||||
const discount = price.discounts.length > 0 ? price.discounts[0] : undefined;
|
||||
const isRecurringDiscount = recurringPrice.discounts.length > 0;
|
||||
|
||||
return {
|
||||
country: checkoutData.ip_geo_country_code,
|
||||
currency: price.currency,
|
||||
discount: {
|
||||
amount: discount?.gross_discount || 0,
|
||||
recurring: isRecurringDiscount,
|
||||
code: discount?.code,
|
||||
type: "promo"
|
||||
},
|
||||
period: plan.period,
|
||||
price: normalizeCheckoutPrice(price),
|
||||
recurringPrice: normalizeCheckoutPrice(recurringPrice),
|
||||
coupon: discount?.code
|
||||
};
|
||||
}
|
||||
|
||||
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"
|
||||
async function getPrice(price: Price, checkoutData: CheckoutEventsData) {
|
||||
const response = await fetch(`${PADDLE_API}/pricing-preview`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
quantity: 1,
|
||||
price_id: price.id
|
||||
}
|
||||
],
|
||||
currency_code: checkoutData.currency_code,
|
||||
address: checkoutData.customer.address
|
||||
? {
|
||||
postal_code: checkoutData.customer.address?.postal_code,
|
||||
country_code: checkoutData.customer.address?.country_code
|
||||
}
|
||||
: undefined,
|
||||
discount_id: checkoutData.discount?.id
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"Paddle-Clienttoken": CLIENT_PADDLE_TOKEN
|
||||
}
|
||||
});
|
||||
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> {
|
||||
const url = ` ${CHECKOUT_SERVICE_ORIGIN}/checkout/${checkoutId}/coupon`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "DELETE"
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const json = (await response.json()) as CheckoutDataResponse;
|
||||
return json.data;
|
||||
interface Discount {
|
||||
id: string;
|
||||
status: "active" | "archived" | "expired" | "used";
|
||||
description: string;
|
||||
enabled_for_checkout: boolean;
|
||||
code: string | null;
|
||||
type: "flat" | "flat_per_seat" | "percentage";
|
||||
amount: string;
|
||||
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(
|
||||
checkoutId: string
|
||||
): Promise<CheckoutData | undefined> {
|
||||
const url = `${CHECKOUT_SERVICE_ORIGIN}/checkout/${checkoutId}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return undefined;
|
||||
const json = (await response.json()) as CheckoutDataResponse;
|
||||
return json.data;
|
||||
interface DiscountLineItem {
|
||||
discount: Discount;
|
||||
total: string;
|
||||
formatted_total: string;
|
||||
}
|
||||
|
||||
interface LineItem {
|
||||
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: {
|
||||
plan: PaddleCheckoutProps["plan"];
|
||||
price: PaddleCheckoutProps["price"];
|
||||
user: PaddleCheckoutProps["user"];
|
||||
theme: PaddleCheckoutProps["theme"];
|
||||
coupon?: PaddleCheckoutProps["coupon"];
|
||||
}) {
|
||||
const { plan, user, theme, coupon } = props;
|
||||
const url = getCheckoutURL({ plan, user, theme });
|
||||
const response = await fetch(url);
|
||||
const { user, theme, coupon, price } = props;
|
||||
const response = await fetch(`${CHECKOUT_SERVICE}/transaction-checkout`, {
|
||||
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;
|
||||
|
||||
const json = (await response.json()) as CheckoutDataResponse;
|
||||
|
||||
let checkoutData = json.data;
|
||||
const checkoutId = checkoutData.public_checkout_id;
|
||||
const checkoutId = checkoutData.id;
|
||||
|
||||
checkoutData = await submitCustomerInfo(
|
||||
checkoutId,
|
||||
@@ -413,3 +462,66 @@ async function createCheckout(props: {
|
||||
|
||||
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 { Loading } from "../../components/icons";
|
||||
import Nomad from "../../assets/nomad.svg?url";
|
||||
import { Period, Plan } from "./types";
|
||||
import { PLAN_METADATA, usePlans } from "./plans";
|
||||
import { useEffect } from "react";
|
||||
import { getCurrencySymbol } from "./helpers";
|
||||
import { Period, Plan, PlanId, Price } from "./types";
|
||||
import { usePlans } from "./plans";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getCurrencySymbol, parseAmount } from "./helpers";
|
||||
import { strings } from "@notesnook/intl";
|
||||
|
||||
type PlansListProps = {
|
||||
onPlanSelected: (plan: Plan) => void;
|
||||
selectedPlan: PlanId;
|
||||
onPlanSelected: (plan: Plan, price: Price) => 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) {
|
||||
const { onPlanSelected, onPlansLoaded } = props;
|
||||
const { onPlanSelected, onPlansLoaded, selectedPlan } = props;
|
||||
const { isLoading, plans, discount, country } = usePlans();
|
||||
|
||||
const [selectedPeriod, setPeriod] = useState<Period>("yearly");
|
||||
console.log({ selectedPlan });
|
||||
useEffect(() => {
|
||||
if (isLoading || !onPlansLoaded) return;
|
||||
onPlansLoaded(plans);
|
||||
@@ -40,7 +57,10 @@ export function PlansList(props: PlansListProps) {
|
||||
|
||||
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" }}>
|
||||
Choose a plan
|
||||
</Text>
|
||||
@@ -54,32 +74,67 @@ export function PlansList(props: PlansListProps) {
|
||||
"Notesnook profits when you purchase a subscription — not by selling your data."
|
||||
)}
|
||||
</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" }}>
|
||||
{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 (
|
||||
<Button
|
||||
key={metadata.title}
|
||||
key={plan.title}
|
||||
disabled={isLoading}
|
||||
data-test-id={`checkout-plan`}
|
||||
variant="secondary"
|
||||
mt={1}
|
||||
bg="transparent"
|
||||
// bg="transparent"
|
||||
// sx={
|
||||
// {
|
||||
// // bg: selectedPlan?.key === plan.key ? "border" : "transparent",
|
||||
// // border:
|
||||
// // selectedPlan?.key === plan.key ? "1px solid var(--accent)" : "none",
|
||||
// border:
|
||||
// selectedPlan?.key === plan.key ? "1px solid var(--accent)" : "none",
|
||||
// }
|
||||
// }
|
||||
onClick={() => onPlanSelected(plan)}
|
||||
onClick={() => onPlanSelected(plan, price)}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
flex: 1,
|
||||
textAlign: "start",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
display: "flex"
|
||||
display: "flex",
|
||||
border:
|
||||
selectedPlan === plan.id
|
||||
? "1px solid var(--accent-selected)"
|
||||
: "none",
|
||||
borderRadius: "default"
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
@@ -87,8 +142,8 @@ export function PlansList(props: PlansListProps) {
|
||||
sx={{ fontWeight: "normal" }}
|
||||
data-test-id="title"
|
||||
>
|
||||
{metadata.title}
|
||||
<br />
|
||||
{plan.title}
|
||||
{/* <br />
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
@@ -97,9 +152,15 @@ export function PlansList(props: PlansListProps) {
|
||||
}}
|
||||
>
|
||||
{metadata.subtitle}
|
||||
</Text> */}
|
||||
</Text>
|
||||
</Text>
|
||||
{isLoading ? <Loading /> : <RecurringPricing plan={plan} />}
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : plan.recurring ? (
|
||||
<RecurringPricing plan={plan} price={price} />
|
||||
) : (
|
||||
<OneTimePricing plan={plan} price={price} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
@@ -108,17 +169,23 @@ export function PlansList(props: PlansListProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type RecurringPricingProps = {
|
||||
type PricingProps = {
|
||||
plan: Plan;
|
||||
price: Price;
|
||||
};
|
||||
function RecurringPricing(props: RecurringPricingProps) {
|
||||
const { plan } = props;
|
||||
function RecurringPricing(props: PricingProps) {
|
||||
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 (
|
||||
<Text
|
||||
sx={{ flexShrink: 0, fontSize: "subBody", textAlign: "end" }}
|
||||
variant="body"
|
||||
>
|
||||
{plan.originalPrice && plan.originalPrice.gross !== plan.price.gross ? (
|
||||
{/* {plan.originalPrice && plan.originalPrice.gross !== plan.price.gross && (
|
||||
<Text
|
||||
sx={{
|
||||
textDecorationLine: "line-through",
|
||||
@@ -129,23 +196,91 @@ function RecurringPricing(props: RecurringPricingProps) {
|
||||
{getCurrencySymbol(plan.currency)}
|
||||
{plan.originalPrice.gross}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text>
|
||||
<Text as="span" sx={{ fontSize: "subtitle" }}>
|
||||
{getCurrencySymbol(plan.currency)}
|
||||
{plan.price.gross}
|
||||
)} */}
|
||||
{/* {monthPrice && (
|
||||
<Text
|
||||
variant="subBody"
|
||||
sx={{
|
||||
textDecorationLine: "line-through",
|
||||
color: "var(--paragraph-secondary)"
|
||||
}}
|
||||
>
|
||||
{getCurrencySymbol(price.currency)}
|
||||
{monthPrice.gross}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
? "/mo"
|
||||
: period === "yearly" || period === "education"
|
||||
: period === "yearly"
|
||||
? "/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 { Period, Plan } from "./types";
|
||||
import { Period, Plan, Price } from "./types";
|
||||
import { IS_DEV } from "./helpers";
|
||||
|
||||
type PlanMetadata = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
function createPrice(id: string, period: Period, subtotal: number): Price {
|
||||
return {
|
||||
id,
|
||||
period,
|
||||
subtotal,
|
||||
total: 0,
|
||||
tax: 0,
|
||||
currency: "USD"
|
||||
};
|
||||
}
|
||||
|
||||
export const isDev = import.meta.env.DEV || IS_TESTING;
|
||||
|
||||
export const EDUCATION_PLAN: Plan = {
|
||||
id: isDev ? "50305" : "658759",
|
||||
period: "education",
|
||||
country: "US",
|
||||
currency: "USD",
|
||||
discount: { type: "regional", amount: 0, recurring: false },
|
||||
price: { gross: 9.99, net: 0, tax: 0 },
|
||||
originalPrice: { gross: 9.99, net: 0, tax: 0 }
|
||||
const FREE_PLAN: Plan = {
|
||||
id: "free",
|
||||
title: "Free",
|
||||
recurring: true,
|
||||
prices: [
|
||||
createPrice("monthly", "monthly", 0),
|
||||
createPrice("yearly", "yearly", 0),
|
||||
createPrice("5-year", "5-year", 0)
|
||||
]
|
||||
};
|
||||
|
||||
export const DEFAULT_PLANS: Plan[] = [
|
||||
FREE_PLAN,
|
||||
{
|
||||
period: "monthly",
|
||||
country: "PK",
|
||||
currency: "USD",
|
||||
discount: { type: "regional", amount: 0, recurring: false },
|
||||
originalPrice: { gross: 4.49, net: 0, tax: 0 },
|
||||
id: isDev ? "9822" : "648884",
|
||||
price: { gross: 4.49, net: 0, tax: 0 }
|
||||
id: "essential",
|
||||
title: "Essential",
|
||||
recurring: true,
|
||||
prices: [
|
||||
createPrice(
|
||||
IS_DEV
|
||||
? "pri_01j00cf6v5kqqvchcpgapr7123"
|
||||
: "pri_01j02dbe7btgk6ta3ctper2161",
|
||||
"monthly",
|
||||
1.99
|
||||
),
|
||||
createPrice(
|
||||
IS_DEV
|
||||
? "pri_01j00d1qq3bart3w1rvt0q8bkt"
|
||||
: "pri_01j02dckdey85cgmrdknd2f4zx",
|
||||
"yearly",
|
||||
1.24
|
||||
)
|
||||
]
|
||||
},
|
||||
{
|
||||
period: "yearly",
|
||||
country: "PK",
|
||||
currency: "USD",
|
||||
discount: { type: "regional", amount: 0, recurring: false },
|
||||
id: isDev ? "50305" : "658759",
|
||||
price: { gross: 49.99, net: 0, tax: 0 },
|
||||
originalPrice: { gross: 49.99, net: 0, tax: 0 }
|
||||
id: "pro",
|
||||
title: "Pro",
|
||||
recurring: true,
|
||||
prices: [
|
||||
createPrice(
|
||||
IS_DEV
|
||||
? "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
|
||||
];
|
||||
|
||||
export const PLAN_METADATA: Record<Period, PlanMetadata> = {
|
||||
monthly: { title: "Monthly", subtitle: `Pay once a month.` },
|
||||
yearly: { title: "Yearly", subtitle: `Pay once a year.` },
|
||||
education: {
|
||||
{
|
||||
id: "believer",
|
||||
title: "Believer",
|
||||
recurring: true,
|
||||
prices: [
|
||||
createPrice(
|
||||
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",
|
||||
subtitle: "Special offer for students & teachers."
|
||||
recurring: false,
|
||||
prices: [
|
||||
createPrice(
|
||||
IS_DEV
|
||||
? "pri_01j00g6asxjskghjcrbxpbd26e"
|
||||
: "pri_01j02dh4mwkbsvpygyf1bd9whs",
|
||||
"yearly",
|
||||
19.99
|
||||
)
|
||||
]
|
||||
}
|
||||
};
|
||||
];
|
||||
|
||||
let CACHED_PLANS: Plan[];
|
||||
export async function getPlans(): Promise<Plan[] | null> {
|
||||
return DEFAULT_PLANS;
|
||||
if (IS_TESTING || import.meta.env.DEV) return DEFAULT_PLANS;
|
||||
if (CACHED_PLANS) return CACHED_PLANS;
|
||||
|
||||
@@ -77,7 +148,7 @@ export async function getPlans(): Promise<Plan[] | null> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const plans = (await response.json()) as Plan[];
|
||||
plans.push(EDUCATION_PLAN);
|
||||
// plans.push(EDUCATION_PLAN);
|
||||
CACHED_PLANS = plans;
|
||||
return plans;
|
||||
}
|
||||
@@ -94,8 +165,8 @@ export function usePlans() {
|
||||
const plans = await getPlans();
|
||||
if (!plans) return;
|
||||
setPlans(plans);
|
||||
setDiscount(Math.max(...plans.map((p) => p.discount?.amount || 0)));
|
||||
setCountry(plans[0].country);
|
||||
// setDiscount(Math.max(...plans.map((p) => p.discount?.amount || 0)));
|
||||
// setCountry(plans[0].country);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} 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/>.
|
||||
*/
|
||||
|
||||
import { Plan, PricingInfo } from "./types";
|
||||
import { Plan, Price, PricingInfo } from "./types";
|
||||
import { create } from "zustand";
|
||||
import { create as produce } from "mutative";
|
||||
|
||||
@@ -25,7 +25,8 @@ interface ICheckoutStore {
|
||||
isCompleted: boolean;
|
||||
completeCheckout: () => void;
|
||||
selectedPlan?: Plan;
|
||||
selectPlan: (plan?: Plan) => void;
|
||||
selectedPrice?: Price;
|
||||
selectPlan: (plan?: Plan, price?: Price) => void;
|
||||
pricingInfo?: PricingInfo;
|
||||
updatePrice: (pricingInfo?: PricingInfo) => void;
|
||||
isApplyingCoupon: boolean;
|
||||
@@ -37,6 +38,7 @@ interface ICheckoutStore {
|
||||
export const useCheckoutStore = create<ICheckoutStore>((set) => ({
|
||||
isCompleted: false,
|
||||
selectedPlan: undefined,
|
||||
selectedPrice: undefined,
|
||||
pricingInfo: undefined,
|
||||
couponCode: undefined,
|
||||
isApplyingCoupon: false,
|
||||
@@ -46,10 +48,11 @@ export const useCheckoutStore = create<ICheckoutStore>((set) => ({
|
||||
state.isCompleted = true;
|
||||
})
|
||||
),
|
||||
selectPlan: (plan) =>
|
||||
selectPlan: (plan, price) =>
|
||||
set(
|
||||
produce((state: ICheckoutStore) => {
|
||||
state.selectedPlan = plan;
|
||||
state.selectedPrice = price;
|
||||
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/>.
|
||||
*/
|
||||
|
||||
export type Period = "monthly" | "yearly" | "education";
|
||||
import {
|
||||
CheckoutEventNames,
|
||||
CheckoutEventsCustomer,
|
||||
CheckoutEventsDiscount,
|
||||
CheckoutEventsItem,
|
||||
PaddleEventData
|
||||
} from "@paddle/paddle-js";
|
||||
|
||||
export enum PaddleEvents {
|
||||
/** 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",
|
||||
export type Period = "monthly" | "yearly" | "5-year";
|
||||
|
||||
"Checkout.Customer.Details" = "Checkout.Customer.Details",
|
||||
"Checkout.RemoveSpinner" = "Checkout.RemoveSpinner"
|
||||
}
|
||||
|
||||
export interface CallbackData {
|
||||
checkout?: Checkout;
|
||||
coupon?: { coupon_code: string };
|
||||
user?: {
|
||||
email: string;
|
||||
id: string;
|
||||
country: string;
|
||||
};
|
||||
}
|
||||
// export interface CallbackData {
|
||||
// checkout?: Checkout;
|
||||
// }
|
||||
|
||||
export interface Checkout {
|
||||
id?: string;
|
||||
@@ -101,29 +47,30 @@ export interface Checkout {
|
||||
|
||||
export type PaddleEvent = {
|
||||
action: "event";
|
||||
event: PaddleEvents;
|
||||
event_name: PaddleEvents;
|
||||
callback_data: CallbackData;
|
||||
event_name: CheckoutEventNames;
|
||||
callback_data: PaddleEventData;
|
||||
};
|
||||
|
||||
export type PlanId = "free" | "essential" | "pro" | "believer" | "education";
|
||||
export interface Plan {
|
||||
id: string;
|
||||
period: Period;
|
||||
price: Price;
|
||||
currency: string;
|
||||
currencySymbol?: string;
|
||||
originalPrice: Price;
|
||||
discount?: Discount;
|
||||
country: string;
|
||||
// period: Period;
|
||||
id: PlanId;
|
||||
title: string;
|
||||
prices: Price[];
|
||||
recurring: boolean;
|
||||
// currency: string;
|
||||
// originalPrice?: Price;
|
||||
// discount: number;
|
||||
// country: string;
|
||||
}
|
||||
|
||||
export type PricingInfo = {
|
||||
country: string;
|
||||
currency: string;
|
||||
// currency: string;
|
||||
price: Price;
|
||||
period: Period;
|
||||
recurringPrice: Price;
|
||||
discount: Discount;
|
||||
discount?: Discount;
|
||||
coupon?: string;
|
||||
invalidCoupon?: boolean;
|
||||
};
|
||||
@@ -136,42 +83,13 @@ export type Discount = {
|
||||
};
|
||||
|
||||
export interface Price {
|
||||
gross: number;
|
||||
gross_after_discount?: number;
|
||||
net: number;
|
||||
net_after_discount?: number;
|
||||
tax: number;
|
||||
tax_after_discount?: number;
|
||||
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;
|
||||
id: string;
|
||||
period: Period;
|
||||
subtotal: string;
|
||||
total: string;
|
||||
tax: string;
|
||||
discount?: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface Customer {
|
||||
@@ -244,4 +162,22 @@ export type TotalPrice = CheckoutPrice & {
|
||||
export interface Vendor {
|
||||
id: number;
|
||||
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/>.
|
||||
*/
|
||||
|
||||
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 { strings } from "@notesnook/intl";
|
||||
import { Box, Flex, Link, Text } from "@theme-ui/components";
|
||||
import { useEffect, useState } from "react";
|
||||
import { db } from "../../../common/db";
|
||||
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> = {
|
||||
completed: "Completed",
|
||||
refunded: "Refunded",
|
||||
partially_refunded: "Partially refunded",
|
||||
disputed: "Disputed"
|
||||
billed: "Billed",
|
||||
canceled: "Canceled",
|
||||
paid: "Paid",
|
||||
past_due: "Past due"
|
||||
};
|
||||
|
||||
export function BillingHistory() {
|
||||
@@ -87,11 +91,11 @@ export function BillingHistory() {
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ id: "date", title: strings.date(), width: "20%" },
|
||||
{ id: "orderId", title: strings.orderId(), width: "20%" },
|
||||
{ id: "id", title: "ID", width: "5%" },
|
||||
{ id: "billedAt", title: "Billed at", width: "20%" },
|
||||
{ id: "amount", title: strings.amount(), width: "20%" },
|
||||
{ id: "status", title: strings.status(), width: "20%" },
|
||||
{ id: "receipt", title: strings.receipt(), width: "20%" }
|
||||
{ id: "invoice", title: "Invoice", width: "20%" }
|
||||
].map((column) =>
|
||||
!column.title ? (
|
||||
<th key={column.id} />
|
||||
@@ -119,29 +123,51 @@ export function BillingHistory() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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">
|
||||
{getFormattedDate(transaction.created_at, "date")}
|
||||
<Copy
|
||||
size={16}
|
||||
onClick={() =>
|
||||
writeToClipboard({ "text/plain": transaction.id })
|
||||
}
|
||||
sx={{ cursor: "pointer" }}
|
||||
/>
|
||||
</Text>
|
||||
<Text as="td" variant="body">
|
||||
{transaction.order_id}
|
||||
{getFormattedDate(transaction.billed_at, "date")}
|
||||
</Text>
|
||||
<Text as="td" variant="body">
|
||||
{transaction.amount} {transaction.currency}
|
||||
{(transaction.details.totals.grand_total / 100).toFixed(2)}{" "}
|
||||
{transaction.details.totals.currency_code}
|
||||
</Text>
|
||||
<Text as="td" variant="body">
|
||||
{strings.transactionStatusToText(transaction.status)}
|
||||
</Text>
|
||||
<Text as="td" variant="body">
|
||||
<Link
|
||||
href={transaction.receipt_url}
|
||||
target="_blank"
|
||||
rel="noreferer nofollow"
|
||||
variant="text.subBody"
|
||||
sx={{ color: "accent" }}
|
||||
<Button
|
||||
variant="anchor"
|
||||
onClick={async () => {
|
||||
const url = await TaskManager.startTask({
|
||||
type: "modal",
|
||||
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()}
|
||||
</Link>
|
||||
Download
|
||||
</Button>
|
||||
</Text>
|
||||
</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 { Button, 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 { Flex, Text } from "@theme-ui/components";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { PromptDialog } from "../../prompt";
|
||||
import { getSubscriptionInfo } from "./user-profile";
|
||||
|
||||
export function SubscriptionStatus() {
|
||||
const user = useUserStore((store) => store.user);
|
||||
|
||||
const [activateTrial, isActivatingTrial] = useAction(async () => {
|
||||
await db.user.activateTrial();
|
||||
});
|
||||
const { title, autoRenew, expiryDate, trial, legacy } =
|
||||
getSubscriptionInfo(user);
|
||||
|
||||
const provider =
|
||||
strings.subscriptionProviderInfo[user?.subscription?.provider || 0];
|
||||
const {
|
||||
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]);
|
||||
const subtitle = autoRenew
|
||||
? `Your subscription will auto renew on ${expiryDate}.`
|
||||
: `Your account will automatically downgrade to the Free plan on ${expiryDate}.`;
|
||||
|
||||
if (!user) return null;
|
||||
return (
|
||||
@@ -92,8 +42,8 @@ export function SubscriptionStatus() {
|
||||
justifyContent: "center",
|
||||
alignItems: "start",
|
||||
bg: "var(--background-secondary)",
|
||||
p: 2,
|
||||
mb: isBasic ? 0 : 4
|
||||
p: 2
|
||||
// mb: isBasic ? 0 : 4
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
@@ -113,144 +63,14 @@ export function SubscriptionStatus() {
|
||||
mt: 2
|
||||
}}
|
||||
>
|
||||
{remainingDays > 0 && (isPro || isProCancelled)
|
||||
? `Pro`
|
||||
: remainingDays > 0 && isTrial
|
||||
? "Trial"
|
||||
: isBeta
|
||||
? "Beta user"
|
||||
: "Basic"}
|
||||
{title}
|
||||
{legacy ? " (legacy)" : ""}
|
||||
</Text>
|
||||
<Text variant="body">
|
||||
{remainingDays > 0 && (isPro || isProCancelled || isTrial || isBeta)
|
||||
? `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."}
|
||||
{trial ? "Your free trial is on-going." : subtitle}
|
||||
</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>
|
||||
{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 { Edit, User } from "../../../components/icons";
|
||||
import { Edit, User as UserIcon } from "../../../components/icons";
|
||||
import { useStore as useUserStore } from "../../../stores/user-store";
|
||||
import { useStore as useSettingStore } from "../../../stores/setting-store";
|
||||
import { getObjectIdTimestamp } from "@notesnook/core";
|
||||
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 { showToast } from "../../../utils/toast";
|
||||
import { EditProfilePictureDialog } from "../../edit-profile-picture-dialog";
|
||||
import { PromptDialog } from "../../prompt";
|
||||
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 = {
|
||||
minimal?: boolean;
|
||||
@@ -40,28 +100,7 @@ export function UserProfile({ minimal }: Props) {
|
||||
const user = useUserStore((store) => store.user);
|
||||
const profile = useSettingStore((store) => store.profile);
|
||||
|
||||
const {
|
||||
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 { title, legacy, trial } = getSubscriptionInfo(user);
|
||||
|
||||
if (!user || !user.id)
|
||||
return (
|
||||
@@ -83,7 +122,7 @@ export function UserProfile({ minimal }: Props) {
|
||||
borderRadius: 80
|
||||
}}
|
||||
>
|
||||
<User size={minimal ? 15 : 20} />
|
||||
<UserIcon size={minimal ? 15 : 20} />
|
||||
</Flex>
|
||||
<Flex sx={{ flexDirection: "column" }}>
|
||||
<Text variant={minimal ? "body" : "subtitle"}>
|
||||
@@ -126,7 +165,7 @@ export function UserProfile({ minimal }: Props) {
|
||||
src={profile.profilePicture}
|
||||
/>
|
||||
) : (
|
||||
<User size={minimal ? 20 : 24} />
|
||||
<UserIcon size={minimal ? 20 : 24} />
|
||||
)}
|
||||
<Flex
|
||||
id="profile-picture-edit"
|
||||
@@ -160,13 +199,7 @@ export function UserProfile({ minimal }: Props) {
|
||||
color: "accent"
|
||||
}}
|
||||
>
|
||||
{remainingDays > 0 && (isPro || isProCancelled)
|
||||
? `PRO`
|
||||
: remainingDays > 0 && isTrial
|
||||
? "TRIAL"
|
||||
: isBeta
|
||||
? "BETA TESTER"
|
||||
: "BASIC"}
|
||||
{`${title}${trial ? " (trial)" : ""}${legacy ? " (legacy)" : ""}`}
|
||||
</Text>
|
||||
|
||||
<Text variant={minimal ? "body" : "subtitle"}>
|
||||
|
||||
@@ -25,6 +25,13 @@ import { BillingHistory } from "./components/billing-history";
|
||||
import { useStore as useUserStore } from "../../stores/user-store";
|
||||
import { isUserSubscribed } from "../../hooks/use-is-user-premium";
|
||||
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[] = [
|
||||
{
|
||||
@@ -32,13 +39,59 @@ export const SubscriptionSettings: SettingsGroup[] = [
|
||||
section: "subscription",
|
||||
header: SubscriptionStatus,
|
||||
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",
|
||||
title: strings.paymentMethod(),
|
||||
description: strings.changePaymentMethodDescription(),
|
||||
isHidden: () => {
|
||||
const user = useUserStore.getState().user;
|
||||
return !isUserSubscribed(user) || user?.subscription.provider !== 3;
|
||||
return (
|
||||
user?.subscription.provider !== SubscriptionProvider.PADDLE ||
|
||||
!isUserSubscribed(user)
|
||||
);
|
||||
},
|
||||
components: [
|
||||
{
|
||||
@@ -46,7 +99,12 @@ export const SubscriptionSettings: SettingsGroup[] = [
|
||||
title: strings.update(),
|
||||
action: async () => {
|
||||
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) {
|
||||
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",
|
||||
title: strings.billingHistory(),
|
||||
isHidden: () => {
|
||||
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 }]
|
||||
}
|
||||
|
||||
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 hasNativeTitlebar: boolean;
|
||||
|
||||
interface Window {
|
||||
ApplePaySession?: PaymentRequest;
|
||||
}
|
||||
|
||||
interface AuthenticationExtensionsClientInputs {
|
||||
prf?: {
|
||||
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/>.
|
||||
*/
|
||||
|
||||
import { User } from "@notesnook/core";
|
||||
import { SubscriptionPlan, SubscriptionType, User } from "@notesnook/core";
|
||||
import { SUBSCRIPTION_STATUS } from "../common/constants";
|
||||
import {
|
||||
useStore as useUserStore,
|
||||
@@ -47,9 +47,10 @@ export function isUserSubscribed(user?: User) {
|
||||
if (!user) user = userstore.get().user;
|
||||
if (!user) return false;
|
||||
|
||||
const subStatus = user.subscription?.type;
|
||||
const { type, plan } = user.subscription || {};
|
||||
return (
|
||||
subStatus === SUBSCRIPTION_STATUS.PREMIUM ||
|
||||
subStatus === SUBSCRIPTION_STATUS.PREMIUM_CANCELED
|
||||
(type === SubscriptionType.PREMIUM ||
|
||||
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