From 4fe4e46740ba382d4beeb1d37cd640760d40720a Mon Sep 17 00:00:00 2001 From: Asher Gomez Date: Thu, 9 Mar 2023 15:56:43 +1100 Subject: [PATCH 0001/1451] initial commit --- .example.env | 4 + .github/workflows/ci.yml | 24 ++ .gitignore | 2 + .vscode/settings.json | 6 + README.md | 50 +++ components/AuthForm.tsx | 30 ++ components/Button.tsx | 14 + components/DashboardLayout.tsx | 68 ++++ components/Footer.tsx | 25 ++ components/Head.tsx | 17 + components/Header.tsx | 25 ++ components/Input.tsx | 15 + components/Layout.tsx | 17 + components/Nav.tsx | 18 + components/Notice.tsx | 14 + constants.ts | 20 ++ deno.json | 10 + dev.ts | 8 + fresh.gen.ts | 50 +++ import_map.json | 18 + islands/TodoList.tsx | 112 ++++++ main.ts | 13 + routes/_404.tsx | 15 + routes/_500.tsx | 17 + routes/_middleware.ts | 14 + routes/api/login.ts | 31 ++ routes/api/logout.ts | 13 + routes/api/signup.ts | 49 +++ routes/api/todo.ts | 33 ++ routes/dashboard/_middleware.ts | 29 ++ routes/dashboard/account.tsx | 59 ++++ routes/dashboard/index.tsx | 12 + routes/dashboard/todos.tsx | 63 ++++ routes/index.tsx | 184 ++++++++++ routes/index2.tsx | 139 ++++++++ routes/login.tsx | 30 ++ routes/logout.ts | 12 + routes/signup.tsx | 30 ++ static/.DS_Store | Bin 0 -> 6148 bytes static/brad.png | Bin 0 -> 5570 bytes static/hero-dark.svg | 595 ++++++++++++++++++++++++++++++++ static/hero-light.svg | 133 +++++++ static/pricing.svg | 258 ++++++++++++++ static/transition.svg | 3 + supabase/.gitignore | 3 + supabase/config.toml | 72 ++++ supabase/seed.sql | 0 twind.config.ts | 5 + utils/stripe.ts | 7 + utils/supabase.ts | 33 ++ utils/supabase_types.ts | 46 +++ utils/todos.ts | 31 ++ 52 files changed, 2476 insertions(+) create mode 100644 .example.env create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 components/AuthForm.tsx create mode 100644 components/Button.tsx create mode 100644 components/DashboardLayout.tsx create mode 100644 components/Footer.tsx create mode 100644 components/Head.tsx create mode 100644 components/Header.tsx create mode 100644 components/Input.tsx create mode 100644 components/Layout.tsx create mode 100644 components/Nav.tsx create mode 100644 components/Notice.tsx create mode 100644 constants.ts create mode 100644 deno.json create mode 100755 dev.ts create mode 100644 fresh.gen.ts create mode 100644 import_map.json create mode 100644 islands/TodoList.tsx create mode 100644 main.ts create mode 100644 routes/_404.tsx create mode 100644 routes/_500.tsx create mode 100644 routes/_middleware.ts create mode 100644 routes/api/login.ts create mode 100644 routes/api/logout.ts create mode 100644 routes/api/signup.ts create mode 100644 routes/api/todo.ts create mode 100644 routes/dashboard/_middleware.ts create mode 100644 routes/dashboard/account.tsx create mode 100644 routes/dashboard/index.tsx create mode 100644 routes/dashboard/todos.tsx create mode 100644 routes/index.tsx create mode 100644 routes/index2.tsx create mode 100644 routes/login.tsx create mode 100644 routes/logout.ts create mode 100644 routes/signup.tsx create mode 100644 static/.DS_Store create mode 100644 static/brad.png create mode 100644 static/hero-dark.svg create mode 100644 static/hero-light.svg create mode 100644 static/pricing.svg create mode 100644 static/transition.svg create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/seed.sql create mode 100644 twind.config.ts create mode 100644 utils/stripe.ts create mode 100644 utils/supabase.ts create mode 100644 utils/supabase_types.ts create mode 100644 utils/todos.ts diff --git a/.example.env b/.example.env new file mode 100644 index 000000000000..12e5236f548f --- /dev/null +++ b/.example.env @@ -0,0 +1,4 @@ +SUPABASE_ANON_KEY=xxx +SUPABASE_URL=https://xxx.supabase.co + +STRIPE_API_KEY=xxx \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000000..4e12ff8ac80f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - name: Setup repo + uses: actions/checkout@v3 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + + - name: Verify formatting + run: deno fmt --check + + - name: Run linter + run: deno lint \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000000..c91f38412061 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +deno.lock \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000000..6f4c05388568 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "deno.enable": true, + "deno.lint": true, + "editor.formatOnSave": true, + "editor.defaultFormatter": "denoland.vscode-deno" +} diff --git a/README.md b/README.md new file mode 100644 index 000000000000..28327c2180fe --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# fresh project + +### Usage + +Start the project: + +``` +deno task start +``` + +This will watch the project directory and restart as necessary. + +## Setup Environmental Variables + +Copy Supabase and Stripe credentials to `.env` same as `.example.env`. + +## Setup Supabase + +1. Create the todos table: + 1. Go to `Databases` > `Tables` + 1. Click `New Table` + 1. Enter the name as `todos` and check `Enable Row Level Security (RLS)` + 1. Configure the following columns: + +| Name | Type | Default value | Primary | +| --------- | ------ | ------------- | ------- | +| `id` | `int8` | -- | `true` | +| `name` | `text` | (empty) | `false` | +| `user_id` | `uuid` | `uid()` | `false` | + +1. Setup authentication: + 1. Go to `Authentication` > `Providers` > `Email` + 1. Disable `Confirm email` + 1. Go to `Authentication` > `Policies` + 1. Click `New Policy` and then `Create a policy from scratch` + 1. Enter the policy name as + `Enable all operations for users based on user_id` + 1. Enter the `USING expression` as `(uid() = user_id)` + +## Setup Stripe (taken from Vercel's Subscription Starter) + +1. Set your custom branding in the + [settings](https://dashboard.stripe.com/settings/branding) +1. Configure the Customer Portal + [settings](https://dashboard.stripe.com/test/settings/billing/portal) +1. Toggle on "Allow customers to update their payment methods" +1. Toggle on "Allow customers to update subscriptions" +1. Toggle on "Allow customers to cancel subscriptions" +1. Add the products and prices that you want +1. Set up the required business information and links diff --git a/components/AuthForm.tsx b/components/AuthForm.tsx new file mode 100644 index 000000000000..594f8e279327 --- /dev/null +++ b/components/AuthForm.tsx @@ -0,0 +1,30 @@ +import Input from "./Input.tsx"; +import Button from "./Button.tsx"; + +interface AuthFormProps { + type: "Login" | "Signup"; +} + +export default function AuthForm({ type }: AuthFormProps) { + return ( +
+ + + +
+ ); +} diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 000000000000..e101f0303a93 --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,14 @@ +import { JSX } from "preact"; + +export default function Button( + props: JSX.HTMLAttributes, +) { + return ( + + + + ); +} diff --git a/main.ts b/main.ts new file mode 100644 index 000000000000..bb00964d6a20 --- /dev/null +++ b/main.ts @@ -0,0 +1,13 @@ +/// +/// +/// +/// +/// + +import { start } from "$fresh/server.ts"; +import manifest from "./fresh.gen.ts"; + +import twindPlugin from "$fresh/plugins/twind.ts"; +import twindConfig from "./twind.config.ts"; + +await start(manifest, { plugins: [twindPlugin(twindConfig)] }); diff --git a/routes/_404.tsx b/routes/_404.tsx new file mode 100644 index 000000000000..d5dbe920068d --- /dev/null +++ b/routes/_404.tsx @@ -0,0 +1,15 @@ +import Head from "@/components/Head.tsx"; + +export default function NotFoundPage() { + return ( + <> + +
+

