22 |
23 |
24 | {/*
*/}
25 |
26 |
32 |
33 |
34 | {title}
35 |
36 |
37 |
38 | {features.map((feature) => (
39 |
43 |
44 | {feature}
45 |
46 | ))}
47 |
48 |
49 |
50 |
61 |
72 |
73 |
74 |
75 |
76 | )
77 | }
78 |
--------------------------------------------------------------------------------
/app/routes/_index/hero-section.tsx:
--------------------------------------------------------------------------------
1 | import { NavLink } from "@remix-run/react"
2 |
3 | import { Logo } from "@/lib/brand/logo"
4 | import { Button } from "@/components/ui/button"
5 |
6 | import { Discountbadge } from "./discount-badge"
7 | import { SocialProof } from "./social-proof"
8 |
9 | export function HeroSection() {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | SignUp
20 |
21 |
22 | Login
23 |
24 |
25 |
26 |
27 |
28 |
32 |
33 |
40 |
41 |
48 |
49 |
50 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | Your tagline goes here
65 |
66 |
67 | Lorem, ipsum dolor sit amet consectetur adipisicing elit. Rerum
68 | quisquam, iusto voluptatem dolore voluptas non laboriosam soluta
69 | quos quod eos! Sapiente archit
70 |
71 |
72 |
73 |
74 | Get started
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | )
86 | }
87 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "mongodb"
10 | url = env("DATABASE_URL")
11 | }
12 |
13 | model User {
14 | id String @id @default(auto()) @map("_id") @db.ObjectId
15 | email String @unique
16 | fullName String
17 | password String?
18 | isGoogleSignUp Boolean @default(false)
19 | emailVerified Boolean @default(false)
20 | createdAt DateTime @default(now())
21 | updatedAt DateTime @updatedAt
22 | verificationCodeId String? @db.ObjectId
23 | customerId String?
24 | PasswordResetToken PasswordResetToken[]
25 | VerificationCode VerificationCode[]
26 | Subscription Subscription[]
27 |
28 | @@index([customerId], name: "customerId")
29 | }
30 |
31 | model PasswordResetToken {
32 | id String @id @default(auto()) @map("_id") @db.ObjectId
33 | token String @unique
34 | expires BigInt
35 | userId String @db.ObjectId
36 | user User @relation(fields: [userId], references: [id])
37 | }
38 |
39 | model VerificationCode {
40 | id String @id @default(auto()) @map("_id") @db.ObjectId
41 | code String
42 | expires BigInt
43 | userId String @db.ObjectId
44 | user User @relation(fields: [userId], references: [id])
45 | }
46 |
47 | // Prisma schema for stripe plan, price, subscription
48 |
49 | model Plan {
50 | id String @id @default(auto()) @map("_id") @db.ObjectId
51 | name String
52 | description String?
53 | prices Price[]
54 | subcriptions Subscription[]
55 | isActive Boolean
56 | stripePlanId String
57 | limits PlanLimit[]
58 | listOfFeatures Json[]
59 |
60 | createdAt DateTime @default(now())
61 | updatedAt DateTime @updatedAt
62 | }
63 |
64 | model PlanLimit {
65 | id String @id @default(auto()) @map("_id") @db.ObjectId
66 | plan Plan @relation(fields: [planId], references: [id])
67 | planId String @db.ObjectId
68 |
69 | allowedUsersCount Int
70 | allowedProjectsCount Int
71 | allowedStorageSize Int
72 | }
73 |
74 | model Price {
75 | id String @id @default(auto()) @map("_id") @db.ObjectId
76 | isActive Boolean
77 | currency String
78 | interval String
79 | nickname String?
80 | amount Int
81 | stripePriceId String
82 | planId String @db.ObjectId
83 | plan Plan @relation(fields: [planId], references: [id])
84 | subscriptions Subscription[]
85 |
86 | createdAt DateTime @default(now())
87 | updatedAt DateTime @updatedAt
88 | }
89 |
90 | model Subscription {
91 | id String @id @default(auto()) @map("_id") @db.ObjectId
92 | isActive Boolean
93 | status String
94 | cancelAtPeriodEnd Boolean
95 | currentPeriodEnd BigInt
96 | currentPeriodStart BigInt
97 | interval String
98 | customerId String
99 | subscriptionId String
100 | planId String @db.ObjectId
101 | plan Plan @relation(fields: [planId], references: [id])
102 | userId String @db.ObjectId
103 | user User @relation(fields: [userId], references: [id])
104 | priceId String @db.ObjectId
105 | price Price @relation(fields: [priceId], references: [id])
106 |
107 | @@index([customerId], name: "customerId")
108 | @@index([subscriptionId], name: "subscriptionId")
109 | }
110 |
--------------------------------------------------------------------------------
/app/routes/_index/pricing.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react"
2 | import { NavLink, useLoaderData } from "@remix-run/react"
3 |
4 | import { getformattedCurrency } from "@/lib/utils"
5 | import {
6 | CTAContainer,
7 | FeaturedBadgeContainer,
8 | FeatureListContainer,
9 | PricingCard,
10 | } from "@/components/pricing/containers"
11 | import {
12 | Feature,
13 | FeatureDescription,
14 | FeaturePrice,
15 | FeatureTitle,
16 | FeatureType,
17 | } from "@/components/pricing/feature"
18 | import { PricingSwitch } from "@/components/pricing/pricing-switch"
19 | import { Button } from "@/components/ui/button"
20 |
21 | import type { loader } from "./route"
22 |
23 | export const Pricing = () => {
24 | const { plans, defaultCurrency } = useLoaderData
()
25 | const [interval, setInterval] = useState<"month" | "year">("month")
26 |
27 | return (
28 |
29 |
30 |
31 |
32 | Pricing Plans
33 |
34 |
35 |
36 | {/* TODO: add content here @keyur */}
37 | Lorem, ipsum dolor sit amet consectetur adipisicing elit. Rerum
38 | quisquam, iusto voluptatem dolore voluptas non laboriosam soluta quos
39 | quod eos! Sapiente archit
40 |
41 |
42 |
setInterval(value === "0" ? "month" : "year")}
44 | />
45 |
46 |
47 | {plans.map((plan) => {
48 | const discount = plan.prices[0].amount * 12 - plan.prices[1].amount
49 | const showDiscount =
50 | interval === "year" && plan.prices[0].amount !== 0
51 | const planPrice = plan.prices.find(
52 | (p) => p.currency === defaultCurrency && p.interval == interval
53 | )?.amount as number
54 |
55 | return (
56 |
57 | {showDiscount && discount > 0 && (
58 |
59 | Save {getformattedCurrency(discount, defaultCurrency)}
60 |
61 | )}
62 | {plan.name}
63 | {plan.description}
64 |
68 |
69 | {(plan.listOfFeatures as FeatureType[]).map(
70 | (feature, index) => (
71 |
77 | )
78 | )}
79 |
80 |
81 |
82 | Choose Plan
83 |
84 |
85 |
86 | )
87 | })}
88 |
89 |
90 |
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/app/routes/api+/webhook.ts:
--------------------------------------------------------------------------------
1 | // generate action for remix which accepets stripe webhook events
2 |
3 | import { json, type ActionFunctionArgs } from "@remix-run/node"
4 | import type { Subscription } from "@prisma/client"
5 |
6 | import { stripe } from "@/services/stripe/setup.server"
7 | import { getPlanByStripeId } from "@/models/plan"
8 | import {
9 | deleteSubscriptionByCustomerId,
10 | getSubscriptionById,
11 | getSubscriptionByStripeId,
12 | updateSubscription,
13 | } from "@/models/subscription"
14 | import { getUserByStripeCustomerId } from "@/models/user"
15 |
16 | const getStripeWebhookEvent = async (request: Request) => {
17 | const sig = request.headers.get("stripe-signature")
18 | if (!sig) return json({}, { status: 400 })
19 |
20 | const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET
21 | if (!endpointSecret) return json({}, { status: 400 })
22 |
23 | try {
24 | const payload = await request.text()
25 | const event = stripe.webhooks.constructEvent(payload, sig, endpointSecret)
26 | return event
27 | } catch (err) {
28 | return json({}, { status: 400 })
29 | }
30 | }
31 |
32 | const sendPlanEndNotification = async (id: Subscription["id"]) => {
33 | const subscription = await getSubscriptionById(id, {
34 | user: true,
35 | plan: true,
36 | price: true,
37 | })
38 | if (!subscription) return
39 | if (subscription.status == "trialing") {
40 | // TODO: send trial ending soon email
41 | console.log("Trial ending soon")
42 | } else {
43 | // TODO: send trial ending soon email
44 | console.log("Plan ending soon")
45 | }
46 | }
47 |
48 | export async function action({ request }: ActionFunctionArgs) {
49 | const event = await getStripeWebhookEvent(request)
50 |
51 | // Handle the event
52 | switch (event.type) {
53 | case "customer.subscription.created":
54 | // Update subscription in database.
55 | // We have handled this in the create-subscription route.
56 | break
57 | case "customer.subscription.updated":
58 | // Update subscription in database.
59 | const stripeSubscription = event.data.object
60 | const customerId = stripeSubscription.customer
61 |
62 | if (!customerId) return new Response("Success", { status: 200 })
63 |
64 | const user = await getUserByStripeCustomerId(customerId as string)
65 |
66 | if (!user) return new Response("Success", { status: 200 })
67 |
68 | const subscriptionByStripeId = await getSubscriptionByStripeId(
69 | stripeSubscription.id
70 | )
71 |
72 | console.log(subscriptionByStripeId)
73 |
74 | if (!subscriptionByStripeId?.id) return json({}, { status: 200 })
75 |
76 | const dbPlan = await getPlanByStripeId(
77 | stripeSubscription.items.data[0].plan.product as string
78 | )
79 | const dbPrice = dbPlan?.prices.find(
80 | (price) =>
81 | price.stripePriceId === stripeSubscription.items.data[0].price.id
82 | )
83 |
84 | await updateSubscription(subscriptionByStripeId.id, {
85 | isActive:
86 | stripeSubscription.status === "active" ||
87 | stripeSubscription.status === "trialing",
88 | status: stripeSubscription.status,
89 | planId: dbPlan?.id,
90 | priceId: dbPrice?.id,
91 | interval: String(stripeSubscription.items.data[0].plan.interval),
92 | currentPeriodStart: stripeSubscription.current_period_start,
93 | currentPeriodEnd: stripeSubscription.current_period_end,
94 | cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
95 | })
96 |
97 | break
98 | case "customer.subscription.deleted":
99 | // Delete subscription from database.
100 | const subscriptionId = event.data.object.id
101 | const subscription = await getSubscriptionByStripeId(subscriptionId)
102 | if (!subscription) return json({}, { status: 200 })
103 | await sendPlanEndNotification(subscription.id)
104 |
105 | await deleteSubscriptionByCustomerId(
106 | event.data.object.customer.toString()
107 | )
108 | break
109 | default:
110 | return json({}, { status: 200 })
111 | }
112 |
113 | return json({}, { status: 200 })
114 | }
115 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * By default, Remix will handle generating the HTTP Response for you.
3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4 | * For more information, see https://remix.run/file-conventions/entry.server
5 | */
6 |
7 | import { PassThrough } from "node:stream"
8 | import type { AppLoadContext, EntryContext } from "@remix-run/node"
9 | import { createReadableStreamFromReadable } from "@remix-run/node"
10 | import { RemixServer } from "@remix-run/react"
11 | import isbot from "isbot"
12 | import { renderToPipeableStream } from "react-dom/server"
13 |
14 | const ABORT_DELAY = 5_000
15 |
16 | export default function handleRequest(
17 | request: Request,
18 | responseStatusCode: number,
19 | responseHeaders: Headers,
20 | remixContext: EntryContext,
21 | loadContext: AppLoadContext
22 | ) {
23 | return isbot(request.headers.get("user-agent"))
24 | ? handleBotRequest(
25 | request,
26 | responseStatusCode,
27 | responseHeaders,
28 | remixContext
29 | )
30 | : handleBrowserRequest(
31 | request,
32 | responseStatusCode,
33 | responseHeaders,
34 | remixContext
35 | )
36 | }
37 |
38 | function handleBotRequest(
39 | request: Request,
40 | responseStatusCode: number,
41 | responseHeaders: Headers,
42 | remixContext: EntryContext
43 | ) {
44 | return new Promise((resolve, reject) => {
45 | let shellRendered = false
46 | const { pipe, abort } = renderToPipeableStream(
47 | ,
52 | {
53 | onAllReady() {
54 | shellRendered = true
55 | const body = new PassThrough()
56 | const stream = createReadableStreamFromReadable(body)
57 |
58 | responseHeaders.set("Content-Type", "text/html")
59 |
60 | resolve(
61 | new Response(stream, {
62 | headers: responseHeaders,
63 | status: responseStatusCode,
64 | })
65 | )
66 |
67 | pipe(body)
68 | },
69 | onShellError(error: unknown) {
70 | reject(error)
71 | },
72 | onError(error: unknown) {
73 | responseStatusCode = 500
74 | // Log streaming rendering errors from inside the shell. Don't log
75 | // errors encountered during initial shell rendering since they'll
76 | // reject and get logged in handleDocumentRequest.
77 | if (shellRendered) {
78 | console.error(error)
79 | }
80 | },
81 | }
82 | )
83 |
84 | setTimeout(abort, ABORT_DELAY)
85 | })
86 | }
87 |
88 | function handleBrowserRequest(
89 | request: Request,
90 | responseStatusCode: number,
91 | responseHeaders: Headers,
92 | remixContext: EntryContext
93 | ) {
94 | return new Promise((resolve, reject) => {
95 | let shellRendered = false
96 | const { pipe, abort } = renderToPipeableStream(
97 | ,
102 | {
103 | onShellReady() {
104 | shellRendered = true
105 | const body = new PassThrough()
106 | const stream = createReadableStreamFromReadable(body)
107 |
108 | responseHeaders.set("Content-Type", "text/html")
109 |
110 | resolve(
111 | new Response(stream, {
112 | headers: responseHeaders,
113 | status: responseStatusCode,
114 | })
115 | )
116 |
117 | pipe(body)
118 | },
119 | onShellError(error: unknown) {
120 | reject(error)
121 | },
122 | onError(error: unknown) {
123 | responseStatusCode = 500
124 | // Log streaming rendering errors from inside the shell. Don't log
125 | // errors encountered during initial shell rendering since they'll
126 | // reject and get logged in handleDocumentRequest.
127 | if (shellRendered) {
128 | console.error(error)
129 | }
130 | },
131 | }
132 | )
133 |
134 | setTimeout(abort, ABORT_DELAY)
135 | })
136 | }
137 |
--------------------------------------------------------------------------------
/app/services/stripe/stripe.server.ts:
--------------------------------------------------------------------------------
1 | import type { Plan, Price, User } from "@prisma/client"
2 | import type { Stripe } from "stripe"
3 |
4 | import { siteConfig } from "@/lib/brand/config"
5 |
6 | import { PLAN_TYPES, type PLAN_INTERVALS } from "./plans.config"
7 | import { stripe } from "./setup.server"
8 |
9 | export const createStripeProduct = async (plan: Partial) => {
10 | const product = await stripe.products.create({
11 | name: plan.name || "",
12 | description: plan.description || undefined,
13 | })
14 |
15 | return product
16 | }
17 |
18 | export const createStripePrice = async (
19 | id: Plan["id"],
20 | price: Partial
21 | ) => {
22 | return await stripe.prices.create({
23 | nickname: price.nickname || undefined,
24 | product: id,
25 | currency: price.currency || "usd",
26 | unit_amount: price.amount || 0,
27 | tax_behavior: "inclusive",
28 | recurring: {
29 | interval: (price.interval as PLAN_INTERVALS) || "month",
30 | },
31 | })
32 | }
33 |
34 | export const createStripeCustomer = async (
35 | { email, fullName }: Partial,
36 | metadata: Stripe.MetadataParam
37 | ) => {
38 | return await stripe.customers.create({
39 | email,
40 | name: fullName,
41 | metadata: metadata,
42 | })
43 | }
44 |
45 | export const createStripeSubscription = async (
46 | customerId: User["customerId"],
47 | priceId: Price["id"]
48 | ) => {
49 | if (!customerId) {
50 | throw new Error("Stripe Customer ID is required")
51 | }
52 | return await stripe.subscriptions.create({
53 | customer: customerId,
54 | items: [
55 | {
56 | price: priceId,
57 | },
58 | ],
59 | })
60 | }
61 |
62 | export const setupStripeCustomerPortal = async (
63 | products: Stripe.BillingPortal.ConfigurationCreateParams.Features.SubscriptionUpdate.Product[]
64 | ) => {
65 | const portal = await stripe.billingPortal.configurations.create({
66 | features: {
67 | customer_update: {
68 | allowed_updates: ["address", "tax_id", "phone", "shipping"],
69 | enabled: true,
70 | },
71 | invoice_history: {
72 | enabled: true,
73 | },
74 | payment_method_update: {
75 | enabled: true,
76 | },
77 | subscription_cancel: {
78 | enabled: true,
79 | },
80 | subscription_pause: {
81 | enabled: false,
82 | },
83 | subscription_update: {
84 | default_allowed_updates: ["price"],
85 | enabled: true,
86 | proration_behavior: "always_invoice",
87 | products: products.filter(({ product }) => product !== PLAN_TYPES.FREE),
88 | },
89 | },
90 | business_profile: {
91 | headline: `${siteConfig.title} - Manage your subscription`,
92 | },
93 | })
94 |
95 | return portal
96 | }
97 |
98 | export const createStripeCheckoutSession = async (
99 | customerId: User["customerId"],
100 | priceId: Price["id"],
101 | successUrl: string,
102 | cancelUrl: string
103 | ) => {
104 | const session = await stripe.checkout.sessions.create({
105 | customer: customerId as string,
106 | payment_method_types: ["card"],
107 | mode: "subscription",
108 | line_items: [
109 | {
110 | price: priceId,
111 | quantity: 1,
112 | },
113 | ],
114 | success_url: successUrl,
115 | cancel_url: cancelUrl,
116 | })
117 |
118 | return session
119 | }
120 |
121 | export const createStripeCustomerPortalSession = async (
122 | customerId: User["customerId"],
123 | returnUrl: string
124 | ) => {
125 | const session = await stripe.billingPortal.sessions.create({
126 | customer: customerId as string,
127 | return_url: returnUrl,
128 | })
129 |
130 | return session
131 | }
132 |
133 | export const createSingleStripeCheckoutSession = async (
134 | customerId: User["customerId"],
135 | priceId: Price["id"],
136 | successUrl: string,
137 | cancelUrl: string
138 | ) => {
139 | const session = await stripe.checkout.sessions.create({
140 | customer: customerId as string,
141 | payment_method_types: ["card"],
142 | mode: "payment",
143 | line_items: [
144 | {
145 | price: priceId,
146 | quantity: 1,
147 | },
148 | ],
149 | success_url: successUrl,
150 | cancel_url: cancelUrl,
151 | })
152 |
153 | return session
154 | }
155 |
--------------------------------------------------------------------------------
/app/lib/server/sitemap/sitemap-utils.server.ts:
--------------------------------------------------------------------------------
1 | // This is adapted from https://github.com/kentcdodds/kentcdodds.com
2 |
3 | import type { ServerBuild } from "@remix-run/server-runtime"
4 | import isEqual from "lodash-es/isEqual.js"
5 |
6 | import type { SEOHandle, SitemapEntry } from "./types.ts"
7 |
8 | type Options = {
9 | siteUrl: string
10 | }
11 |
12 | function typedBoolean(
13 | value: T
14 | ): value is Exclude {
15 | return Boolean(value)
16 | }
17 |
18 | function removeTrailingSlash(s: string) {
19 | return s.endsWith("/") ? s.slice(0, -1) : s
20 | }
21 |
22 | async function getSitemapXml(
23 | request: Request,
24 | routes: ServerBuild["routes"],
25 | options: Options
26 | ) {
27 | const { siteUrl } = options
28 |
29 | function getEntry({
30 | route,
31 | lastmod,
32 | changefreq,
33 | priority = 0.7,
34 | }: SitemapEntry) {
35 | return `
36 |
37 | ${siteUrl}${route}
38 | ${lastmod ? `${lastmod} ` : ""}
39 | ${changefreq ? `${changefreq} ` : ""}
40 | ${typeof priority === "number" ? `${priority} ` : ""}
41 |
42 | `.trim()
43 | }
44 |
45 | const rawSitemapEntries = (
46 | await Promise.all(
47 | Object.entries(routes).map(async ([id, { module: mod }]) => {
48 | if (id === "root") return
49 |
50 | const handle = mod.handle as SEOHandle | undefined
51 | if (handle?.getSitemapEntries) {
52 | return handle.getSitemapEntries(request)
53 | }
54 |
55 | // exclude resource routes from the sitemap
56 | // (these are an opt-in via the getSitemapEntries method)
57 | if (!("default" in mod)) return
58 |
59 | const manifestEntry = routes[id]
60 |
61 | if (!manifestEntry) {
62 | console.warn(`Could not find a manifest entry for ${id}`)
63 | return
64 | }
65 | let parentId = manifestEntry.parentId
66 | let parent = parentId ? routes[parentId] : null
67 |
68 | let path
69 | if (manifestEntry.path) {
70 | path = removeTrailingSlash(manifestEntry.path)
71 | } else if (manifestEntry.index) {
72 | path = ""
73 | } else {
74 | return
75 | }
76 |
77 | while (parent) {
78 | // the root path is '/', so it messes things up if we add another '/'
79 | const parentPath = parent.path ? removeTrailingSlash(parent.path) : ""
80 |
81 | if (parent.path) {
82 | path = `${parentPath}/${path}`
83 | parentId = parent.parentId
84 | parent = parentId ? routes[parentId] : null
85 | } else {
86 | path = `${parentPath}/${path}`
87 | parent = null
88 | }
89 | }
90 |
91 | // we can't handle dynamic routes, so if the handle doesn't have a
92 | // getSitemapEntries function, we just
93 | if (path.includes(":")) return
94 | if (id === "root") return
95 |
96 | const entry: SitemapEntry = { route: removeTrailingSlash(path) }
97 | return entry
98 | })
99 | )
100 | )
101 | .flatMap((z) => z)
102 | .filter(typedBoolean)
103 |
104 | const sitemapEntries: Array = []
105 | for (const entry of rawSitemapEntries) {
106 | const existingEntryForRoute = sitemapEntries.find(
107 | (e) => e.route === entry.route
108 | )
109 | if (existingEntryForRoute) {
110 | if (!isEqual(existingEntryForRoute, entry)) {
111 | // if (false) {
112 | console.warn(
113 | `Duplicate route for ${entry.route} with different sitemap data`,
114 | { entry, existingEntryForRoute }
115 | )
116 | }
117 | } else {
118 | sitemapEntries.push(entry)
119 | }
120 | }
121 |
122 | return `
123 |
124 |
129 | ${sitemapEntries.map((entry) => getEntry(entry)).join("")}
130 |
131 | `.trim()
132 | }
133 |
134 | export { getSitemapXml }
135 |
--------------------------------------------------------------------------------
/app/routes/_index/footer.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { Logo } from "@/lib/brand/logo"
4 |
5 | export default function Footer() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | 476 Shantivan Soc. Rajkot, India
17 |
18 |
19 |
20 |
43 |
66 |
89 |
112 |
113 |
114 |
115 |
116 | )
117 | }
118 |
--------------------------------------------------------------------------------
/app/lib/server/auth-utils.sever.ts:
--------------------------------------------------------------------------------
1 | import * as crypto from "crypto"
2 | import type { User } from "@prisma/client"
3 |
4 | import { prisma } from "@/services/db/db.server"
5 | import { sendEmail } from "@/services/email/resend.server"
6 | import ResetPasswordEmailTemplate from "@/components/email/reset-password-template"
7 | import VerificationEmailTemplate from "@/components/email/verify-email-template"
8 |
9 | import { siteConfig } from "../brand/config"
10 |
11 | const EXPIRES_IN = 1000 * 60 * 20 // 20 mins
12 |
13 | const DEFAULT_ALPHABET = "abcdefghijklmnopqrstuvwxyz1234567890"
14 |
15 | export const generateRandomString = (
16 | length: number,
17 | alphabet: string = DEFAULT_ALPHABET
18 | ) => {
19 | const randomUint32Values = new Uint32Array(length)
20 | crypto.getRandomValues(randomUint32Values)
21 | const u32Max = 0xffffffff
22 | let result = ""
23 | for (let i = 0; i < randomUint32Values.length; i++) {
24 | const rand = randomUint32Values[i] / (u32Max + 1)
25 | result += alphabet[Math.floor(alphabet.length * rand)]
26 | }
27 | return result
28 | }
29 |
30 | export const isWithinExpiration = (expiresInMs: number | bigint): boolean => {
31 | const currentTime = Date.now()
32 | if (currentTime > expiresInMs) return false
33 | return true
34 | }
35 |
36 | // TODO: decide where this util should reside here or auth.server.ts
37 | export const sendResetPasswordLink = async (user: User) => {
38 | async function emailResetLink(code: string) {
39 | // TODO: user env variable for url of reset link
40 | const url = process.env.HOST_URL
41 | ? `${process.env.HOST_URL}/reset-password?code=${code}`
42 | : `http://localhost:3000/reset-password?code=${code}`
43 | await sendEmail(
44 | `${user.fullName} <${user.email}>`,
45 | `Password reset - ${siteConfig.title}`,
46 | ResetPasswordEmailTemplate({ resetLink: url })
47 | )
48 | }
49 |
50 | const storedUserTokens = await prisma.passwordResetToken.findMany({
51 | where: {
52 | userId: user.id,
53 | },
54 | })
55 | if (storedUserTokens.length > 0) {
56 | const reusableStoredToken = storedUserTokens.find((token) => {
57 | // check if expiration is within 1 hour
58 | // and reuse the token if true
59 | return isWithinExpiration(Number(token.expires) - EXPIRES_IN / 2)
60 | })
61 | if (reusableStoredToken) {
62 | await emailResetLink(reusableStoredToken.token)
63 | return
64 | }
65 | }
66 | const token = generateRandomString(63)
67 | await prisma.passwordResetToken.create({
68 | data: {
69 | token,
70 | expires: new Date().getTime() + EXPIRES_IN,
71 | userId: user.id,
72 | },
73 | })
74 |
75 | await emailResetLink(token)
76 | }
77 |
78 | export const sendVerificationCode = async (user: User) => {
79 | const code = generateRandomString(8, "0123456789")
80 |
81 | await prisma.$transaction(async (trx) => {
82 | await trx.verificationCode
83 | .deleteMany({
84 | where: {
85 | userId: user.id,
86 | },
87 | })
88 | .catch()
89 |
90 | await trx.verificationCode.create({
91 | data: {
92 | code,
93 | userId: user.id,
94 | expires: Date.now() + 1000 * 60 * 20, // 10 minutes
95 | },
96 | })
97 | })
98 |
99 | if (process.env.NODE_ENV === "development") {
100 | console.log(`verification for ${user.email} code is: ${code}`)
101 | } else {
102 | await sendEmail(
103 | `${user.fullName} <${user.email}>`,
104 | `Verification code - ${siteConfig.title}`,
105 | VerificationEmailTemplate({ validationCode: code })
106 | )
107 | }
108 | }
109 |
110 | export const validatePasswordResetToken = async (token: string) => {
111 | const storedToken = await prisma.$transaction(async (trx) => {
112 | const storedToken = await trx.passwordResetToken.findFirst({
113 | where: {
114 | token,
115 | },
116 | })
117 |
118 | if (!storedToken) {
119 | throw new Error("Invalid token")
120 | }
121 |
122 | await trx.passwordResetToken.delete({
123 | where: {
124 | token,
125 | },
126 | })
127 |
128 | return storedToken
129 | })
130 | const tokenExpires = Number(storedToken.expires)
131 | if (!isWithinExpiration(tokenExpires)) {
132 | throw new Error("Expired token")
133 | }
134 | return storedToken.userId
135 | }
136 |
--------------------------------------------------------------------------------
/app/services/auth.server.ts:
--------------------------------------------------------------------------------
1 | // app/services/auth.server.ts
2 | import { randomBytes, scrypt, timingSafeEqual } from "crypto"
3 | import type { User } from "@prisma/client"
4 | import { Authenticator } from "remix-auth"
5 | import { FormStrategy } from "remix-auth-form"
6 | import { GoogleStrategy } from "remix-auth-google"
7 | import { z } from "zod"
8 |
9 | import { sendVerificationCode } from "@/lib/server/auth-utils.sever"
10 | import { sessionStorage } from "@/services/session.server"
11 |
12 | import { prisma } from "./db/db.server"
13 |
14 | const payloadSchema = z.object({
15 | email: z.string(),
16 | password: z.string(),
17 | fullName: z.string().optional(),
18 | tocAccepted: z.literal(true).optional(),
19 | type: z.enum(["login", "signup"]),
20 | })
21 |
22 | // Create an instance of the authenticator, pass a generic with what
23 | // strategies will return and will store in the session
24 | export let authenticator = new Authenticator(sessionStorage)
25 |
26 | const keyLength = 32
27 |
28 | export const hash = async (password: string): Promise => {
29 | return new Promise((resolve, reject) => {
30 | // generate random 16 bytes long salt - recommended by NodeJS Docs
31 | const salt = randomBytes(16).toString("hex")
32 |
33 | scrypt(password, salt, keyLength, (err, derivedKey) => {
34 | if (err) reject(err)
35 | // derivedKey is of type Buffer
36 | resolve(`${salt}.${derivedKey.toString("hex")}`)
37 | })
38 | })
39 | }
40 |
41 | export const compare = async (
42 | password: string,
43 | hash: string
44 | ): Promise => {
45 | return new Promise((resolve, reject) => {
46 | const [salt, hashKey] = hash.split(".")
47 | // we need to pass buffer values to timingSafeEqual
48 | const hashKeyBuff = Buffer.from(hashKey, "hex")
49 | scrypt(password, salt, keyLength, (err, derivedKey) => {
50 | if (err) reject(err)
51 | // compare the new supplied password with the hashed password using timeSafeEqual
52 | resolve(timingSafeEqual(hashKeyBuff, derivedKey))
53 | })
54 | })
55 | }
56 |
57 | const formStrategy = new FormStrategy(async ({ form, context }) => {
58 | const parsedData = payloadSchema.safeParse(context)
59 |
60 | if (parsedData.success) {
61 | const { email, password, type, fullName } = parsedData.data
62 |
63 | if (type === "login") {
64 | // let user = await login(email, password);
65 | // the type of this user must match the type you pass to the Authenticator
66 | // the strategy will automatically inherit the type if you instantiate
67 | // directly inside the `use` method
68 | const user = await prisma.user.findFirst({
69 | where: {
70 | email,
71 | },
72 | })
73 |
74 | if (user) {
75 | if (user.isGoogleSignUp && !user.password) {
76 | throw new Error("GOOGLE_SIGNUP")
77 | }
78 |
79 | const isPasswordCorrect = await compare(password, user?.password || "")
80 | if (isPasswordCorrect) {
81 | return user
82 | } else {
83 | // TODO: type errors well
84 | throw new Error("INVALID_PASSWORD")
85 | }
86 | }
87 | } else {
88 | const hashedPassword = await hash(password)
89 | const user = await prisma.user.create({
90 | data: {
91 | email: email,
92 | password: hashedPassword,
93 | fullName: fullName || "",
94 | },
95 | })
96 |
97 | sendVerificationCode(user)
98 |
99 | return user
100 | }
101 | } else {
102 | throw new Error("Parsing Failed", { cause: parsedData.error.flatten() })
103 | }
104 |
105 | throw new Error("Login failed")
106 | })
107 |
108 | const googleStrategy = new GoogleStrategy(
109 | {
110 | clientID: process.env.GOOGLE_CLIENT_ID || "",
111 | clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
112 | callbackURL: process.env.HOST_URL
113 | ? `${process.env.HOST_URL}/google/callback`
114 | : "http://localhost:3000/google/callback",
115 | },
116 | async ({ accessToken, refreshToken, extraParams, profile }) => {
117 | return await prisma.user.upsert({
118 | where: {
119 | email: profile.emails[0].value,
120 | },
121 | create: {
122 | email: profile.emails[0].value,
123 | emailVerified: true,
124 | fullName: profile.displayName,
125 | isGoogleSignUp: true,
126 | },
127 | update: {},
128 | })
129 | }
130 | )
131 |
132 | authenticator.use(formStrategy, "user-pass").use(googleStrategy, "google")
133 |
--------------------------------------------------------------------------------
/app/services/stripe/plans.config.ts:
--------------------------------------------------------------------------------
1 | import type { Plan, PlanLimit } from "@prisma/client"
2 |
3 | export const enum PLAN_TYPES {
4 | FREE = "free",
5 | BASIC = "basic",
6 | PRO = "pro",
7 | // ENTERPRISE = 'enterprise',
8 | }
9 |
10 | export const enum PLAN_INTERVALS {
11 | MONTHLY = "month",
12 | YEARLY = "year",
13 | }
14 |
15 | export const enum CURRENCIES {
16 | USD = "usd",
17 | EUR = "eur",
18 | }
19 |
20 | export const DEFAULT_PLANS: {
21 | [key in PLAN_TYPES]: Plan & {
22 | limits: {
23 | [key in Exclude]: number
24 | }
25 | prices: {
26 | [key in PLAN_INTERVALS]: {
27 | [key in CURRENCIES]: number
28 | }
29 | }
30 | }
31 | } = {
32 | [PLAN_TYPES.FREE]: {
33 | id: "free",
34 | name: "Free",
35 | description: "Free plan",
36 | isActive: true,
37 | stripePlanId: "free",
38 | listOfFeatures: [
39 | {
40 | name: "1 user",
41 | isAvailable: true,
42 | inProgress: false,
43 | },
44 | {
45 | name: "1 project",
46 | isAvailable: true,
47 | inProgress: false,
48 | },
49 | {
50 | name: "1GB storage",
51 | isAvailable: true,
52 | inProgress: false,
53 | },
54 | ],
55 | limits: {
56 | allowedUsersCount: 1,
57 | allowedProjectsCount: 1,
58 | allowedStorageSize: 1,
59 | },
60 | prices: {
61 | [PLAN_INTERVALS.MONTHLY]: {
62 | [CURRENCIES.USD]: 0,
63 | [CURRENCIES.EUR]: 0,
64 | },
65 | [PLAN_INTERVALS.YEARLY]: {
66 | [CURRENCIES.USD]: 0,
67 | [CURRENCIES.EUR]: 0,
68 | },
69 | },
70 | createdAt: new Date(),
71 | updatedAt: new Date(),
72 | },
73 | [PLAN_TYPES.BASIC]: {
74 | id: "basic",
75 | name: "Basic",
76 | description: "Basic plan",
77 | isActive: true,
78 | stripePlanId: "basic",
79 | listOfFeatures: [
80 | {
81 | name: "5 users",
82 | isAvailable: true,
83 | inProgress: false,
84 | },
85 | {
86 | name: "5 projects",
87 | isAvailable: true,
88 | inProgress: false,
89 | },
90 | {
91 | name: "5GB storage",
92 | isAvailable: true,
93 | inProgress: false,
94 | },
95 | ],
96 | limits: {
97 | allowedUsersCount: 5,
98 | allowedProjectsCount: 5,
99 | allowedStorageSize: 5,
100 | },
101 | prices: {
102 | [PLAN_INTERVALS.MONTHLY]: {
103 | [CURRENCIES.USD]: 10,
104 | [CURRENCIES.EUR]: 10,
105 | },
106 | [PLAN_INTERVALS.YEARLY]: {
107 | [CURRENCIES.USD]: 100,
108 | [CURRENCIES.EUR]: 100,
109 | },
110 | },
111 | createdAt: new Date(),
112 | updatedAt: new Date(),
113 | },
114 | [PLAN_TYPES.PRO]: {
115 | id: "pro",
116 | name: "Pro",
117 | description: "Pro plan",
118 | isActive: true,
119 | stripePlanId: "pro",
120 | listOfFeatures: [
121 | {
122 | name: "10 users",
123 | isAvailable: true,
124 | inProgress: false,
125 | },
126 | {
127 | name: "10 projects",
128 | isAvailable: true,
129 | inProgress: false,
130 | },
131 | {
132 | name: "10GB storage",
133 | isAvailable: true,
134 | inProgress: false,
135 | },
136 | ],
137 | limits: {
138 | allowedUsersCount: 10,
139 | allowedProjectsCount: 10,
140 | allowedStorageSize: 10,
141 | },
142 | prices: {
143 | [PLAN_INTERVALS.MONTHLY]: {
144 | [CURRENCIES.USD]: 20,
145 | [CURRENCIES.EUR]: 20,
146 | },
147 | [PLAN_INTERVALS.YEARLY]: {
148 | [CURRENCIES.USD]: 200,
149 | [CURRENCIES.EUR]: 200,
150 | },
151 | },
152 | createdAt: new Date(),
153 | updatedAt: new Date(),
154 | },
155 | // [PLAN_TYPES.ENTERPRISE]: {
156 | // id: 'enterprise',
157 | // name: 'Enterprise',
158 | // description: 'Enterprise plan',
159 | // isActive: true,
160 | // listOfFeatures: [
161 | // 'Unlimited users',
162 | // 'Unlimited projects',
163 | // 'Unlimited storage',
164 | // ],
165 | // limits: {
166 | // allowedUsersCount: 0,
167 | // allowedProjectsCount: 0,
168 | // allowedStorageSize: 0,
169 | // },
170 | // prices: {
171 | // [PLAN_INTERVALS.MONTHLY]: {
172 | // [CURRENCIES.USD]: 50,
173 | // [CURRENCIES.EUR]: 50,
174 | // },
175 | // [PLAN_INTERVALS.YEARLY]: {
176 | // [CURRENCIES.USD]: 500,
177 | // [CURRENCIES.EUR]: 500,
178 | // },
179 | // },
180 | // createdAt: new Date(),
181 | // updatedAt: new Date()
182 | // },
183 | }
184 |
--------------------------------------------------------------------------------
/app/routes/_auth+/forgot-password.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from "react"
2 | import {
3 | json,
4 | type ActionFunctionArgs,
5 | type LoaderFunctionArgs,
6 | } from "@remix-run/node"
7 | import type { MetaFunction } from "@remix-run/react"
8 | import { Form, useActionData, useNavigation } from "@remix-run/react"
9 | import { conform, useForm } from "@conform-to/react"
10 | import { parse } from "@conform-to/zod"
11 | import { ReloadIcon } from "@radix-ui/react-icons"
12 | import { AuthenticityTokenInput } from "remix-utils/csrf/react"
13 | import { z } from "zod"
14 |
15 | import { sendResetPasswordLink } from "@/lib/server/auth-utils.sever"
16 | import { validateCsrfToken } from "@/lib/server/csrf.server"
17 | import { mergeMeta } from "@/lib/server/seo/seo-helpers"
18 | import { authenticator } from "@/services/auth.server"
19 | import { prisma } from "@/services/db/db.server"
20 | import { CommonErrorBoundary } from "@/components/error-boundry"
21 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
22 | import { Button } from "@/components/ui/button"
23 | import { Input } from "@/components/ui/input"
24 | import { Label } from "@/components/ui/label"
25 |
26 | const schema = z.object({
27 | email: z
28 | .string({ required_error: "Email is required" })
29 | .email("Email is invalid"),
30 | })
31 |
32 | export async function loader({ request }: LoaderFunctionArgs) {
33 | // If the user is already authenticated redirect to /dashboard directly
34 | return await authenticator.isAuthenticated(request, {
35 | successRedirect: "/",
36 | })
37 | }
38 |
39 | export const meta: MetaFunction = mergeMeta(
40 | // these will override the parent meta
41 | () => {
42 | return [{ title: "Forgot Password" }]
43 | }
44 | )
45 |
46 | export const action = async ({ request }: ActionFunctionArgs) => {
47 | await validateCsrfToken(request)
48 | const formData = await request.formData()
49 |
50 | const submission = await parse(formData, {
51 | schema,
52 | })
53 |
54 | if (!submission.value || submission.intent !== "submit") {
55 | return json({ ...submission, emailSent: false })
56 | }
57 |
58 | const user = await prisma.user.findFirst({
59 | where: {
60 | email: submission.value.email,
61 | },
62 | })
63 |
64 | if (user) {
65 | await sendResetPasswordLink(user)
66 | return json({ ...submission, emailSent: true } as const)
67 | }
68 | }
69 |
70 | export default function ForgotPassword() {
71 | const navigation = useNavigation()
72 | const isFormSubmitting = navigation.state === "submitting"
73 | const lastSubmission = useActionData()
74 | const id = useId()
75 |
76 | const [form, { email }] = useForm({
77 | id,
78 | lastSubmission,
79 | shouldValidate: "onBlur",
80 | shouldRevalidate: "onInput",
81 | onValidate({ formData }) {
82 | return parse(formData, { schema })
83 | },
84 | })
85 |
86 | return (
87 | <>
88 |
89 | Get password reset link
90 |
91 | {!lastSubmission?.emailSent ? (
92 |
123 | ) : (
124 |
125 |
126 | Link sent successfully!
127 |
128 | Password reset link has been sent to your email. Please check spam
129 | folder as well
130 |
131 |
132 |
133 | )}
134 | >
135 | )
136 | }
137 |
138 | export function ErrorBoundary() {
139 | return
140 | }
141 |
--------------------------------------------------------------------------------
/app/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SheetPrimitive from "@radix-ui/react-dialog"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 | import { X } from "lucide-react"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Sheet = SheetPrimitive.Root
9 |
10 | const SheetTrigger = SheetPrimitive.Trigger
11 |
12 | const SheetClose = SheetPrimitive.Close
13 |
14 | const SheetPortal = SheetPrimitive.Portal
15 |
16 | const SheetOverlay = React.forwardRef<
17 | React.ElementRef,
18 | React.ComponentPropsWithoutRef
19 | >(({ className, ...props }, ref) => (
20 |
28 | ))
29 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
30 |
31 | const sheetVariants = cva(
32 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
33 | {
34 | variants: {
35 | side: {
36 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
37 | bottom:
38 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
39 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
40 | right:
41 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
42 | },
43 | },
44 | defaultVariants: {
45 | side: "right",
46 | },
47 | }
48 | )
49 |
50 | interface SheetContentProps
51 | extends React.ComponentPropsWithoutRef,
52 | VariantProps {}
53 |
54 | const SheetContent = React.forwardRef<
55 | React.ElementRef,
56 | SheetContentProps
57 | >(({ side = "right", className, children, ...props }, ref) => (
58 |
59 |
60 |
65 | {children}
66 |
67 |
68 | Close
69 |
70 |
71 |
72 | ))
73 | SheetContent.displayName = SheetPrimitive.Content.displayName
74 |
75 | const SheetHeader = ({
76 | className,
77 | ...props
78 | }: React.HTMLAttributes) => (
79 |
86 | )
87 | SheetHeader.displayName = "SheetHeader"
88 |
89 | const SheetFooter = ({
90 | className,
91 | ...props
92 | }: React.HTMLAttributes) => (
93 |
100 | )
101 | SheetFooter.displayName = "SheetFooter"
102 |
103 | const SheetTitle = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
112 | ))
113 | SheetTitle.displayName = SheetPrimitive.Title.displayName
114 |
115 | const SheetDescription = React.forwardRef<
116 | React.ElementRef,
117 | React.ComponentPropsWithoutRef
118 | >(({ className, ...props }, ref) => (
119 |
124 | ))
125 | SheetDescription.displayName = SheetPrimitive.Description.displayName
126 |
127 | export {
128 | Sheet,
129 | SheetPortal,
130 | SheetOverlay,
131 | SheetTrigger,
132 | SheetClose,
133 | SheetContent,
134 | SheetHeader,
135 | SheetFooter,
136 | SheetTitle,
137 | SheetDescription,
138 | }
139 |
--------------------------------------------------------------------------------
/app/routes/_index/features-variant-b.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { UserIcon } from "lucide-react"
3 |
4 | export default function FeaturesVariantB() {
5 | return (
6 |
7 |
8 | Features that help you get going
9 |
10 |
11 |
12 |
13 |
14 |
18 |
19 | Best feature everyday
20 |
21 |
22 |
23 | Lorem ipsum dolor, sit amet consectetur adipisicing elit.
24 | Consequatur inventore neque aut voluptas, rem debitis quisquam
25 | perferendis odit officia. Deserunt.
26 |
27 |
28 |
29 |
30 |
31 |
32 |
36 |
37 | Best feature everyday
38 |
39 |
40 |
41 | Lorem ipsum dolor, sit amet consectetur adipisicing elit.
42 | Consequatur inventore neque aut voluptas, rem debitis quisquam
43 | perferendis odit officia. Deserunt.
44 |
45 |
46 |
47 |
48 |
49 |
50 |
54 |
55 | Best feature everyday
56 |
57 |
58 |
59 | Lorem ipsum dolor, sit amet consectetur adipisicing elit.
60 | Consequatur inventore neque aut voluptas, rem debitis quisquam
61 | perferendis odit officia. Deserunt.
62 |
63 |
64 |
65 |
66 |
67 |
68 |
72 |
73 | Best feature everyday
74 |
75 |
76 |
77 | Lorem ipsum dolor, sit amet consectetur adipisicing elit.
78 | Consequatur inventore neque aut voluptas, rem debitis quisquam
79 | perferendis odit officia. Deserunt.
80 |
81 |
82 |
83 |
84 |
85 |
86 |
90 |
91 | Best feature everyday
92 |
93 |
94 |
95 | Lorem ipsum dolor, sit amet consectetur adipisicing elit.
96 | Consequatur inventore neque aut voluptas, rem debitis quisquam
97 | perferendis odit officia. Deserunt.
98 |
99 |
100 |
101 |
102 |
103 |
104 |
108 |
109 | Best feature everyday
110 |
111 |
112 |
113 | Lorem ipsum dolor, sit amet consectetur adipisicing elit.
114 | Consequatur inventore neque aut voluptas, rem debitis quisquam
115 | perferendis odit officia. Deserunt.
116 |
117 |
118 |
119 |
120 |
121 | )
122 | }
123 |
--------------------------------------------------------------------------------
/app/routes/dashboard/plans/route.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react"
2 | import { redirect } from "@remix-run/node"
3 | import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"
4 | import { Form, useLoaderData } from "@remix-run/react"
5 |
6 | import { getformattedCurrency } from "@/lib/utils"
7 | import { authenticator } from "@/services/auth.server"
8 | import { createCheckoutSession } from "@/models/checkout"
9 | import { getAllPlans, getPlanByIdWithPrices } from "@/models/plan"
10 | import { getSubscriptionByUserId } from "@/models/subscription"
11 | import { getUserById } from "@/models/user"
12 | import { getUserCurrencyFromRequest } from "@/utils/currency"
13 | import {
14 | CTAContainer,
15 | FeaturedBadgeContainer,
16 | FeatureListContainer,
17 | PricingCard,
18 | } from "@/components/pricing/containers"
19 | import type { FeatureType } from "@/components/pricing/feature"
20 | import {
21 | Feature,
22 | FeatureDescription,
23 | FeaturePrice,
24 | FeatureTitle,
25 | } from "@/components/pricing/feature"
26 | import { PricingSwitch } from "@/components/pricing/pricing-switch"
27 | import { Button } from "@/components/ui/button"
28 |
29 | // TODO: to be discussed with Keyur
30 | declare global {
31 | interface BigInt {
32 | toJSON(): string
33 | }
34 | }
35 |
36 | BigInt.prototype.toJSON = function () {
37 | return this.toString()
38 | }
39 |
40 | export const loader = async ({ request }: LoaderFunctionArgs) => {
41 | const session = await authenticator.isAuthenticated(request, {
42 | failureRedirect: "/login",
43 | })
44 |
45 | const subscription = await getSubscriptionByUserId(session.id)
46 |
47 | const defaultCurrency = getUserCurrencyFromRequest(request)
48 |
49 | let plans = await getAllPlans()
50 |
51 | plans = plans
52 | .map((plan) => {
53 | return {
54 | ...plan,
55 | prices: plan.prices
56 | .filter((price) => price.currency === defaultCurrency)
57 | .map((price) => ({
58 | ...price,
59 | amount: price.amount / 100,
60 | })),
61 | }
62 | })
63 | .sort((a, b) => a.prices[0].amount - b.prices[0].amount)
64 |
65 | return {
66 | plans,
67 | subscription,
68 | defaultCurrency,
69 | }
70 | }
71 |
72 | export const action = async ({ request }: ActionFunctionArgs) => {
73 | const formData = await request.formData()
74 | const planId = formData.get("planId")
75 | const interval = formData.get("interval") as "month" | "year"
76 | const currency = formData.get("currency") as string
77 |
78 | const session = await authenticator.isAuthenticated(request, {
79 | failureRedirect: "/login",
80 | })
81 |
82 | if (!planId || !interval) {
83 | return redirect("/dashboard/plans")
84 | }
85 |
86 | const dbPlan = await getPlanByIdWithPrices(planId as string)
87 |
88 | if (!dbPlan) {
89 | return redirect("/dashboard/plans")
90 | }
91 |
92 | const user = await getUserById(session.id)
93 |
94 | const price = dbPlan.prices.find(
95 | (p) => p.interval === interval && p.currency === currency
96 | )
97 |
98 | if (!price) {
99 | return redirect("/dashboard/plans")
100 | }
101 |
102 | const checkout = await createCheckoutSession(
103 | user?.customerId as string,
104 | price.stripePriceId,
105 | `${process.env.HOST_URL}/dashboard/plans`,
106 | `${process.env.HOST_URL}/dashboard/plans`
107 | )
108 |
109 | if (!checkout) {
110 | return redirect("/dashboard/plans")
111 | }
112 |
113 | return redirect(checkout.url as string)
114 | }
115 |
116 | export default function PlansPage() {
117 | const { plans, subscription, defaultCurrency } =
118 | useLoaderData()
119 | const [interval, setInterval] = useState<"month" | "year">("month")
120 | // render shadcn ui pricing table using Card
121 |
122 | return (
123 |
124 |
125 |
126 |
127 | Pricing Plans
128 |
129 |
130 |
131 | {/* TODO: add content here @keyur */}
132 | Lorem, ipsum dolor sit amet consectetur adipisicing elit. Rerum
133 | quisquam, iusto voluptatem dolore voluptas non laboriosam soluta quos
134 | quod eos! Sapiente archit
135 |
136 |
137 |
setInterval(value === "0" ? "month" : "year")}
139 | />
140 |
141 |
142 | {plans.map((plan) => {
143 | const discount = plan.prices[0].amount * 12 - plan.prices[1].amount
144 | const showDiscount =
145 | interval === "year" && plan.prices[0].amount !== 0
146 | const planPrice = plan.prices.find(
147 | (p) => p.currency === defaultCurrency && p.interval == interval
148 | )?.amount as number
149 |
150 | return (
151 |
152 | {showDiscount && discount > 0 && (
153 |
154 | Save {getformattedCurrency(discount, defaultCurrency)}
155 |
156 | )}
157 | {plan.name}
158 | {plan.description}
159 |
163 |
164 | {(plan.listOfFeatures as FeatureType[]).map(
165 | (feature, index) => (
166 |
172 | )
173 | )}
174 |
175 |
176 |
195 |
196 |
197 | )
198 | })}
199 |
200 |
201 |
202 | )
203 | }
204 |
--------------------------------------------------------------------------------
/app/routes/_auth+/reset-password.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from "react"
2 | import { json } from "@remix-run/node"
3 | import type {
4 | ActionFunctionArgs,
5 | LoaderFunctionArgs,
6 | MetaFunction,
7 | } from "@remix-run/node"
8 | import { Form, NavLink, useActionData, useNavigation } from "@remix-run/react"
9 | import { conform, useForm } from "@conform-to/react"
10 | import { parse } from "@conform-to/zod"
11 | import { ReloadIcon } from "@radix-ui/react-icons"
12 | import { AlertCircle } from "lucide-react"
13 | import { AuthenticityTokenInput } from "remix-utils/csrf/react"
14 | import { z } from "zod"
15 |
16 | import { validatePasswordResetToken } from "@/lib/server/auth-utils.sever"
17 | import { validateCsrfToken } from "@/lib/server/csrf.server"
18 | import { mergeMeta } from "@/lib/server/seo/seo-helpers"
19 | import { authenticator, hash } from "@/services/auth.server"
20 | import { prisma } from "@/services/db/db.server"
21 | import { CommonErrorBoundary } from "@/components/error-boundry"
22 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
23 | import { Button } from "@/components/ui/button"
24 | import { Input } from "@/components/ui/input"
25 | import { Label } from "@/components/ui/label"
26 |
27 | const schema = z
28 | .object({
29 | password: z.string().min(8, "Password must be at least 8 characters"),
30 | confirmPassword: z
31 | .string()
32 | .min(8, "Password must be at least 8 characters"),
33 | })
34 | .refine((data) => data.password === data.confirmPassword, {
35 | message: "Please make sure your passwords match",
36 | path: ["confirmPassword"],
37 | })
38 |
39 | export async function loader({ request }: LoaderFunctionArgs) {
40 | // If the user is already authenticated redirect to /dashboard directly
41 | return await authenticator.isAuthenticated(request, {
42 | successRedirect: "/dashboard",
43 | })
44 | }
45 |
46 | export const meta: MetaFunction = mergeMeta(
47 | // these will override the parent meta
48 | () => {
49 | return [{ title: "Reset Password" }]
50 | }
51 | )
52 |
53 | export const action = async ({ request }: ActionFunctionArgs) => {
54 | await validateCsrfToken(request)
55 | const url = new URL(request.url)
56 |
57 | const code = url.searchParams.get("code")
58 |
59 | const formData = await request.formData()
60 |
61 | const submission = await parse(formData, {
62 | schema,
63 | })
64 |
65 | if (!code) {
66 | return json({ ...submission, isLinkExpired: true, resetSuccess: false })
67 | }
68 |
69 | if (!submission.value || submission.intent !== "submit") {
70 | return json({ ...submission, isLinkExpired: false, resetSuccess: false })
71 | }
72 |
73 | // TODO: check naming conventions
74 | const resetToken = await prisma.passwordResetToken.findFirst({
75 | // TODO: confirm if we should keep it code or rename to token
76 | where: {
77 | token: code,
78 | },
79 | })
80 |
81 | if (resetToken) {
82 | try {
83 | const userId = await validatePasswordResetToken(resetToken?.token)
84 |
85 | const hashedPassword = await hash(submission.value.password)
86 |
87 | await prisma.user.update({
88 | where: {
89 | id: userId,
90 | },
91 | data: {
92 | password: hashedPassword,
93 | },
94 | })
95 |
96 | return json({ ...submission, isLinkExpired: false, resetSuccess: true })
97 | } catch (error) {
98 | return json({ ...submission, isLinkExpired: true, resetSuccess: false })
99 | }
100 | } else {
101 | return json({ ...submission, isLinkExpired: true, resetSuccess: false })
102 | }
103 | }
104 |
105 | export default function ForgotPassword() {
106 | const navigation = useNavigation()
107 | const isFormSubmitting = navigation.state === "submitting"
108 | const lastSubmission = useActionData()
109 | const id = useId()
110 |
111 | const [form, { password, confirmPassword }] = useForm({
112 | id,
113 | lastSubmission,
114 | shouldValidate: "onBlur",
115 | shouldRevalidate: "onInput",
116 | onValidate({ formData }) {
117 | return parse(formData, { schema })
118 | },
119 | })
120 |
121 | if (lastSubmission?.isLinkExpired) {
122 | return (
123 | <>
124 |
125 | Reset Link expired
126 |
127 |
128 |
129 |
130 | Error
131 |
132 | Reset link has expired please request new one{" "}
133 |
134 | Request new link
135 |
136 |
137 |
138 |
139 | >
140 | )
141 | }
142 | if (!lastSubmission?.isLinkExpired && !lastSubmission?.resetSuccess) {
143 | return (
144 | <>
145 |
146 | Reset password
147 |
148 |
191 | >
192 | )
193 | }
194 |
195 | if (lastSubmission?.resetSuccess) {
196 | return (
197 | <>
198 |
199 | Reset Successful
200 |
201 |
202 |
203 |
204 | Success
205 |
206 | Your password has been reset successfully.{" "}
207 |
208 | Login
209 |
210 |
211 |
212 |
213 | >
214 | )
215 | }
216 | }
217 |
218 | export function ErrorBoundary() {
219 | return
220 | }
221 |
--------------------------------------------------------------------------------
/app/routes/_auth+/login.tsx:
--------------------------------------------------------------------------------
1 | import { useId } from "react"
2 | import {
3 | json,
4 | redirect,
5 | type ActionFunctionArgs,
6 | type LoaderFunctionArgs,
7 | } from "@remix-run/node"
8 | import type { MetaFunction } from "@remix-run/react"
9 | import { Form, NavLink, useActionData, useNavigation } from "@remix-run/react"
10 | import { conform, useForm } from "@conform-to/react"
11 | import { parse } from "@conform-to/zod"
12 | import { ReloadIcon } from "@radix-ui/react-icons"
13 | import { AuthenticityTokenInput } from "remix-utils/csrf/react"
14 | import { z } from "zod"
15 |
16 | import GoogleLogo from "@/lib/assets/logos/google"
17 | import { sendVerificationCode } from "@/lib/server/auth-utils.sever"
18 | import { validateCsrfToken } from "@/lib/server/csrf.server"
19 | import { mergeMeta } from "@/lib/server/seo/seo-helpers"
20 | import { authenticator } from "@/services/auth.server"
21 | import { prisma } from "@/services/db/db.server"
22 | import { commitSession, getSession } from "@/services/session.server"
23 | import { CommonErrorBoundary } from "@/components/error-boundry"
24 | import { Button } from "@/components/ui/button"
25 | import { Input } from "@/components/ui/input"
26 | import { Label } from "@/components/ui/label"
27 |
28 | const schema = z.object({
29 | email: z
30 | .string({ required_error: "Please enter email to continue" })
31 | .email("Please enter a valid email"),
32 | password: z.string().min(8, "Password must be at least 8 characters"),
33 | })
34 |
35 | export async function loader({ request }: LoaderFunctionArgs) {
36 | // If the user is already authenticated redirect to /dashboard directly
37 | return await authenticator.isAuthenticated(request, {
38 | successRedirect: "/dashboard",
39 | })
40 | }
41 |
42 | export const meta: MetaFunction = mergeMeta(
43 | // these will override the parent meta
44 | () => {
45 | return [{ title: "Login" }]
46 | }
47 | )
48 |
49 | export const action = async ({ request }: ActionFunctionArgs) => {
50 | await validateCsrfToken(request)
51 | const clonedRequest = request.clone()
52 | const formData = await clonedRequest.formData()
53 |
54 | const submission = await parse(formData, {
55 | schema: schema.superRefine(async (data, ctx) => {
56 | const existingUser = await prisma.user.findFirst({
57 | where: {
58 | email: data.email,
59 | },
60 | select: { id: true },
61 | })
62 |
63 | if (!existingUser) {
64 | ctx.addIssue({
65 | path: ["email"],
66 | code: z.ZodIssueCode.custom,
67 | message: "Either email or password is incorrect",
68 | })
69 | return
70 | }
71 | }),
72 | async: true,
73 | })
74 |
75 | if (!submission.value || submission.intent !== "submit") {
76 | return json(submission)
77 | }
78 |
79 | try {
80 | const user = await authenticator.authenticate("user-pass", request, {
81 | throwOnError: true,
82 | context: {
83 | ...submission.value,
84 | type: "login",
85 | },
86 | })
87 |
88 | let session = await getSession(request.headers.get("cookie"))
89 | // and store the user data
90 | session.set(authenticator.sessionKey, user)
91 |
92 | let headers = new Headers({ "Set-Cookie": await commitSession(session) })
93 |
94 | // Todo: make redirect config driven e.g add login success route
95 | if (user.emailVerified) {
96 | return redirect("/dashboard", { headers })
97 | }
98 | await sendVerificationCode(user)
99 | return redirect("/verify-email", { headers })
100 | } catch (error) {
101 | // TODO: fix type here
102 | // TODO: create constant for message type of auth errors
103 | const typedError = error as any
104 |
105 | switch (typedError.message) {
106 | case "INVALID_PASSWORD":
107 | return {
108 | ...submission,
109 | error: { email: ["Either email or password is incorrect"] },
110 | }
111 | case "GOOGLE_SIGNUP":
112 | return {
113 | ...submission,
114 | error: {
115 | email: [
116 | "You have already signed up with google. Please use google to login",
117 | ],
118 | },
119 | }
120 | default:
121 | return null
122 | }
123 | }
124 | }
125 |
126 | export default function Login() {
127 | const navigation = useNavigation()
128 | const isFormSubmitting = navigation.state === "submitting"
129 | const isSigningInWithEmail =
130 | isFormSubmitting && navigation.formAction !== "/auth/google"
131 | const isSigningInWithGoogle =
132 | isFormSubmitting && navigation.formAction === "/auth/google"
133 | const lastSubmission = useActionData()
134 | const id = useId()
135 |
136 | const [form, { email, password }] = useForm({
137 | id,
138 | lastSubmission,
139 | shouldValidate: "onBlur",
140 | shouldRevalidate: "onInput",
141 | onValidate({ formData }) {
142 | return parse(formData, { schema })
143 | },
144 | })
145 | return (
146 | <>
147 |
148 | Sign in to your account
149 |
150 |
151 |
194 |
206 |
207 |
208 | Not a member?{" "}
209 |
210 |
211 | Sign up
212 |
213 |
214 |
215 |
216 |
217 | Forgot Password
218 |
219 |
220 |
221 |
222 | >
223 | )
224 | }
225 |
226 | export function ErrorBoundary() {
227 | return
228 | }
229 |
--------------------------------------------------------------------------------
/app/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
3 | import { Check, ChevronRight, Circle } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const DropdownMenu = DropdownMenuPrimitive.Root
8 |
9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
10 |
11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
12 |
13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
14 |
15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
16 |
17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
18 |
19 | const DropdownMenuSubTrigger = React.forwardRef<
20 | React.ElementRef,
21 | React.ComponentPropsWithoutRef & {
22 | inset?: boolean
23 | }
24 | >(({ className, inset, children, ...props }, ref) => (
25 |
34 | {children}
35 |
36 |
37 | ))
38 | DropdownMenuSubTrigger.displayName =
39 | DropdownMenuPrimitive.SubTrigger.displayName
40 |
41 | const DropdownMenuSubContent = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef
44 | >(({ className, ...props }, ref) => (
45 |
53 | ))
54 | DropdownMenuSubContent.displayName =
55 | DropdownMenuPrimitive.SubContent.displayName
56 |
57 | const DropdownMenuContent = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, sideOffset = 4, ...props }, ref) => (
61 |
62 |
71 |
72 | ))
73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
74 |
75 | const DropdownMenuItem = React.forwardRef<
76 | React.ElementRef,
77 | React.ComponentPropsWithoutRef & {
78 | inset?: boolean
79 | }
80 | >(({ className, inset, ...props }, ref) => (
81 |
90 | ))
91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
92 |
93 | const DropdownMenuCheckboxItem = React.forwardRef<
94 | React.ElementRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, children, checked, ...props }, ref) => (
97 |
106 |
107 |
108 |
109 |
110 |
111 | {children}
112 |
113 | ))
114 | DropdownMenuCheckboxItem.displayName =
115 | DropdownMenuPrimitive.CheckboxItem.displayName
116 |
117 | const DropdownMenuRadioItem = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, children, ...props }, ref) => (
121 |
129 |
130 |
131 |
132 |
133 |
134 | {children}
135 |
136 | ))
137 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
138 |
139 | const DropdownMenuLabel = React.forwardRef<
140 | React.ElementRef,
141 | React.ComponentPropsWithoutRef & {
142 | inset?: boolean
143 | }
144 | >(({ className, inset, ...props }, ref) => (
145 |
154 | ))
155 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
156 |
157 | const DropdownMenuSeparator = React.forwardRef<
158 | React.ElementRef,
159 | React.ComponentPropsWithoutRef
160 | >(({ className, ...props }, ref) => (
161 |
166 | ))
167 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
168 |
169 | const DropdownMenuShortcut = ({
170 | className,
171 | ...props
172 | }: React.HTMLAttributes) => {
173 | return (
174 |
178 | )
179 | }
180 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
181 |
182 | export {
183 | DropdownMenu,
184 | DropdownMenuTrigger,
185 | DropdownMenuContent,
186 | DropdownMenuItem,
187 | DropdownMenuCheckboxItem,
188 | DropdownMenuRadioItem,
189 | DropdownMenuLabel,
190 | DropdownMenuSeparator,
191 | DropdownMenuShortcut,
192 | DropdownMenuGroup,
193 | DropdownMenuPortal,
194 | DropdownMenuSub,
195 | DropdownMenuSubContent,
196 | DropdownMenuSubTrigger,
197 | DropdownMenuRadioGroup,
198 | }
199 |
--------------------------------------------------------------------------------
/app/routes/_index/feature-section.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | CheckIcon,
3 | CreditCard,
4 | Database,
5 | Layers2Icon,
6 | MailIcon,
7 | SearchIcon,
8 | User2Icon,
9 | } from "lucide-react"
10 |
11 | export function FeatureSection() {
12 | return (
13 | <>
14 |
15 |
16 |
17 |
18 | All your features in one place
19 |
20 |
21 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Hic
22 | aliquid voluptas saepe maxime asperiores totam fuga assumenda iure
23 | repudiandae. Ab, ipsum vitae!
24 |
25 |
26 |
27 |
28 |
29 |
34 |
35 | Lorem ipsum dolor sit amet.
36 |
37 |
51 |
52 |
53 |
58 |
59 | Second Feature
60 |
61 |
75 |
76 |
77 |
82 |
83 | Third Feature
84 |
85 |
99 |
100 |
101 |
106 |
107 | Fourth Feature
108 |
109 |
110 |
111 |
112 |
Example 1
113 |
114 |
115 |
116 |
Example 2
117 |
118 |
119 |
120 |
Example 3
121 |
122 |
123 |
124 |
125 |
130 |
131 | Fifth Feature
132 |
133 |
134 |
135 |
136 |
Example 1
137 |
138 |
139 |
140 |
Example 2
141 |
142 |
143 |
144 |
Example 3
145 |
146 |
147 |
148 |
149 |
154 |
155 | Sixth Feature
156 |
157 |
158 |
159 |
160 |
Example 1
161 |
162 |
163 |
164 |
Example 2
165 |
166 |
167 |
168 |
Example 3
169 |
170 |
171 |
172 |
173 |
174 |
175 | >
176 | )
177 | }
178 |
--------------------------------------------------------------------------------
/app/lib/brand/logo.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "../utils"
2 |
3 | type Props = React.SVGProps
4 | export const Logo = ({ className, ...props }: Props) => {
5 | return (
6 |
13 |
17 |
21 |
25 |
29 |
33 |
37 |
41 |
45 |
49 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/app/routes/_auth+/verify-email.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | json,
3 | redirect,
4 | type ActionFunctionArgs,
5 | type LoaderFunctionArgs,
6 | } from "@remix-run/node"
7 | import type { MetaFunction } from "@remix-run/react"
8 | import {
9 | Form,
10 | useActionData,
11 | useLoaderData,
12 | useNavigation,
13 | } from "@remix-run/react"
14 | import { ReloadIcon } from "@radix-ui/react-icons"
15 | import { z } from "zod"
16 |
17 | import {
18 | isWithinExpiration,
19 | sendVerificationCode,
20 | } from "@/lib/server/auth-utils.sever"
21 | import { mergeMeta } from "@/lib/server/seo/seo-helpers"
22 | import buildTags from "@/lib/server/seo/seo-utils"
23 | import { authenticator } from "@/services/auth.server"
24 | import { prisma } from "@/services/db/db.server"
25 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
26 | import { Button } from "@/components/ui/button"
27 | import { Input } from "@/components/ui/input"
28 | import { Label } from "@/components/ui/label"
29 |
30 | const requestCodeSchema = z.object({
31 | email: z
32 | .string({ required_error: "Please enter email to continue" })
33 | .email("Please enter a valid email"),
34 | })
35 |
36 | const codeVerificationSchema = z.object({
37 | code: z.string({ required_error: "Please enter a verification code" }),
38 | })
39 |
40 | export async function loader({ request }: LoaderFunctionArgs) {
41 | const user = await authenticator.isAuthenticated(request)
42 |
43 | if (user) {
44 | if (user.emailVerified) {
45 | return redirect("/dashboard")
46 | }
47 |
48 | const result = await prisma.verificationCode.findFirst({
49 | where: {
50 | userId: user.id,
51 | },
52 | include: {
53 | user: true,
54 | },
55 | })
56 |
57 | if (!result) {
58 | return json({
59 | codeAvailableWithUser: false,
60 | email: user.email,
61 | })
62 | }
63 |
64 | if (!isWithinExpiration(result.expires)) {
65 | await prisma.verificationCode.deleteMany({
66 | where: {
67 | userId: user.id,
68 | },
69 | })
70 | return json({
71 | codeAvailableWithUser: false,
72 | email: user.email,
73 | })
74 | }
75 |
76 | return json({
77 | codeAvailableWithUser: true,
78 | email: user.email,
79 | })
80 | }
81 |
82 | return redirect("/login")
83 | }
84 |
85 | export const meta: MetaFunction = mergeMeta(
86 | // these will override the parent meta
87 | () => {
88 | return buildTags({
89 | title: "Verify Email",
90 | description: "Verify your email",
91 | })
92 | }
93 | )
94 |
95 | type FormDataType = {
96 | intent: "requestCode" | "verifyCode"
97 | } & z.infer &
98 | z.infer
99 |
100 | export const action = async ({ request }: ActionFunctionArgs) => {
101 | const clonedRequest = request.clone()
102 | const user = await authenticator.isAuthenticated(request, {
103 | failureRedirect: "/login",
104 | })
105 |
106 | const formData = Object.fromEntries(
107 | await clonedRequest.formData()
108 | ) as unknown as FormDataType
109 |
110 | switch (formData.intent) {
111 | case "verifyCode":
112 | const requestCodeSubmission = await codeVerificationSchema
113 | .superRefine(async (data, ctx) => {
114 | const verificationCode = await prisma.verificationCode.findFirst({
115 | where: {
116 | userId: user.id,
117 | },
118 | })
119 |
120 | if (verificationCode?.code !== formData.code) {
121 | ctx.addIssue({
122 | path: ["code"],
123 | code: z.ZodIssueCode.custom,
124 | message: "Please enter a valid code",
125 | })
126 | return
127 | }
128 | })
129 | .safeParseAsync(formData)
130 |
131 | if (requestCodeSubmission.success) {
132 | const updatedUser = await prisma.user.update({
133 | where: {
134 | id: user.id,
135 | },
136 | data: {
137 | emailVerified: true,
138 | },
139 | })
140 |
141 | await prisma.verificationCode.deleteMany({
142 | where: {
143 | userId: user.id,
144 | },
145 | })
146 | return json({ verified: true })
147 | } else {
148 | return json({
149 | errors: requestCodeSubmission.error.flatten().fieldErrors,
150 | })
151 | }
152 | case "requestCode":
153 | const verifyCodeSubmission = requestCodeSchema.safeParse(formData)
154 | if (verifyCodeSubmission.success) {
155 | await sendVerificationCode(user)
156 | return json({ verified: false })
157 | } else {
158 | return json({
159 | errors: verifyCodeSubmission.error.flatten().fieldErrors,
160 | })
161 | }
162 | default:
163 | break
164 | }
165 | }
166 |
167 | export default function VerifyEmail() {
168 | const navigation = useNavigation()
169 | const data = useLoaderData()
170 | const isFormSubmitting = navigation.state === "submitting"
171 | const actionData = useActionData<{
172 | errors: { code: Array; email: Array }
173 | verified?: boolean
174 | }>()
175 |
176 | const isVerifiying =
177 | navigation.state === "submitting" &&
178 | navigation.formData?.get("intent") === "verifyCode"
179 |
180 | if (actionData?.verified) {
181 | return (
182 |
183 |
184 | Email successfully!
185 |
186 | Email has been verified successfully!
187 |
188 |
189 |
190 | )
191 | }
192 |
193 | if (data.codeAvailableWithUser) {
194 | return (
195 | <>
196 |
197 | Enter a verification code
198 |
199 | {data.codeAvailableWithUser && (
200 |
201 | You must have recieved and email verification code on {data?.email}
202 |
203 | )}
204 |
205 |
229 |
230 |
231 | Did not recieve code?
232 |
252 |
253 |
254 |
255 | >
256 | )
257 | }
258 |
259 | if (!data.codeAvailableWithUser) {
260 | return (
261 |
298 | )
299 | }
300 | }
301 |
--------------------------------------------------------------------------------
/app/routes/_auth+/signup.tsx:
--------------------------------------------------------------------------------
1 | import { useId, useRef } from "react"
2 | import {
3 | json,
4 | type ActionFunctionArgs,
5 | type LoaderFunctionArgs,
6 | } from "@remix-run/node"
7 | import type { MetaFunction } from "@remix-run/react"
8 | import { Form, NavLink, useActionData, useNavigation } from "@remix-run/react"
9 | import type { FieldConfig } from "@conform-to/react"
10 | import { conform, useForm, useInputEvent } from "@conform-to/react"
11 | import { parse } from "@conform-to/zod"
12 | import { ReloadIcon } from "@radix-ui/react-icons"
13 | import { AuthenticityTokenInput } from "remix-utils/csrf/react"
14 | import { z } from "zod"
15 |
16 | import GoogleLogo from "@/lib/assets/logos/google"
17 | import { validateCsrfToken } from "@/lib/server/csrf.server"
18 | import { mergeMeta } from "@/lib/server/seo/seo-helpers"
19 | import { authenticator } from "@/services/auth.server"
20 | import { prisma } from "@/services/db/db.server"
21 | import { CommonErrorBoundary } from "@/components/error-boundry"
22 | import { Button } from "@/components/ui/button"
23 | import { Checkbox } from "@/components/ui/checkbox"
24 | import { Input } from "@/components/ui/input"
25 | import { Label } from "@/components/ui/label"
26 |
27 | export async function loader({ request }: LoaderFunctionArgs) {
28 | // If the user is already authenticated redirect to /dashboard directly
29 | return await authenticator.isAuthenticated(request, {
30 | successRedirect: "/dashboard",
31 | })
32 | }
33 |
34 | export const meta: MetaFunction = mergeMeta(
35 | // these will override the parent meta
36 | () => {
37 | return [{ title: "Sign up" }]
38 | }
39 | )
40 |
41 | const schema = z
42 | .object({
43 | email: z
44 | .string({ required_error: "Please enter email to continue" })
45 | .email("Please enter a valid email"),
46 | fullName: z.string({ required_error: "Please enter name to continue" }),
47 | password: z.string().min(8, "Password must be at least 8 characters"),
48 | confirmPassword: z
49 | .string()
50 | .min(8, "Password must be at least 8 characters"),
51 | tocAccepted: z.literal("on", {
52 | errorMap: () => ({ message: "You must accept the terms & conditions" }),
53 | }),
54 | // tocAccepted: z.string({
55 | // required_error: "You must accept the terms & conditions",
56 | // }),
57 | })
58 | .refine((data) => data.password === data.confirmPassword, {
59 | message: "Please make sure your passwords match",
60 | path: ["confirmPassword"],
61 | })
62 |
63 | export const action = async ({ request }: ActionFunctionArgs) => {
64 | await validateCsrfToken(request)
65 |
66 | const clonedRequest = request.clone()
67 | const formData = await clonedRequest.formData()
68 |
69 | const submission = await parse(formData, {
70 | schema: schema.superRefine(async (data, ctx) => {
71 | const existingUser = await prisma.user.findFirst({
72 | where: {
73 | email: data.email,
74 | },
75 | select: { id: true },
76 | })
77 |
78 | if (existingUser) {
79 | ctx.addIssue({
80 | path: ["email"],
81 | code: z.ZodIssueCode.custom,
82 | message: "A user with this email already exists",
83 | })
84 | return
85 | }
86 | }),
87 | async: true,
88 | })
89 |
90 | if (!submission.value || submission.intent !== "submit") {
91 | return json(submission)
92 | }
93 |
94 | return authenticator.authenticate("user-pass", request, {
95 | successRedirect: "/verify-email",
96 | throwOnError: true,
97 | context: { ...submission.value, type: "signup", tocAccepted: true },
98 | })
99 | }
100 |
101 | export default function Signup() {
102 | const navigation = useNavigation()
103 | const isFormSubmitting = navigation.state === "submitting"
104 | const isSigningUpWithEmail =
105 | isFormSubmitting && navigation.formAction !== "/auth/google"
106 | const isSigningUpWithGoogle =
107 | isFormSubmitting && navigation.formAction === "/auth/google"
108 | const lastSubmission = useActionData()
109 | const id = useId()
110 |
111 | const [form, { email, fullName, password, confirmPassword, tocAccepted }] =
112 | useForm({
113 | id,
114 | lastSubmission,
115 | shouldValidate: "onBlur",
116 | shouldRevalidate: "onInput",
117 | onValidate({ formData }) {
118 | return parse(formData, { schema })
119 | },
120 | })
121 |
122 | return (
123 | <>
124 |
125 | Create new account
126 |
127 |
128 |
213 |
225 |
226 |
227 |
228 | Already a member?{" "}
229 |
230 |
231 | Sign in
232 |
233 |
234 |
235 |
236 |
237 | >
238 | )
239 | }
240 |
241 | function CustomCheckbox({
242 | label,
243 | ...config
244 | }: FieldConfig & { label: string }) {
245 | const shadowInputRef = useRef(null)
246 | const control = useInputEvent({
247 | ref: shadowInputRef,
248 | })
249 | // The type of the ref might be different depends on the UI library
250 | const customInputRef = useRef(null)
251 |
252 | return (
253 |
254 |
261 | customInputRef.current?.focus()}
268 | />
269 |
273 | Accept terms and conditions
274 |
275 |
276 | )
277 | }
278 |
279 | export function ErrorBoundary() {
280 | return
281 | }
282 |
--------------------------------------------------------------------------------