diff --git a/apps/web/src/common/theme.ts b/apps/web/src/common/theme.ts index 487e0d155..4f4b43804 100644 --- a/apps/web/src/common/theme.ts +++ b/apps/web/src/common/theme.ts @@ -43,12 +43,12 @@ export const THEME_LIGHT: any = { primary: { accent: "#008836", accentForeground: "#ffffff", - paragraph: "#5b5b5b", + paragraph: "#666666", background: "#ffffff", border: "#E2E2E2", heading: "#181818", icon: "#181818", - separator: "#F0F0F0", + separator: "#E5E5E5", placeholder: "#858585", hover: "#eee", backdrop: "#0000001a", @@ -57,7 +57,7 @@ export const THEME_LIGHT: any = { secondary: { accent: "#0D9E46", accentForeground: "#ffffff", - paragraph: "#858585", + paragraph: "#909090", background: "#F7F7F7", border: "#EFEFEF", heading: "#4B4B4B", diff --git a/apps/web/src/components/field/index.tsx b/apps/web/src/components/field/index.tsx index a5f6b9ed0..b6f9101e1 100644 --- a/apps/web/src/components/field/index.tsx +++ b/apps/web/src/components/field/index.tsx @@ -31,6 +31,7 @@ type Action = { icon?: Icon; component?: JSX.Element; hidden?: boolean; + sx?: ThemeUIStyleObject; }; export type FieldProps = InputProps & { label?: string; @@ -182,7 +183,8 @@ function Field(props: FieldProps) { margin: 0, display: "flex", alignItems: "center", - justifyContent: "center" + justifyContent: "center", + ...action.sx }} disabled={action.disabled} > @@ -225,7 +227,8 @@ function Field(props: FieldProps) { margin: 0, display: "flex", alignItems: "center", - justifyContent: "center" + justifyContent: "center", + ...action.sx }} disabled={action.disabled} > diff --git a/apps/web/src/components/icons/index.tsx b/apps/web/src/components/icons/index.tsx index df5429f41..15a6455d2 100644 --- a/apps/web/src/components/icons/index.tsx +++ b/apps/web/src/components/icons/index.tsx @@ -24,7 +24,6 @@ import { mdiMinus, mdiBookOutline, mdiNotebookOutline, - mdiArrowLeft, mdiArrowRight, mdiArrowDown, mdiBookPlusMultipleOutline, @@ -45,7 +44,6 @@ import { mdiMagnify, mdiMenu, mdiLoginVariant, - mdiEmailAlertOutline, mdiAccountOutline, mdiLogoutVariant, mdiSunglasses, @@ -59,8 +57,6 @@ import { mdiExportVariant, mdiArrowExpandDown, mdiArrowTopRightThick, - mdiChevronLeft, - mdiChevronRight, mdiPound, mdiPinOutline, mdiPin, @@ -109,7 +105,6 @@ import { mdiNoteOutline, mdiSyncAlert, mdiAlertOutline, - mdiEyeOutline, mdiEyeOffOutline, mdiAttachment, mdiPencilOutline, @@ -176,7 +171,6 @@ import { mdiCalendarClockOutline, mdiPuzzleOutline, mdiBellRingOutline, - mdiClockTimeFiveOutline, mdiBellOffOutline, mdiVibrate, mdiBellCancelOutline, @@ -246,6 +240,16 @@ import ChevronLeftIcon from "@notesnook/icons/chevron-left.svg?react"; import ChevronRightIcon from "@notesnook/icons/chevron-right.svg?react"; import ClockIcon from "@notesnook/icons/clock.svg?react"; import WarningCircle from "@notesnook/icons/warning-circle.svg?react"; +import CloudSvg from "@notesnook/icons/cloud.svg?react"; +import ShieldCheckSvg from "@notesnook/icons/shield-check.svg?react"; +import DeviceMobileCameraSvg from "@notesnook/icons/device-mobile-camera.svg?react"; +import FileSvg from "@notesnook/icons/file.svg?react"; +import ImageSvg from "@notesnook/icons/image.svg?react"; +import ArrowRightSvg from "@notesnook/icons/arrow-right.svg?react"; +import CheckCircleSvg from "@notesnook/icons/check-circle.svg?react"; +import ArrowLeftSvg from "@notesnook/icons/arrow-left.svg?react"; +import CheckCircleSuccesSvg from "@notesnook/icons/check-circle-success.svg?react"; +import InfoSvg from "@notesnook/icons/info.svg?react"; type MDIIconWrapperProps = { title?: string; @@ -370,7 +374,6 @@ export const Minus = createIcon(mdiMinus); export const Notebook = createIcon(mdiBookOutline); export const Notebooks = createIcon(mdiBookMultipleOutline); export const Notebook2 = createIcon(mdiNotebookOutline); -export const ArrowLeft = createIcon(mdiArrowLeft); export const ArrowRight = createIcon(mdiArrowRight); export const ArrowDown = createIcon(mdiArrowDown); export const ArrowTopRight = createIcon(mdiArrowTopRight); @@ -423,7 +426,6 @@ export const LightMode = createIcon(mdiWeatherSunny); export const ThemeIcon = createIcon(mdiThemeLightDark); export const Checkmark = createIcon(mdiCheck); export const DoubleCheckmark = createIcon(mdiCheckAll); -export const CheckCircle = createIcon(mdiCheckCircle); export const CheckIntermediate = createIcon(mdiMinusCircleOutline); export const CheckRemove = createIcon(mdiCloseCircleOutline); export const CheckCircleOutline = createIcon(mdiCheckCircleOutline); @@ -438,7 +440,6 @@ export const Text = createIcon(mdiFormatTitle); export const Success = createIcon(mdiCheckCircle); export const Error = createIcon(mdiAlertCircle); export const Warn = createIcon(mdiAlertOutline); -export const Info = createIcon(mdiInformation); export const ToggleUnchecked = createIcon(mdiToggleSwitchOffOutline); export const ToggleChecked = createIcon(mdiToggleSwitchOutline); export const Backup = createIcon(mdiBackupRestore); @@ -630,3 +631,13 @@ export const ChevronLeft = createSvgIcon(ChevronLeftIcon); export const ChevronRight = createSvgIcon(ChevronRightIcon); export const Clock = createSvgIcon(ClockIcon); export const Warning = createSvgIcon(WarningCircle); +export const Cloud = createSvgIcon(CloudSvg); +export const ShieldCheck = createSvgIcon(ShieldCheckSvg); +export const DeviceMobileCamera = createSvgIcon(DeviceMobileCameraSvg); +export const FileDoc = createSvgIcon(FileSvg); +export const Picture = createSvgIcon(ImageSvg); +export const ArrowRightSvgIcon = createSvgIcon(ArrowRightSvg); +export const CheckCircle = createSvgIcon(CheckCircleSvg); +export const ArrowLeft = createSvgIcon(ArrowLeftSvg); +export const CheckCircleSuccess = createSvgIcon(CheckCircleSuccesSvg); +export const Info = createSvgIcon(InfoSvg); diff --git a/apps/web/src/views/checkout.tsx b/apps/web/src/views/checkout.tsx index 826a960ed..a99c6cef8 100644 --- a/apps/web/src/views/checkout.tsx +++ b/apps/web/src/views/checkout.tsx @@ -18,27 +18,35 @@ along with this program. If not, see . */ import "../app.css"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Box, Button, Flex, Text } from "@theme-ui/components"; +import Field from "../components/field"; import { hardNavigate, useQueryParams } from "../navigation"; -import { Support } from "../components/icons"; -import { HeadlessAuth } from "./auth"; import { - CheckoutCompleted, - CheckoutDetails, - CheckoutPricing -} from "../dialogs/buy-dialog"; + Coupon, + Loading, + CheckCircle, + ArrowLeft, + Cloud, + ShieldCheck, + DeviceMobileCamera, + Info, + CheckCircleSuccess +} from "../components/icons"; +import { HeadlessAuth } from "./auth"; +import { CheckoutDetails } from "../dialogs/buy-dialog"; import { useCheckoutStore } from "../dialogs/buy-dialog/store"; import { useStore as useUserStore } from "../stores/user-store"; import { z } from "zod"; import { FEATURE_HIGHLIGHTS, - toPricingInfo + toPricingInfo, + formatPrice } from "../dialogs/buy-dialog/helpers"; import { isUserSubscribed } from "../hooks/use-is-user-premium"; -import { PLAN_METADATA } from "../dialogs/buy-dialog/plans"; -import { planToAvailability } from "@notesnook/common"; -import { FeatureCaption } from "../dialogs/buy-dialog/feature-caption"; +import { PLAN_METADATA, PERIOD_METADATA } from "../dialogs/buy-dialog/plans"; +import { getFeature, planToAvailability } from "@notesnook/common"; +import dayjs from "dayjs"; import { EVENTS } from "@notesnook/core"; import { db } from "../common/db"; @@ -83,11 +91,21 @@ const PlanSchema = z.object({ .optional() }); -const CHECKOUT_STEPS = ["Account", "Payment", "Complete"]; +const CHECKOUT_STEP = { + ACCOUNT: "Account", + PAYMENT: "Payment", + COMPLETE: "Complete" +} as const; + +type CheckoutStep = (typeof CHECKOUT_STEP)[keyof typeof CHECKOUT_STEP]; + function Checkout() { const [{ plan }] = useQueryParams(); + const selectedPlan = useCheckoutStore((state) => state.selectedPlan); - const [currentStep, setCurrentStep] = useState(0); + const [currentStep, setCurrentStep] = useState( + CHECKOUT_STEP.ACCOUNT + ); const [error, setError] = useState(); const [customer, setCustomer] = useState<{ id: string; email: string }>(); @@ -114,12 +132,12 @@ function Checkout() { useCheckoutStore.getState().applyCoupon(pricingInfo.data.discount?.code); if (pricingInfo.data.customer) { setCustomer(pricingInfo.data.customer); - setCurrentStep(1); + setCurrentStep(CHECKOUT_STEP.PAYMENT); } }, [plan]); useEffect(() => { - if (currentStep === 2) { + if (currentStep === CHECKOUT_STEP.COMPLETE) { const event = db.eventManager.subscribe( EVENTS.userSubscriptionUpdated, () => { @@ -151,178 +169,225 @@ function Checkout() { alignItems: "center", justifyContent: "space-between", borderBottom: "1px solid var(--border)", - px: 2 + px: "spacing13", + py: "spacing8" }} > - - + + {currentStep === CHECKOUT_STEP.COMPLETE ? null : ( + + )} + - + Notesnook - - - - - - {CHECKOUT_STEPS.map((step, index) => ( - <> - - = index - ? "accent" - : "background-secondary", - height: 25, - width: 25, - borderRadius: 100, - alignItems: "center", - justifyContent: "center", - flexShrink: 0 - }} - > - = index - ? "accentForeground" - : "paragraph-secondary", - textAlign: "center" - }} - > - {index + 1} - - - {step} - - {index < CHECKOUT_STEPS.length - 1 ? ( - - ) : null} - - ))} - - + {currentStep === CHECKOUT_STEP.COMPLETE ? ( + + hardNavigate("/notes")} + /> + + ) : ( + - {currentStep === 0 ? ( - { - if (ctx?.authenticated) { - await useUserStore.getState().init(); - const user = useUserStore.getState().user; - setCustomer(user); - setCurrentStep(isUserSubscribed(user) ? 2 : 1); - } else setError("Failed to create account."); - }} - /> - ) : currentStep === 1 ? ( - - - Final step, make the payment. - - - You are one step away from unlocking the full potential of - Notesnook. - - { - if (window.ReactNativeWebView) { - window.ReactNativeWebView.postMessage( - JSON.stringify({ - success: true - }) + + {[CHECKOUT_STEP.ACCOUNT, CHECKOUT_STEP.PAYMENT].map( + (step, index) => ( + <> + + = step + ? "accent" + : "background-secondary", + height: 24, + width: 24, + borderRadius: 100, + alignItems: "center", + justifyContent: "center", + flexShrink: 0 + }} + > + = step + ? "accentForeground" + : "heading-secondary", + textAlign: "center" + }} + > + {index + 1} + + + + {step} + + + {index < + [CHECKOUT_STEP.ACCOUNT, CHECKOUT_STEP.PAYMENT].length - + 1 ? ( + + ) : null} + + ) + )} + + + + {currentStep === CHECKOUT_STEP.ACCOUNT ? ( + { + if (ctx?.authenticated) { + await useUserStore.getState().init(); + const user = useUserStore.getState().user; + setCustomer(user); + setCurrentStep( + isUserSubscribed(user) + ? CHECKOUT_STEP.COMPLETE + : CHECKOUT_STEP.PAYMENT ); - } else { - setCurrentStep(2); - } + } else setError("Failed to create account."); }} /> - - ) : currentStep === 2 ? ( - - hardNavigate("/notes")} - buttonText="Start your journey" - /> - - ) : null} + ) : currentStep === CHECKOUT_STEP.PAYMENT ? ( + + + Final step, make the payment. + + + You are one step away from unlocking the full potential of + Notesnook. + + { + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage( + JSON.stringify({ + success: true + }) + ); + } else { + setCurrentStep(CHECKOUT_STEP.COMPLETE); + } + }} + /> + + ) : null} + - - {currentStep === 2 ? null : ( - )} - + + )} - {currentStep === 2 ? null : ( + {currentStep === CHECKOUT_STEP.COMPLETE ? null : ( @@ -354,71 +417,621 @@ function Checkout() { } export default Checkout; +type CheckoutSuccessCardProps = { + selectedPlan?: Plan; + onClose: () => void; +}; + +function CheckoutSuccessCard(props: CheckoutSuccessCardProps) { + const { selectedPlan, onClose } = props; + + if (!selectedPlan) return null; + + const storageCaption = + getFeature("storage").availability[planToAvailability(selectedPlan.plan)] + .caption; + + const features = [ + { + icon: , + title: "Cloud Storage", + value: storageCaption + }, + { + icon: , + title: "Encryption", + value: "AES-256 Zero Know" + }, + { + icon: , + title: "Device Sync", + value: "Unlimited Sync" + }, + { + icon: , + title: "VIP Support", + value: "Prioritized Priority" + } + ]; + + return ( + + + + + + + Payment Confirmed! + + + Your premium workspace is ready. Enjoy secure note-taking, + seamless syncing, and a distraction-free writing experience across + all your devices. + + + + + + + Active Membership + + + + + { + PLAN_METADATA[selectedPlan.plan as keyof typeof PLAN_METADATA] + .title + }{" "} + Plan + + + + + + {features.map((feature) => ( + + {feature.icon} + + + {feature.title} + + + {feature.value} + + + + ))} + + + + + + ); +} + function CheckoutSummaryMobile() { const selectedPlan = useCheckoutStore((state) => state.selectedPlan); const pricingInfo = useCheckoutStore((state) => state.pricingInfo); + const isApplyingCoupon = useCheckoutStore((state) => state.isApplyingCoupon); + const couponCode = useCheckoutStore((state) => state.couponCode); + const applyCoupon = useCheckoutStore((state) => state.applyCoupon); + const setIsApplyingCoupon = useCheckoutStore( + (state) => state.setIsApplyingCoupon + ); if (!selectedPlan || !pricingInfo) return null; return ( - <> - + - - Summary - - - - - + Payment Summary + + + ); } function CheckoutSummary() { const selectedPlan = useCheckoutStore((state) => state.selectedPlan); const pricingInfo = useCheckoutStore((state) => state.pricingInfo); + const isApplyingCoupon = useCheckoutStore((state) => state.isApplyingCoupon); + const couponCode = useCheckoutStore((state) => state.couponCode); + const applyCoupon = useCheckoutStore((state) => state.applyCoupon); + const setIsApplyingCoupon = useCheckoutStore( + (state) => state.setIsApplyingCoupon + ); if (!selectedPlan || !pricingInfo) return null; + return ( + + + Payment Summary + + + + + + ); +} + +type SummaryContentProps = { + selectedPlan: Plan; + pricingInfo: NonNullable< + ReturnType["pricingInfo"] + >; + isApplyingCoupon: boolean; + couponCode?: string; + applyCoupon: (code?: string) => void; + setIsApplyingCoupon: (v: boolean) => void; +}; + +function SummaryContent(props: SummaryContentProps) { + const { + selectedPlan, + pricingInfo, + isApplyingCoupon, + couponCode, + applyCoupon, + setIsApplyingCoupon + } = props; + const { price } = pricingInfo; + + const [couponInputValue, setCouponInputValue] = useState(""); + const couponInputRef = useRef(null); + + // keep the displayed value in sync when a coupon gets applied/removed elsewhere + useEffect(() => { + if (couponCode) setCouponInputValue(couponCode); + }, [couponCode]); + return ( <> - - Summary - - - - {PLAN_METADATA[selectedPlan.plan].title} plan - - - {FEATURE_HIGHLIGHTS.map((feature) => { - const caption = - feature.availability[planToAvailability(selectedPlan.plan)] - .caption; - return ( - - {feature.title} - - - - - ); - })} - - - + {PLAN_METADATA[selectedPlan.plan as keyof typeof PLAN_METADATA].title}{" "} + Plan + + + {PERIOD_METADATA[selectedPlan.period].title} Billing + + + + {FEATURE_HIGHLIGHTS.map((feature) => { + const caption: string | number | boolean = + feature.availability[planToAvailability(selectedPlan.plan)].caption; + return ( + + + {feature.title} + + {typeof caption === "boolean" || caption === "infinity" ? ( + + ) : ( + + {caption} + + )} + + ); + })} + + + + + + + + Today + + + {price.trial_period + ? formatPrice(0, price.currency) + : price.subtotal} + + + + {price.trial_period ? ( + + + After {price.trial_period.frequency} days + + + {price.subtotal} + + + ) : null} + + {pricingInfo.recurringPrice ? ( + + + {pricingInfo.period === "monthly" + ? "Next Month" + : pricingInfo.period === "yearly" + ? "Next Year" + : dayjs() + .add(pricingInfo.period === "5-year" ? 5 : 1, "year") + .add(price.trial_period?.frequency || 0, "days") + .format("YYYY-MM-DD")} + + + {pricingInfo.recurringPrice.subtotal} + + + ) : null} + + + + + + + Total for today + + + {price.trial_period ? formatPrice(0, price.currency) : price.total} + + + + + + setCouponInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && couponInputValue.trim()) { + setIsApplyingCoupon(true); + applyCoupon(couponInputValue.trim()); + } + }} + sx={{ + border: "1px solid var(--border)", + borderRadius: "radius2", + height: "spacing13", + overflow: "hidden" + }} + styles={{ + input: { + border: "none", + outline: "none !important", + bg: "transparent", + fontSize: "lg", + color: "paragraph-primary", + pl: "38px", + pr: "85px", + lineHeight: "100%" + } + }} + leftActions={[ + { + testId: "coupon-icon", + disabled: true, + sx: { + width: "38px", + pl: "spacing5", + pr: "spacing4", + justifyContent: "flex-start", + bg: "transparent" + }, + component: + } + ]} + rightActions={[ + { + testId: couponCode ? "remove-coupon-button" : "apply-coupon-button", + disabled: + isApplyingCoupon || (!couponCode && !couponInputValue.trim()), + onClick: () => { + setIsApplyingCoupon(true); + if (couponCode) { + applyCoupon(undefined); + setCouponInputValue(""); + } else { + applyCoupon(couponInputValue.trim()); + } + }, + sx: { + // width: "85px", + // minWidth: "85px", + px: 0, + bg: "accent", + borderRadius: "radius2" + }, + component: ( + + ) + } + ]} + /> ); } diff --git a/packages/icons/svgs/arrow-left.svg b/packages/icons/svgs/arrow-left.svg new file mode 100644 index 000000000..a2dea2009 --- /dev/null +++ b/packages/icons/svgs/arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/svgs/check-circle-success.svg b/packages/icons/svgs/check-circle-success.svg new file mode 100644 index 000000000..fd4252e2f --- /dev/null +++ b/packages/icons/svgs/check-circle-success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/icons/svgs/check-circle.svg b/packages/icons/svgs/check-circle.svg index 6c282eb71..2b7f1fb9c 100755 --- a/packages/icons/svgs/check-circle.svg +++ b/packages/icons/svgs/check-circle.svg @@ -1,6 +1,6 @@ - + diff --git a/packages/icons/svgs/cloud.svg b/packages/icons/svgs/cloud.svg index 36098840e..230eea064 100755 --- a/packages/icons/svgs/cloud.svg +++ b/packages/icons/svgs/cloud.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/icons/svgs/device-mobile-camera.svg b/packages/icons/svgs/device-mobile-camera.svg index 403837a48..223d34aed 100644 --- a/packages/icons/svgs/device-mobile-camera.svg +++ b/packages/icons/svgs/device-mobile-camera.svg @@ -1,5 +1,5 @@ - + diff --git a/packages/icons/svgs/info.svg b/packages/icons/svgs/info.svg new file mode 100644 index 000000000..658173aa5 --- /dev/null +++ b/packages/icons/svgs/info.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/svgs/shield-check.svg b/packages/icons/svgs/shield-check.svg index a10b58b1b..cac4ed6dc 100644 --- a/packages/icons/svgs/shield-check.svg +++ b/packages/icons/svgs/shield-check.svg @@ -1,3 +1,3 @@ - +