diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 812afe2f..108f4dfb 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -46,6 +46,13 @@ export function Dashboard({ products }: DashboardProps): React.ReactElement { Thanks for using the Reacher{" "} {productName(subscription?.prices?.products)}! + {subscription && ( + <> + + Manage Subscription + + + )} Billing History
@@ -62,12 +69,9 @@ export function Dashboard({ products }: DashboardProps): React.ReactElement { {formatDate(new Date(subscription.cancel_at))} )} -
- {subscription ? ( - - Manage Subscription - - ) : ( + {subscription?.prices?.products?.id !== + COMMERCIAL_LICENSE_PRODUCT_ID && ( +
Upgrade Plan - )} -
+
+ )}
diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 369e81d8..1606c188 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { Link as GLink, Select, Spacer, Text } from "@geist-ui/react"; +import { Button, Link as GLink, Select, Spacer, Text } from "@geist-ui/react"; import Image from "next/image"; import { useRouter } from "next/router"; import React from "react"; @@ -9,6 +9,7 @@ import logo from "../assets/logo/reacher.svg"; import { sentryException } from "@/util/sentry"; import { useUser } from "@/util/useUser"; import styles from "./Nav.module.css"; +import Link from "next/link"; export function Nav(): React.ReactElement { const { user, userDetails, signOut } = useUser(); @@ -64,7 +65,7 @@ export function Nav(): React.ReactElement { Help Center - {user && ( + {user ? ( + ) : ( + <> + + + + + + + + )} ); diff --git a/src/components/ProductCard/Card.tsx b/src/components/ProductCard/Card.tsx index 1a3ebf69..2a93bce4 100644 --- a/src/components/ProductCard/Card.tsx +++ b/src/components/ProductCard/Card.tsx @@ -5,13 +5,14 @@ import React from "react"; import styles from "./Card.module.css"; export interface CardProps extends React.HTMLProps { - body?: string | React.ReactChild; - cta?: string | React.ReactChild; - features?: (string | React.ReactChild)[]; - header: string | React.ReactChild; - price?: string | React.ReactChild; - subtitle?: string | React.ReactChild; - footer?: string | React.ReactChild; + body?: React.ReactElement; + cta?: React.ReactElement; + extra?: React.ReactElement; + features?: (string | React.ReactElement)[]; + header?: string | React.ReactElement; + price?: string | React.ReactElement; + subtitle?: React.ReactElement; + footer?: React.ReactElement; title: string; } @@ -19,6 +20,7 @@ export function Card({ cta, header, features, + extra, price, title, subtitle, @@ -52,6 +54,8 @@ export function Card({ + {extra} +
What you get: diff --git a/src/components/ProductCard/Commercial.tsx b/src/components/ProductCard/Commercial.tsx new file mode 100644 index 00000000..529040fb --- /dev/null +++ b/src/components/ProductCard/Commercial.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { Check, Info } from "@geist-ui/react-icons"; +import { ProductCard, ProductCardProps } from "./ProductCard"; +import { Spacer, Text } from "@geist-ui/react"; +import styles from "./Card.module.css"; + +export function Commercial(props: ProductCardProps): React.ReactElement { + return ( + + + What you need to do: + + +
+
+ +
+ + Purchase servers yourself to self-host Reacher.{" "} + + Read how + + . + +
+ +
+ } + features={features} + footer={ +
+
+ +
+ + Want a free trial before committing? + Feel free to try self-hosting with the{" "} + + open-source guide + + , and subscribe once you're ready. + +
+ } + header={ + + 🏠 Self-Hosted + + } + subtitle={ + + Unlimited email verifications / mo + + } + /> + ); +} + +const features = [ + + Unlimited{" "} + + full-featured + {" "} + email verifications. + , + + 💪 Bulk email verifications.{" "} + + Read more. + + , + No data sent back to Reacher., + + + Customer support via email/chat. + , + + See{" "} + + full terms and details + + . + , +]; diff --git a/src/components/ProductCard/FreeTrial.tsx b/src/components/ProductCard/FreeTrial.tsx index 52bf7365..68272b82 100644 --- a/src/components/ProductCard/FreeTrial.tsx +++ b/src/components/ProductCard/FreeTrial.tsx @@ -26,40 +26,37 @@ export function FreeTrial({ {active ? "Current Plan" : "Not available"} } - features={[ - "50 email verifications per month.", - - - Full-featured - {" "} - email verifications. - , - - Support via{" "} - - Github Issues - - . - , - "No credit card required.", - ]} + features={features} header="Free Forever" - subtitle={ - - Use Reacher's servers with
- high IP reputation. -
- } + subtitle={50 email verifications / mo} title={productName()} price={priceString} /> ); } + +const features = [ + "Use Reacher's servers with high IP reputation.", + + + Full-featured + {" "} + email verifications. + , + + Support via{" "} + + Github Issues + + . + , + "No credit card required.", +]; diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000..149009eb --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,114 @@ +import { Button } from "@geist-ui/react"; +import { useRouter } from "next/router"; +import React, { useState } from "react"; + +import { postData } from "@/util/helpers"; +import { sentryException } from "@/util/sentry"; +import { getStripe } from "@/util/stripeClient"; +import { COMMERCIAL_LICENSE_PRODUCT_ID } from "@/util/subs"; +import type {} from "@/util/supabaseClient"; +import { useUser } from "@/util/useUser"; +import { Card } from "./Card"; +import { Tables } from "@/supabase/database.types"; +import { ProductWithPrice } from "@/supabase/domain.types"; + +export interface ProductCardProps { + currency: string; + product: ProductWithPrice; + subscription: Tables<"subscriptions"> | null; + extra?: React.ReactElement; + header?: React.ReactElement; + footer?: React.ReactElement; + features?: (string | React.ReactElement)[]; + subtitle?: React.ReactElement; +} + +export function ProductCard({ + currency, + product, + subscription, + ...props +}: ProductCardProps): React.ReactElement { + const router = useRouter(); + const [priceIdLoading, setPriceIdLoading] = useState(); + const { session, user } = useUser(); + + const active = !!subscription; + const price = product.prices.find(({ currency: c }) => currency === c); + if (!price || !price.unit_amount) { + return

Error: No price found for product {product.id}.

; + } + + const handleCheckout = async (price: Tables<"prices">) => { + setPriceIdLoading(price.id); + + if (!session) { + router.push("/signup").catch(sentryException); + + return; + } + + try { + const { sessionId } = await postData<{ sessionId: string }>({ + url: "/api/stripe/create-checkout-session", + data: { price }, + token: session.access_token, + }); + + const stripe = await getStripe(); + if (!stripe) { + throw new Error("Empty stripe object at checkout"); + } + + await stripe.redirectToCheckout({ sessionId }); + } catch (err) { + sentryException(err as Error); + alert((err as Error).message); + } finally { + setPriceIdLoading(false); + } + }; + + const priceString = new Intl.NumberFormat("en-US", { + style: "currency", + currency: price.currency || undefined, + minimumFractionDigits: 0, + }).format(price.unit_amount / 100); + + return ( + { + window.sa_event && + window.sa_event( + `pricing:${ + product.id === COMMERCIAL_LICENSE_PRODUCT_ID + ? "commercial" + : "saas" + }` + ); + handleCheckout(price).catch(sentryException); + }} + type="success" + > + {priceIdLoading + ? session + ? "Redirecting to Stripe..." + : "Redirecting to sign up page..." + : active + ? "Current Plan" + : user + ? "Upgrade Plan" + : "Get Started"} + + } + key={price.product_id} + price={priceString} + title={product.name || "No Product Name"} // Latter should never happen + {...props} + /> + ); +} diff --git a/src/components/ProductCard/SaaS100k.tsx b/src/components/ProductCard/SaaS100k.tsx new file mode 100644 index 00000000..91dbaf39 --- /dev/null +++ b/src/components/ProductCard/SaaS100k.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { Text } from "@geist-ui/react"; +import { ProductCard } from "./ProductCard"; +import type { ProductCardProps } from "./ProductCard"; + +export function SaaS100k(props: ProductCardProps): React.ReactElement { + return ( + + ✨ New ✨ + + } + subtitle={100,000 email verifications / mo} + /> + ); +} + +const features = [ + "Use Reacher's servers with high IP reputation.", + + + Full-featured + {" "} + email verifications. + , + + Customer support via email/chat. + , + "Cancel anytime.", +]; diff --git a/src/components/ProductCard/SaaS10k.tsx b/src/components/ProductCard/SaaS10k.tsx new file mode 100644 index 00000000..c10cce4c --- /dev/null +++ b/src/components/ProductCard/SaaS10k.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { Text } from "@geist-ui/react"; +import { ProductCard } from "./ProductCard"; +import type { ProductCardProps } from "./ProductCard"; + +export function SaaS10k(props: ProductCardProps): React.ReactElement { + return ( + + For individuals + + } + features={features} + subtitle={10,000 email verifications / mo} + /> + ); +} + +const features = [ + "Use Reacher's servers with high IP reputation.", + + + Full-featured + {" "} + email verifications. + , + + Customer support via email/chat. + , + "Cancel anytime.", +]; diff --git a/src/components/ProductCard/Sub.tsx b/src/components/ProductCard/Sub.tsx deleted file mode 100644 index 970738d8..00000000 --- a/src/components/ProductCard/Sub.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { Button, Text } from "@geist-ui/react"; -import { Info } from "@geist-ui/react-icons"; -import { useRouter } from "next/router"; -import React, { useState } from "react"; - -import { postData } from "@/util/helpers"; -import { sentryException } from "@/util/sentry"; -import { getStripe } from "@/util/stripeClient"; -import { COMMERCIAL_LICENSE_PRODUCT_ID } from "@/util/subs"; -import type {} from "@/util/supabaseClient"; -import { useUser } from "@/util/useUser"; -import { Card } from "./Card"; -import styles from "./Card.module.css"; -import { Tables } from "@/supabase/database.types"; -import { ProductWithPrice } from "@/supabase/domain.types"; - -export interface ProductCardProps { - currency: string; - product: ProductWithPrice; - subscription: Tables<"subscriptions"> | null; -} - -export function ProductCard({ - currency, - product, - subscription, -}: ProductCardProps): React.ReactElement { - const router = useRouter(); - const [priceIdLoading, setPriceIdLoading] = useState(); - const { session, user } = useUser(); - - const active = !!subscription; - const price = product.prices.find(({ currency: c }) => currency === c); - if (!price || !price.unit_amount) { - return

Error: No price found for product {product.id}.

; - } - - const handleCheckout = async (price: Tables<"prices">) => { - setPriceIdLoading(price.id); - - if (!session) { - router.push("/signup").catch(sentryException); - - return; - } - - try { - const { sessionId } = await postData<{ sessionId: string }>({ - url: "/api/stripe/create-checkout-session", - data: { price }, - token: session.access_token, - }); - - const stripe = await getStripe(); - if (!stripe) { - throw new Error("Empty stripe object at checkout"); - } - - await stripe.redirectToCheckout({ sessionId }); - } catch (err) { - sentryException(err as Error); - alert((err as Error).message); - } finally { - setPriceIdLoading(false); - } - }; - - const priceString = new Intl.NumberFormat("en-US", { - style: "currency", - currency: price.currency || undefined, - minimumFractionDigits: 0, - }).format(price.unit_amount / 100); - - return ( - { - window.sa_event && - window.sa_event( - `pricing:${ - product.id === COMMERCIAL_LICENSE_PRODUCT_ID - ? "commercial" - : "saas" - }` - ); - handleCheckout(price).catch(sentryException); - }} - type="success" - > - {priceIdLoading - ? session - ? "Redirecting to Stripe..." - : "Redirecting to sign up page..." - : active - ? "Current Plan" - : user - ? "Upgrade Plan" - : "Get Started"} - - } - features={ - product.id === COMMERCIAL_LICENSE_PRODUCT_ID - ? licenseFeatures() - : saasFeatures() - } - footer={ - product.id === COMMERCIAL_LICENSE_PRODUCT_ID ? ( -
-
- -
- - Want a free trial before - committing? Feel free to try self-hosting with the{" "} - - open-source guide - - , and subscribe once you're ready. - -
- ) : undefined - } - header={ - product.id === COMMERCIAL_LICENSE_PRODUCT_ID ? ( - - For self-hosting - - ) : ( - - Most popular - - ) - } - key={price.product_id} - price={priceString} - subtitle={ - product.id === COMMERCIAL_LICENSE_PRODUCT_ID ? ( - - Self-host Reacher
- with your own infrastructure. -
- ) : ( - - Use Reacher's servers with
- high IP reputation. -
- ) - } - title={product.name || "No Product Name"} // Latter should never happen - /> - ); -} - -function saasFeatures(): (string | React.ReactElement)[] { - return [ - "10000 email verifications per month.", - - - Full-featured - {" "} - email verifications. - , - "Customer support via email/chat.", - "Cancel anytime.", - ]; -} - -function licenseFeatures(): (string | React.ReactElement)[] { - return [ - - Unlimited email verifications per month. - , - - 💪 Bulk email verification.{" "} - - Read more. - - , - - Self-host in your commercial apps. No data sent - back to Reacher. - , - - - Full-featured - {" "} - email verifications. - , - "Customer support via email/chat.", - - Comes with{" "} - - self-host guides - {" "} - (Heroku, Docker). - , - - See{" "} - - full terms and details - - . - , - ]; -} diff --git a/src/components/ProductCard/index.ts b/src/components/ProductCard/index.ts index 58e93393..d5df76bd 100644 --- a/src/components/ProductCard/index.ts +++ b/src/components/ProductCard/index.ts @@ -1,2 +1,4 @@ +export * from "./Commercial"; export * from "./FreeTrial"; -export * from "./Sub"; +export * from "./SaaS10k"; +export * from "./SaaS100k"; diff --git a/src/components/SubGetStarted/GetStartedLicense.tsx b/src/components/SubGetStarted/GetStartedLicense.tsx index d31285d8..4a1b19a0 100644 --- a/src/components/SubGetStarted/GetStartedLicense.tsx +++ b/src/components/SubGetStarted/GetStartedLicense.tsx @@ -4,7 +4,7 @@ import React from "react"; export function GetStartedLicense(): React.ReactElement { return ( - How to get started with email verifications? + How to get started with the Commercial License? To get started with self-hosting, please refer to our{" "} diff --git a/src/components/SubGetStarted/GetStartedSaas.tsx b/src/components/SubGetStarted/GetStartedSaas.tsx index c8b46251..ca2dc4bc 100644 --- a/src/components/SubGetStarted/GetStartedSaas.tsx +++ b/src/components/SubGetStarted/GetStartedSaas.tsx @@ -103,7 +103,10 @@ export function GetStartedSaas(): React.ReactElement { for using the API, with a program called Postman. If you still have questions, just use the chat widget on the bottom right corner to send me a message, or shoot me an email at{" "} - amaury@reacher.email. + + 📧 amaury@reacher.email + + . ); diff --git a/src/pages/pricing.tsx b/src/pages/pricing.tsx index 9bda9f69..705c6e40 100644 --- a/src/pages/pricing.tsx +++ b/src/pages/pricing.tsx @@ -1,15 +1,18 @@ -import { Grid, Select, Spacer, Text } from "@geist-ui/react"; +import { Collapse, Grid, Page, Select, Spacer, Text } from "@geist-ui/react"; import { GetStaticProps } from "next"; import React, { useState } from "react"; -import { FreeTrial, Nav, ProductCard } from "../components"; +import { Nav } from "../components/Nav"; +import { SaaS10k, SaaS100k, Commercial } from "../components/ProductCard"; import { COMMERCIAL_LICENSE_PRODUCT_ID, + SAAS_100K_PRODUCT_ID, SAAS_10K_PRODUCT_ID, } from "@/util/subs"; import { getActiveProductWithPrices } from "@/util/supabaseClient"; import { useUser } from "@/util/useUser"; import { ProductWithPrice } from "@/supabase/domain.types"; +import Link from "next/link"; export const getStaticProps: GetStaticProps = async () => { const products = await getActiveProductWithPrices(); @@ -34,12 +37,17 @@ export default function Pricing({ subscriptionCurrency || "eur" ); - const saasProduct = products.find(({ id }) => id === SAAS_10K_PRODUCT_ID); - const licenseProduct = products.find( + const saas10kProduct = products.find( + ({ id }) => id === SAAS_10K_PRODUCT_ID + ); + const saas100kProduct = products.find( + ({ id }) => id === SAAS_100K_PRODUCT_ID + ); + const commercialProduct = products.find( ({ id }) => id === COMMERCIAL_LICENSE_PRODUCT_ID ); - if (!saasProduct || !licenseProduct) { - throw new Error("Pricing: saasProduct or licenseProduct not found."); + if (!saas10kProduct || !saas100kProduct || !commercialProduct) { + throw new Error("Pricing: saasProduct or commercialProduct not found."); } return ( @@ -66,24 +74,33 @@ export default function Pricing({ - + - - + + + + + Frequently Asked Questions + + + + + Reacher currently does not offer a + SaaS plan for verifying 1 million or more emails. If + you need to verify 1 million or more emails, please + check the Commercial License plan. You will need to + purchase your own servers and self-host Reacher. + + + Yes. Simple{" "} + create an account and + you can use the textbox to verify your email. + + + Yes. Follow these steps in the{" "} + + self-host guide + + . + + + Send me an email to{" "} + + 📧 amaury@reacher.email + + , I reply pretty fast. + + + ); diff --git a/src/pages/signup.tsx b/src/pages/signup.tsx index 95cf7ffe..0a72a40a 100644 --- a/src/pages/signup.tsx +++ b/src/pages/signup.tsx @@ -117,6 +117,11 @@ export default function SignUp(): React.ReactElement { return ( +

+ + 50 free email verifications when you sign up. + +

{ .from("sub_and_calls") .select("*") .eq("user_id", user.id) + .order("current_period_start", { ascending: false }) + .limit(1) .single(); if (res2.error) { throw res2.error; diff --git a/src/util/subs.ts b/src/util/subs.ts index 629564e6..eede0d1a 100644 --- a/src/util/subs.ts +++ b/src/util/subs.ts @@ -3,6 +3,8 @@ import { SubscriptionWithPrice } from "@/supabase/domain.types"; // We're hardcoding these as env variables. export const SAAS_10K_PRODUCT_ID = process.env.NEXT_PUBLIC_SAAS_10K_PRODUCT_ID; +export const SAAS_100K_PRODUCT_ID = + process.env.NEXT_PUBLIC_SAAS_100K_PRODUCT_ID; export const COMMERCIAL_LICENSE_PRODUCT_ID = process.env.NEXT_PUBLIC_COMMERCIAL_LICENSE_PRODUCT_ID; @@ -19,5 +21,9 @@ export function productName(product?: Tables<"products">): string { // Return the max monthly calls export function subApiMaxCalls(sub: SubscriptionWithPrice | null): number { - return sub?.prices?.products?.id === SAAS_10K_PRODUCT_ID ? 10000 : 50; + return sub?.prices?.products?.id === SAAS_100K_PRODUCT_ID + ? 100_000 + : sub?.prices?.products?.id === SAAS_10K_PRODUCT_ID + ? 10_000 + : 50; } diff --git a/src/util/supabaseClient.ts b/src/util/supabaseClient.ts index a562fdb6..f8113cf4 100644 --- a/src/util/supabaseClient.ts +++ b/src/util/supabaseClient.ts @@ -24,8 +24,7 @@ export async function getActiveProductWithPrices(): Promise< .order("metadata->index") // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore Supabase typings error? - .order("unit_amount", { foreignTable: "prices" }); - + .order("unit_amount", { foreignTable: "prices", ascending: false }); if (error) { throw error; } diff --git a/src/util/useUser.tsx b/src/util/useUser.tsx index 732e2d64..d01c64e1 100644 --- a/src/util/useUser.tsx +++ b/src/util/useUser.tsx @@ -94,7 +94,8 @@ export const UserContextProvider = ( supabase .from("subscriptions") .select("*, prices(*, products(*))") - .in("status", ["trialing", "active", "past_due"]); + .in("status", ["trialing", "active", "past_due"]) + .order("current_period_start", { ascending: false }); useEffect(() => { if (user) { Promise.all([getUserDetails(), getSubscription()])