Page not found

+

+ Return home +

+
+ + ); +} diff --git a/routes/_500.tsx b/routes/_500.tsx new file mode 100644 index 000000000000..204ea82f6f9e --- /dev/null +++ b/routes/_500.tsx @@ -0,0 +1,17 @@ +import Head from "@/components/Head.tsx"; +import { ErrorPageProps } from "$fresh/server.ts"; + +export default function Error500Page(props: ErrorPageProps) { + return ( + <> + +
+

Server error

+

500 internal error: {(props.error as Error).message}

+

+ Return home +

+
+ + ); +} diff --git a/routes/_middleware.ts b/routes/_middleware.ts new file mode 100644 index 000000000000..8503ef66429f --- /dev/null +++ b/routes/_middleware.ts @@ -0,0 +1,14 @@ +import { MiddlewareHandlerContext } from "$fresh/server.ts"; +import { hasSupabaseAuthToken } from "@/utils/supabase.ts"; + +export interface State { + isLoggedIn: boolean; +} + +export async function handler( + request: Request, + ctx: MiddlewareHandlerContext, +) { + ctx.state.isLoggedIn = hasSupabaseAuthToken(request.headers); + return await ctx.next(); +} diff --git a/routes/api/login.ts b/routes/api/login.ts new file mode 100644 index 000000000000..426e57f4c81e --- /dev/null +++ b/routes/api/login.ts @@ -0,0 +1,31 @@ +import type { Handlers } from "$fresh/server.ts"; +import { createSupabaseClient } from "@/utils/supabase.ts"; +import { assert } from "std/testing/asserts.ts"; + +export const handler: Handlers = { + async POST(request) { + const form = await request.formData(); + const email = form.get("email"); + const password = form.get("password"); + + assert(typeof email === "string"); + assert(typeof password === "string"); + + const headers = new Headers(); + const supabaseClient = createSupabaseClient(request.headers, headers); + const { error } = await supabaseClient.auth.signInWithPassword({ + email, + password, + }); + + let redirectUrl = new URL(request.url).searchParams.get("redirect_url") ?? + "/"; + if (error) { + redirectUrl = `/login?error=${encodeURIComponent(error.message)}`; + } + + headers.set("location", redirectUrl); + + return new Response(null, { headers, status: 302 }); + }, +}; diff --git a/routes/api/logout.ts b/routes/api/logout.ts new file mode 100644 index 000000000000..d2801fd00e8e --- /dev/null +++ b/routes/api/logout.ts @@ -0,0 +1,13 @@ +import type { Handlers } from "$fresh/server.ts"; +import { createSupabaseClient } from "@/utils/supabase.ts"; + +export const handler: Handlers = { + async GET(request) { + const headers = new Headers({ location: "/" }); + const supabaseClient = createSupabaseClient(request.headers, headers); + const { error } = await supabaseClient.auth.signOut(); + if (error) throw error; + + return new Response(null, { headers, status: 302 }); + }, +}; diff --git a/routes/api/signup.ts b/routes/api/signup.ts new file mode 100644 index 000000000000..532e63ebd45c --- /dev/null +++ b/routes/api/signup.ts @@ -0,0 +1,49 @@ +import type { Handlers } from "$fresh/server.ts"; +import { STRIPE_PRICES } from "@/constants.ts"; +import { stripe } from "@/utils/stripe.ts"; +import { createSupabaseClient } from "@/utils/supabase.ts"; +import { assert } from "std/testing/asserts.ts"; + +export const handler: Handlers = { + async POST(request) { + const form = await request.formData(); + const email = form.get("email"); + const password = form.get("password"); + + assert(typeof email === "string"); + assert(typeof password === "string"); + + const headers = new Headers(); + const supabaseClient = createSupabaseClient(request.headers, headers); + + // 1. Create Stripe customer with the given email + const { id } = await stripe.customers.create({ email }); + + // 2. Get the URL for the initial Stripe billing session to redirect the user to + const { url } = await stripe.checkout.sessions.create({ + success_url: new URL(request.url).origin + "/todos", + customer: id, + line_items: [ + { + price: STRIPE_PRICES[0], + quantity: 1, + }, + ], + mode: "subscription", + }); + + // 3. Signup the Supabse user with the Stripe customer ID as metadata + const { error } = await supabaseClient.auth.signUp({ + email, + password, + options: { data: { stripe_customer_id: id } }, + }); + + const redirectUrl = error + ? `/signup?error=${encodeURIComponent(error.message)}` + : url!; + headers.set("location", redirectUrl); + + return new Response(null, { headers, status: 302 }); + }, +}; diff --git a/routes/api/todo.ts b/routes/api/todo.ts new file mode 100644 index 000000000000..97988ff55a85 --- /dev/null +++ b/routes/api/todo.ts @@ -0,0 +1,33 @@ +import type { Handlers } from "$fresh/server.ts"; +import { AuthError } from "@supabase/supabase-js"; +import { createSupabaseClient } from "@/utils/supabase.ts"; +import { createTodo, deleteTodo } from "@/utils/todos.ts"; + +export const handler: Handlers = { + async POST(request) { + try { + const supabaseClient = createSupabaseClient(request.headers); + const todo = await request.json(); + await createTodo(supabaseClient, todo); + + return Response.json(null, { status: 201 }); + } catch (error) { + const status = error instanceof AuthError ? 401 : 400; + + return new Response(error, { status }); + } + }, + async DELETE(request) { + try { + const supabaseClient = createSupabaseClient(request.headers); + const { id } = await request.json(); + await deleteTodo(supabaseClient, id); + + return new Response(null, { status: 202 }); + } catch (error) { + const status = error instanceof AuthError ? 401 : 400; + + return new Response(error, { status }); + } + }, +}; diff --git a/routes/dashboard/_middleware.ts b/routes/dashboard/_middleware.ts new file mode 100644 index 000000000000..e182a3813f0a --- /dev/null +++ b/routes/dashboard/_middleware.ts @@ -0,0 +1,29 @@ +import { MiddlewareHandlerContext } from "$fresh/server.ts"; +import { createSupabaseClient } from "@/utils/supabase.ts"; +import type { State } from "@/routes/_middleware.ts"; + +export interface DashboardState extends State { + supabaseClient: ReturnType; +} + +export function getLoginPath(redirectUrl: string) { + const params = new URLSearchParams({ redirect_url: redirectUrl }); + return `/login?${params}`; +} + +export async function handler( + request: Request, + ctx: MiddlewareHandlerContext, +) { + if (ctx.state.isLoggedIn) { + ctx.state.supabaseClient = createSupabaseClient(request.headers); + return await ctx.next(); + } + + return new Response(null, { + status: 302, + headers: { + location: getLoginPath(request.url), + }, + }); +} diff --git a/routes/dashboard/account.tsx b/routes/dashboard/account.tsx new file mode 100644 index 000000000000..f1d63f7274df --- /dev/null +++ b/routes/dashboard/account.tsx @@ -0,0 +1,59 @@ +import type { Handlers, PageProps } from "$fresh/server.ts"; +import Head from "@/components/Head.tsx"; +import { stripe } from "@/utils/stripe.ts"; +import { Stripe } from "stripe"; +import type { User } from "@supabase/supabase-js"; +import type { DashboardState } from "./_middleware.ts"; +import DashboardLayout from "@/components/DashboardLayout.tsx"; + +interface AccountPageData { + user: User; + billingSession: Stripe.Response; +} + +export const handler: Handlers = { + async GET(request, ctx) { + const { data: { user }, error } = await ctx.state.supabaseClient.auth + .getUser(); + if (error) throw error; + + const customer = user!.user_metadata.stripe_customer_id; + const returnUrl = new URL(request.url).href; + + const billingSession = await stripe.billingPortal.sessions.create({ + customer, + return_url: returnUrl, + }); + + return await ctx.render({ + user: user!, + billingSession, + }); + }, +}; + +export default function AccountPage(props: PageProps) { + return ( + <> + + + + + + ); +} diff --git a/routes/dashboard/index.tsx b/routes/dashboard/index.tsx new file mode 100644 index 000000000000..75cc0cf0f685 --- /dev/null +++ b/routes/dashboard/index.tsx @@ -0,0 +1,12 @@ +import type { Handlers } from "$fresh/server.ts"; + +export const handler: Handlers = { + GET() { + return new Response(null, { + status: 302, + headers: { + location: "/dashboard/todos", + }, + }); + }, +}; diff --git a/routes/dashboard/todos.tsx b/routes/dashboard/todos.tsx new file mode 100644 index 000000000000..4ba87f6b88e2 --- /dev/null +++ b/routes/dashboard/todos.tsx @@ -0,0 +1,63 @@ +import type { Handlers, PageProps } from "$fresh/server.ts"; +import { getTodos, type Todo } from "@/utils/todos.ts"; +import { stripe } from "@/utils/stripe.ts"; +import Head from "@/components/Head.tsx"; +import TodoList from "@/islands/TodoList.tsx"; +import Notice from "@/components/Notice.tsx"; +import { DashboardState } from "./_middleware.ts"; +import DashboardLayout from "@/components/DashboardLayout.tsx"; + +interface Data { + isPaid: boolean; + todos: Todo[]; +} + +export const handler: Handlers = { + async GET(_request, ctx) { + if (ctx.state.isLoggedIn) { + const { data: { user }, error } = await ctx.state.supabaseClient.auth + .getUser(); + if (error) throw error; + + const { data: [subscription] } = await stripe.subscriptions.list({ + customer: user!.user_metadata.stripe_customer_id, + }); + + return await ctx.render({ + isPaid: subscription.plan.amount > 0, + todos: await getTodos(ctx.state.supabaseClient), + }); + } + + return await ctx.render({ + isPaid: false, + todos: [], + }); + }, +}; + +export default function TodosPage(props: PageProps) { + return ( + <> + + + {!props.data.isPaid && ( + + You are on a free subscription. Please{" "} + upgrade{" "} + to enable unlimited todos + + } + /> + )} + + + + ); +} diff --git a/routes/index.tsx b/routes/index.tsx new file mode 100644 index 000000000000..03971a21a025 --- /dev/null +++ b/routes/index.tsx @@ -0,0 +1,184 @@ +import Button from "@/components/Button.tsx"; +import Head from "@/components/Head.tsx"; +import Header from "@/components/Header.tsx"; +import Footer from "@/components/Footer.tsx"; +import IconListDetails from "tabler-icons/list-details.tsx"; +import IconCheckbox from "tabler-icons/checkbox.tsx"; +import IconPrompt from "tabler-icons/prompt.tsx"; + +interface HeadingProps { + title: string; + subtitle?: string; +} + +function Heading(props: HeadingProps) { + return ( +
+

+ {props.title} +

+

+ {props.subtitle} +

+
+ ); +} + +function Hero() { + return ( +
+

+ Your SaaS here. +

+

+ Some details about your SaaS. +

+ +
+ ); +} + +function TopSection() { + return ( +
+
+ +
+ ); +} + +function FeaturesSection() { + const features = [ + { + icon: IconListDetails, + title: "First feature", + description: "A little description here.", + }, + { + icon: IconCheckbox, + title: "Second feature", + description: "A little description here.", + }, + { + icon: IconPrompt, + title: "Third feature", + description: "A little description here.", + }, + ]; + + return ( + <> +
+
+
+ {features.map((feature) => ( +
+ +

+ {feature.title} +

+

{feature.description}

+
+ ))} +
+
+
+
+
+ + ); +} + +function PricingSection() { + const points = [ + "Landing Page", + "Subscription Billing (via Stripe)", + "Customer Portal", + "User Authentication", + "SEO Friendly", + "Production-Ready", + "Mobile Friendly", + ]; + + return ( +
+ +
+ Pricing image +
+ {points.map((point) =>

{point}

)} +
+
+
+ ); +} + +function TestimonialSection() { + return ( +
+ +
+ Brad, CEO of Good Things +

"This app is a game changer."

+
+

+ Brad +

+

CEO of Good Things

+
+
+
+ ); +} + +function BottomSection() { + return ( +
+
+
+
+
+ ); +} + +export default function LandingPage() { + return ( + <> + + +
+ + + + + +
+ + + ); +} diff --git a/routes/index2.tsx b/routes/index2.tsx new file mode 100644 index 000000000000..9510308e8f2c --- /dev/null +++ b/routes/index2.tsx @@ -0,0 +1,139 @@ +import Nav from "@/components/Nav.tsx"; +import Head from "@/components/Head.tsx"; +import { SITE_DESCRIPTION, SITE_NAME } from "@/constants.ts"; +import Button from "@/components/Button.tsx"; +import type { Handlers, PageProps } from "$fresh/server.ts"; +import { stripe } from "@/utils/stripe.ts"; +import Stripe from "stripe"; + +function sortProductsFromLowestPrice(products: Stripe.Product[]) { + return products.sort((productA, productB) => + (productA.default_price as Stripe.Price).unit_amount! - + (productB.default_price as Stripe.Price).unit_amount! + ); +} + +export const handler: Handlers = { + async GET(_, ctx) { + const { data } = await stripe.products.list({ + expand: ["data.default_price"], + active: true, + }); + + return await ctx.render(sortProductsFromLowestPrice(data)); + }, +}; + +function HeaderLogo() { + return {SITE_NAME}; +} + +function Header() { + const navItems = [ + { + href: "/", + inner: , + }, + { + href: "/dashboard", + inner: "Dashboard", + }, + ]; + + return ( +
+
+
+
+ ); +} + +function PricingItem(props: { product: Stripe.Product }) { + return ( +
+
+

+ {props.product.name} +

+

{props.product.description}

+
+

+ ${(props.product.default_price as Stripe.Price).unit_amount! / 100} + {" "}per month +

+ +
+ ); +} + +function Hero(props: { products: Stripe.Product[] }) { + return ( +
+
+

+ {SITE_DESCRIPTION} +

+

Lorem ipsum dolor sit amet, consectetur.

+
+ +
+ {props.products.map((product) => )} +
+
+ ); +} + +function Footer() { + const navItems = [ + { + href: "#", + inner: "GitHub", + }, + ]; + + return ( +
+
+ + {SITE_NAME} + +
+
+ ); +} + +export default function HomePage(props: PageProps) { + return ( + <> + + +
+ +