diff --git a/.eslintrc b/.eslintrc index 00e74b1..5e9d6d8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,74 +1,74 @@ { - "plugins": ["tailwindcss"], - "extends": [ - "@remix-run/eslint-config", - "@remix-run/eslint-config/node", - "plugin:tailwindcss/recommended", - "prettier" - ], - "parserOptions": { - "project": ["./tsconfig.json"] - }, - "settings": { - // Help eslint-plugin-tailwindcss to parse Tailwind classes outside of className - "tailwindcss": { - "callees": ["tw"] - }, - "jest": { - "version": 27 - } - }, - "rules": { - "no-console": "warn", - "arrow-body-style": ["warn", "as-needed"], - // @typescript-eslint - "@typescript-eslint/no-duplicate-imports": "error", - "@typescript-eslint/consistent-type-imports": "error", - "@typescript-eslint/no-unused-vars": [ - "warn", - { - "vars": "all", - "args": "all", - "argsIgnorePattern": "^_", - "destructuredArrayIgnorePattern": "^_", - "ignoreRestSiblings": false - } - ], - //import - "import/no-cycle": "error", - "import/no-unresolved": "error", - "import/no-default-export": "warn", - "import/order": [ - "error", - { - "groups": ["builtin", "external", "internal"], - "pathGroups": [ - { - "pattern": "react", - "group": "external", - "position": "before" - } - ], - "pathGroupsExcludedImportTypes": ["react"], - "newlines-between": "always", - "alphabetize": { - "order": "asc", - "caseInsensitive": true - } - } - ] - }, - "overrides": [ - { - "files": [ - "./app/root.tsx", - "./app/entry.client.tsx", - "./app/entry.server.tsx", - "./app/routes/**/*.tsx" - ], - "rules": { - "import/no-default-export": "off" - } - } - ] + "plugins": ["tailwindcss"], + "extends": [ + "@remix-run/eslint-config", + "@remix-run/eslint-config/node", + "plugin:tailwindcss/recommended", + "prettier" + ], + "parserOptions": { + "project": ["./tsconfig.json"] + }, + "settings": { + // Help eslint-plugin-tailwindcss to parse Tailwind classes outside of className + "tailwindcss": { + "callees": ["tw"] + }, + "jest": { + "version": 27 + } + }, + "rules": { + "no-console": "warn", + "arrow-body-style": ["warn", "as-needed"], + // @typescript-eslint + "@typescript-eslint/no-duplicate-imports": "error", + "@typescript-eslint/consistent-type-imports": "error", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "vars": "all", + "args": "all", + "argsIgnorePattern": "^_", + "destructuredArrayIgnorePattern": "^_", + "ignoreRestSiblings": false + } + ], + //import + "import/no-cycle": "error", + "import/no-unresolved": "error", + "import/no-default-export": "warn", + "import/order": [ + "error", + { + "groups": ["builtin", "external", "internal"], + "pathGroups": [ + { + "pattern": "react", + "group": "external", + "position": "before" + } + ], + "pathGroupsExcludedImportTypes": ["react"], + "newlines-between": "always", + "alphabetize": { + "order": "asc", + "caseInsensitive": true + } + } + ] + }, + "overrides": [ + { + "files": [ + "./app/root.tsx", + "./app/entry.client.tsx", + "./app/entry.server.tsx", + "./app/routes/**/*.tsx" + ], + "rules": { + "import/no-default-export": "off" + } + } + ] } diff --git a/.github/workflows/for-this-stack-repo-only.yml b/.github/workflows/for-this-stack-repo-only.yml index dd6f0ed..23930e8 100644 --- a/.github/workflows/for-this-stack-repo-only.yml +++ b/.github/workflows/for-this-stack-repo-only.yml @@ -1,60 +1,60 @@ name: 🚀 Check Stack on: - push: - branches: - - main - - dev - pull_request: {} + push: + branches: + - main + - dev + pull_request: {} permissions: - actions: write - contents: read + actions: write + contents: read jobs: - lint: - name: ⬣ ESLint - runs-on: ubuntu-latest - steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - - - name: ⬇️ Checkout repo - uses: actions/checkout@v3 - - - name: ⎔ Setup node - uses: actions/setup-node@v3 - with: - node-version: 16 - - - name: 📥 Download deps - uses: bahmutov/npm-install@v1 - with: - useLockFile: false - - - name: ⚙️ Build CSS - run: npm run generate:css - - - name: 🔬 Lint - run: npm run lint - - typecheck: - name: ʦ TypeScript - runs-on: ubuntu-latest - steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - - - name: ⬇️ Checkout repo - uses: actions/checkout@v3 - - - name: ⎔ Setup node - uses: actions/setup-node@v3 - with: - node-version: 16 - - - name: 📥 Download deps - uses: bahmutov/npm-install@v1 - with: - useLockFile: false - - - name: 🔎 Type check - run: npm run typecheck --if-present + lint: + name: ⬣ ESLint + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: ⚙️ Build CSS + run: npm run generate:css + + - name: 🔬 Lint + run: npm run lint + + typecheck: + name: ʦ TypeScript + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: 🔎 Type check + run: npm run typecheck --if-present diff --git a/.gitignore b/.gitignore index b17b197..06a4891 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ node_modules .env .cache -/app/styles/tailwind.css +# lock files +package-lock.json +yarn.lock +pnpm-lock.yaml diff --git a/.prettierrc.cjs b/.prettierrc.cjs new file mode 100644 index 0000000..9d4eec2 --- /dev/null +++ b/.prettierrc.cjs @@ -0,0 +1,5 @@ +/** @type {import("prettier").Options} */ +module.exports = { + tabWidth: 4, + useTabs: true, +}; diff --git a/.vscode/settings.json b/.vscode/settings.json index e2ce37f..cc5fea3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,8 @@ { - "tailwindCSS.experimental.classRegex": [ - ["tw\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"] - ] + "tailwindCSS.experimental.classRegex": [ + ["tw\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"] + ], + "typescript.tsdk": "node_modules/typescript/lib", + "editor.tabSize": 4, + "editor.insertSpaces": false, } diff --git a/Dockerfile b/Dockerfile index 94c8ceb..1623377 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # base node image -FROM node:16-bullseye-slim as base +FROM node:18-bookworm-slim as base # set for base and all layer that inherit from it ENV NODE_ENV production @@ -12,7 +12,7 @@ FROM base as deps WORKDIR /myapp -ADD package.json package-lock.json ./ +ADD package.json ./ RUN npm install --production=false # Setup production node_modules @@ -21,7 +21,7 @@ FROM base as production-deps WORKDIR /myapp COPY --from=deps /myapp/node_modules /myapp/node_modules -ADD package.json package-lock.json ./ +ADD package.json ./ RUN npm prune --production # Build the app diff --git a/README.md b/README.md index 652de20..e0703e6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ ![supa-stripe-stack](https://user-images.githubusercontent.com/20722140/216357731-806840c9-03f0-4ee5-a3cc-f12382b4bc88.png) - > I want to thank and credit [DevXO](https://github.com/dev-xo) for its work on [Stripe Stack](https://github.com/dev-xo/stripe-stack) which helped me a lot to build the webhook part of this stack. Learn more about [Remix Stacks](https://remix.run/stacks). @@ -28,30 +27,30 @@ npx create-remix --template rphlmr/supa-stripe-stack ### Features -- Authentication (email/password) with [Supabase](https://supabase.com/) -- Subscriptions (default: `free`, `tier_1`, `tier_2`) with [Stripe](https://stripe.com/) - - Multi-currency (default: `usd` and `eur`) - - Interval (default: `month` and `year`) -- A taking notes app demo with tier limits on the max number of notes (default: `free` = 2, `tier_1` = 4, `tier_2` = infinite) +- Authentication (email/password) with [Supabase](https://supabase.com/) +- Subscriptions (default: `free`, `tier_1`, `tier_2`) with [Stripe](https://stripe.com/) + - Multi-currency (default: `usd` and `eur`) + - Interval (default: `month` and `year`) +- A taking notes app demo with tier limits on the max number of notes (default: `free` = 2, `tier_1` = 4, `tier_2` = infinite) ### Tools -- [Fly app deployment](https://fly.io) with [Docker](https://www.docker.com/products/docker-desktop/) -- Production-ready [Supabase Database](https://supabase.com/) -- Healthcheck endpoint for [Fly backups region fallbacks](https://fly.io/docs/reference/configuration/#services-http_checks) -- Email/Password Authentication with [cookie-based sessions](https://remix.run/docs/en/v1/api/remix#createcookiesessionstorage) -- Database ORM with [Prisma](https://prisma.io) -- Forms Schema (client and server sides !) validation with [Zod](https://github.com/colinhacks/zod) and [React Zorm](https://github.com/esamattis/react-zorm) -- Styling with [Tailwind](https://tailwindcss.com/) -- Code formatting with [Prettier](https://prettier.io) -- Linting with [ESLint](https://eslint.org) -- Static Types with [TypeScript 4.9](https://typescriptlang.org) +- [Fly app deployment](https://fly.io) with [Docker](https://www.docker.com/products/docker-desktop/) +- Production-ready [Supabase Database](https://supabase.com/) +- Healthcheck endpoint for [Fly backups region fallbacks](https://fly.io/docs/reference/configuration/#services-http_checks) +- Email/Password Authentication with [cookie-based sessions](https://remix.run/docs/en/v1/api/remix#createcookiesessionstorage) +- Database ORM with [Prisma](https://prisma.io) +- Forms Schema (client and server sides !) validation with [Zod](https://github.com/colinhacks/zod) and [React Zorm](https://github.com/esamattis/react-zorm) +- Styling with [Tailwind](https://tailwindcss.com/) +- Code formatting with [Prettier](https://prettier.io) +- Linting with [ESLint](https://eslint.org) +- Static Types with [TypeScript 4.9](https://typescriptlang.org) ## What's not in the stack -- Unit Testing 😶 (will try to add it) -- E2E Testing 😶 (will try to add it with [Playwright](https://playwright.dev/))) -- GitHub Actions +- Unit Testing 😶 (will try to add it) +- E2E Testing 😶 (will try to add it with [Playwright](https://playwright.dev/))) +- GitHub Actions ## Why Supabase? @@ -61,28 +60,28 @@ I love it. ## #1 Supabase project -- Create a [Supabase database](https://supabase.com/) (the free tier gives you 2 databases) +- Create a [Supabase database](https://supabase.com/) (the free tier gives you 2 databases) - > It'll ask you to define the `Database Password`. Save it somewhere, you'll need it later. + > It'll ask you to define the `Database Password`. Save it somewhere, you'll need it later. - ![create_project](https://user-images.githubusercontent.com/20722140/216093400-405916ae-7c30-4aa1-8c73-b41a512f1507.png) + ![create_project](https://user-images.githubusercontent.com/20722140/216093400-405916ae-7c30-4aa1-8c73-b41a512f1507.png) -- Go to https://app.supabase.io/project/{PROJECT}/settings/database to find your database secrets +- Go to https://app.supabase.io/project/{PROJECT}/settings/database to find your database secrets - ![database_secrets](https://user-images.githubusercontent.com/20722140/216097216-f77a56ac-b17e-4031-bd29-ad239639829d.png) + ![database_secrets](https://user-images.githubusercontent.com/20722140/216097216-f77a56ac-b17e-4031-bd29-ad239639829d.png) - - It's time to copy/paste some secrets from this page 👆 into your `.env` file - - `URI` 👉 `DATABASE_URL` - - Replace `[YOUR-PASSWORD]` with your `Database Password` (from the previous step) + - It's time to copy/paste some secrets from this page 👆 into your `.env` file + - `URI` 👉 `DATABASE_URL` + - Replace `[YOUR-PASSWORD]` with your `Database Password` (from the previous step) -- Go to https://app.supabase.io/project/{PROJECT}/settings/api to find your API secrets +- Go to https://app.supabase.io/project/{PROJECT}/settings/api to find your API secrets - ![project_secrets](https://user-images.githubusercontent.com/20722140/216094297-df265aaf-1c50-4dc7-bdd0-14bc8aa00e17.png) + ![project_secrets](https://user-images.githubusercontent.com/20722140/216094297-df265aaf-1c50-4dc7-bdd0-14bc8aa00e17.png) - - It's time to copy/paste some secrets from this page 👆 into your `.env` file - - `URL` 👉 `SUPABASE_URL` - - `anon` `public` 👉 `SUPABASE_ANON_PUBLIC` - - `service_role` `secret` 👉 `SUPABASE_SERVICE_ROLE` + - It's time to copy/paste some secrets from this page 👆 into your `.env` file + - `URL` 👉 `SUPABASE_URL` + - `anon` `public` 👉 `SUPABASE_ANON_PUBLIC` + - `service_role` `secret` 👉 `SUPABASE_SERVICE_ROLE` ## #2 Stripe project @@ -90,21 +89,21 @@ I love it. > This CLI gives you the ability to listen Stripe webhook events and forward them to your local server. -- Create a [Stripe account](https://dashboard.stripe.com/register) -- Go to https://dashboard.stripe.com/test/apikeys +- Create a [Stripe account](https://dashboard.stripe.com/register) +- Go to https://dashboard.stripe.com/test/apikeys - ![stripe_secrets](https://user-images.githubusercontent.com/20722140/216101036-1e94b7fe-29e6-4f34-85eb-9e0f7c0002a4.png) + ![stripe_secrets](https://user-images.githubusercontent.com/20722140/216101036-1e94b7fe-29e6-4f34-85eb-9e0f7c0002a4.png) - - It's time to copy/paste some secrets from this page 👆 into your `.env` file - - `Secret key` 👉 `STRIPE_SECRET_KEY` + - It's time to copy/paste some secrets from this page 👆 into your `.env` file + - `Secret key` 👉 `STRIPE_SECRET_KEY` -- As long as you're here, and let's assume you've installed the Stripe CLI, you can run the following command to start Stripe webhook listener and get your `webhook signing secret` - ```sh - stripe listen --forward-to localhost:3000/api/webhook - ... - > Ready! You are using Stripe API Version [2022-11-15]. Your webhook signing secret is whsec_d7f96cbdb268xxxxxxxxxxxxxxxx - ``` - - `whsec_d7f96cbdb268xxxxxxxxxxxxxxxx` 👉 `STRIPE_ENDPOINT_SECRET` +- As long as you're here, and let's assume you've installed the Stripe CLI, you can run the following command to start Stripe webhook listener and get your `webhook signing secret` + ```sh + stripe listen --forward-to localhost:3000/api/webhook + ... + > Ready! You are using Stripe API Version [2022-11-15]. Your webhook signing secret is whsec_d7f96cbdb268xxxxxxxxxxxxxxxx + ``` + - `whsec_d7f96cbdb268xxxxxxxxxxxxxxxx` 👉 `STRIPE_ENDPOINT_SECRET` ## #3 Fly project @@ -115,18 +114,18 @@ In the meantime, you can look at [my other stack working with Fly](https://githu There are other environment variables you can set in your `.env` file. -- `SERVER_URL`: the URL of your server (`http://localhost:3000` in local env) -- `SESSION_SECRET`: a secret string used to encrypt your session cookie -- `DEFAULT_CURRENCY`: default currency for your Stripe subscriptions if the user currency is not supported. (only used for UI purposes) - > **Note**: - > - > The currency we show on the Pricing page is based on the user locale.See [getDefaultCurrency](app/utils/http.server.ts) - > - > It's not reliable because Stripe checkout will choose a currency based on the user's IP address. - > - > You can implement a better solution by using geo-ip services. - > - > **After the user subscribe, we'll use the currency selected by Stripe**. +- `SERVER_URL`: the URL of your server (`http://localhost:3000` in local env) +- `SESSION_SECRET`: a secret string used to encrypt your session cookie +- `DEFAULT_CURRENCY`: default currency for your Stripe subscriptions if the user currency is not supported. (only used for UI purposes) + > **Note**: + > + > The currency we show on the Pricing page is based on the user locale.See [getDefaultCurrency](app/utils/http.server.ts) + > + > It's not reliable because Stripe checkout will choose a currency based on the user's IP address. + > + > You can implement a better solution by using geo-ip services. + > + > **After the user subscribe, we'll use the currency selected by Stripe**. # How it works diff --git a/app/components/button.tsx b/app/components/button.tsx index aefdb71..af74802 100644 --- a/app/components/button.tsx +++ b/app/components/button.tsx @@ -4,39 +4,39 @@ import { Link } from "@remix-run/react"; import { tw } from "~/utils"; export function Button({ - disabled, - className, - children, + disabled, + className, + children, }: { - disabled?: boolean; - className?: string; - children: React.ReactNode; + disabled?: boolean; + className?: string; + children: React.ReactNode; }) { - return ( - - ); + return ( + + ); } export function ButtonLink({ to, className, children, ...props }: LinkProps) { - return ( - - {children} - - ); + return ( + + {children} + + ); } diff --git a/app/components/time.tsx b/app/components/time.tsx index f82fbfa..511ed30 100644 --- a/app/components/time.tsx +++ b/app/components/time.tsx @@ -1,17 +1,17 @@ import { useLocales } from "~/utils"; export function Time({ date }: { date?: string | null }) { - const { locales, timeZone } = useLocales(); + const { locales, timeZone } = useLocales(); - if (!date) return -; + if (!date) return -; - return ( - - ); + return ( + + ); } diff --git a/app/database/db.server.ts b/app/database/db.server.ts index 852646f..a41fed6 100644 --- a/app/database/db.server.ts +++ b/app/database/db.server.ts @@ -5,8 +5,8 @@ import { NODE_ENV } from "../utils/env"; let db: PrismaClient; declare global { - // eslint-disable-next-line no-var - var __db__: PrismaClient; + // eslint-disable-next-line no-var + var __db__: PrismaClient; } // this is needed because in development we don't want to restart @@ -14,13 +14,13 @@ declare global { // create a new connection to the DB with every change either. // in production, we'll have a single connection to the DB. if (NODE_ENV === "production") { - db = new PrismaClient(); + db = new PrismaClient(); } else { - if (!global.__db__) { - global.__db__ = new PrismaClient(); - } - db = global.__db__; - db.$connect(); + if (!global.__db__) { + global.__db__ = new PrismaClient(); + } + db = global.__db__; + db.$connect(); } export { db }; diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 9e4843b..343b4b6 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -9,24 +9,24 @@ import { LocaleProvider } from "~/utils"; const locales = window.navigator.languages as Locales; const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; document.cookie = `timeZone=${timeZone}; path=/; max-age=${ - 60 * 60 * 24 * 365 + 60 * 60 * 24 * 365 }; secure; samesite=lax`; function hydrate() { - React.startTransition(() => { - hydrateRoot( - document, - - - - - - ); - }); + React.startTransition(() => { + hydrateRoot( + document, + + + + + , + ); + }); } if (window.requestIdleCallback) { - window.requestIdleCallback(hydrate); + window.requestIdleCallback(hydrate); } else { - window.setTimeout(hydrate, 1); + window.setTimeout(hydrate, 1); } diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 09df315..95b1f3e 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -12,48 +12,48 @@ import { LocaleProvider, getCookie, Logger } from "~/utils"; const ABORT_DELAY = 5000; export default function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, ) { - const locales = getClientLocales(request); - const timeZone = getCookie("timeZone", request.headers) || "UTC"; - - const callbackName = isbot(request.headers.get("user-agent")) - ? "onAllReady" - : "onShellReady"; - - return new Promise((resolve, reject) => { - let didError = false; - - const { pipe, abort } = renderToPipeableStream( - - - , - { - [callbackName]() { - const body = new PassThrough(); - - responseHeaders.set("Content-Type", "text/html"); - - resolve( - new Response(body, { - status: didError ? 500 : responseStatusCode, - headers: responseHeaders, - }) - ); - pipe(body); - }, - onShellError(err: unknown) { - reject(err); - }, - onError(error: unknown) { - didError = true; - Logger.error(error); - }, - } - ); - setTimeout(abort, ABORT_DELAY); - }); + const locales = getClientLocales(request); + const timeZone = getCookie("timeZone", request.headers) || "UTC"; + + const callbackName = isbot(request.headers.get("user-agent")) + ? "onAllReady" + : "onShellReady"; + + return new Promise((resolve, reject) => { + let didError = false; + + const { pipe, abort } = renderToPipeableStream( + + + , + { + [callbackName]() { + const body = new PassThrough(); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(body, { + status: didError ? 500 : responseStatusCode, + headers: responseHeaders, + }), + ); + pipe(body); + }, + onShellError(err: unknown) { + reject(err); + }, + onError(error: unknown) { + didError = true; + Logger.error(error); + }, + }, + ); + setTimeout(abort, ABORT_DELAY); + }); } diff --git a/app/hooks/use-interval.ts b/app/hooks/use-interval.ts index 7e8712b..00d7a15 100644 --- a/app/hooks/use-interval.ts +++ b/app/hooks/use-interval.ts @@ -8,21 +8,21 @@ import { useEffect, useRef } from "react"; * @param delay - in milliseconds */ export function useInterval(callback: () => void, delay: number | null) { - const savedCallback = useRef<() => void>(); + const savedCallback = useRef<() => void>(); - // Remember the latest callback. - useEffect(() => { - savedCallback.current = callback; - }, [callback]); + // Remember the latest callback. + useEffect(() => { + savedCallback.current = callback; + }, [callback]); - // Set up the interval. - useEffect(() => { - function tick() { - savedCallback.current?.(); - } - if (delay) { - const id = setInterval(tick, delay); - return () => clearInterval(id); - } - }, [delay]); + // Set up the interval. + useEffect(() => { + function tick() { + savedCallback.current?.(); + } + if (delay) { + const id = setInterval(tick, delay); + return () => clearInterval(id); + } + }, [delay]); } diff --git a/app/integrations/stripe/stripe.server.ts b/app/integrations/stripe/stripe.server.ts index 5dccb86..4a60691 100644 --- a/app/integrations/stripe/stripe.server.ts +++ b/app/integrations/stripe/stripe.server.ts @@ -5,13 +5,13 @@ import { STRIPE_SECRET_KEY } from "~/utils"; let _stripe: Stripe; function getStripeServerClient() { - if (!_stripe) { - // Reference : https://github.com/stripe/stripe-node#usage-with-typescript - _stripe = new Stripe(STRIPE_SECRET_KEY, { - apiVersion: "2022-11-15", - }); - } - return _stripe; + if (!_stripe) { + // Reference : https://github.com/stripe/stripe-node#usage-with-typescript + _stripe = new Stripe(STRIPE_SECRET_KEY, { + apiVersion: "2022-11-15", + }); + } + return _stripe; } export const stripe = getStripeServerClient(); diff --git a/app/integrations/supabase/client.ts b/app/integrations/supabase/client.ts index 89ac52a..abd03b1 100644 --- a/app/integrations/supabase/client.ts +++ b/app/integrations/supabase/client.ts @@ -2,30 +2,30 @@ import { createClient } from "@supabase/supabase-js"; import { SupaStripeStackError } from "~/utils"; import { - SUPABASE_SERVICE_ROLE, - SUPABASE_URL, - SUPABASE_ANON_PUBLIC, + SUPABASE_SERVICE_ROLE, + SUPABASE_URL, + SUPABASE_ANON_PUBLIC, } from "~/utils/env"; import { isBrowser } from "~/utils/is-browser"; function getSupabaseClient(supabaseKey: string, accessToken?: string) { - const global = accessToken - ? { - global: { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - } - : {}; + const global = accessToken + ? { + global: { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + } + : {}; - return createClient(SUPABASE_URL, supabaseKey, { - auth: { - autoRefreshToken: false, - persistSession: false, - }, - ...global, - }); + return createClient(SUPABASE_URL, supabaseKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + ...global, + }); } /** @@ -36,7 +36,7 @@ function getSupabaseClient(supabaseKey: string, accessToken?: string) { * Reason : https://github.com/rphlmr/supa-fly-stack/pull/43#issue-1336412790 */ function getSupabase(accessToken?: string) { - return getSupabaseClient(SUPABASE_ANON_PUBLIC, accessToken); + return getSupabaseClient(SUPABASE_ANON_PUBLIC, accessToken); } /** @@ -47,13 +47,13 @@ function getSupabase(accessToken?: string) { * Reason : https://github.com/rphlmr/supa-fly-stack/pull/43#issue-1336412790 */ function supabaseAdmin() { - if (isBrowser) - throw new SupaStripeStackError({ - message: - "supabaseAdmin is not available in browser and should NOT be used in insecure environments", - }); + if (isBrowser) + throw new SupaStripeStackError({ + message: + "supabaseAdmin is not available in browser and should NOT be used in insecure environments", + }); - return getSupabaseClient(SUPABASE_SERVICE_ROLE); + return getSupabaseClient(SUPABASE_SERVICE_ROLE); } export { supabaseAdmin, getSupabase }; diff --git a/app/modules/auth/index.ts b/app/modules/auth/index.ts index f067c89..645681b 100644 --- a/app/modules/auth/index.ts +++ b/app/modules/auth/index.ts @@ -1,13 +1,13 @@ export { - createEmailAuthAccount, - deleteAuthAccount, - signInWithEmail, - refreshAccessToken, + createEmailAuthAccount, + deleteAuthAccount, + signInWithEmail, + refreshAccessToken, } from "./service.server"; export { - createAuthSession, - destroyAuthSession, - requireAuthSession, - isAnonymousSession, + createAuthSession, + destroyAuthSession, + requireAuthSession, + isAnonymousSession, } from "./session.server"; export * from "./types"; diff --git a/app/modules/auth/mappers.ts b/app/modules/auth/mappers.ts index 1928deb..e24df84 100644 --- a/app/modules/auth/mappers.ts +++ b/app/modules/auth/mappers.ts @@ -4,25 +4,25 @@ import { SupaStripeStackError } from "~/utils"; import type { AuthSession } from "./types"; export function mapAuthSession( - supabaseAuthSession: SupabaseAuthSession + supabaseAuthSession: SupabaseAuthSession, ): AuthSession { - if (!supabaseAuthSession.user?.email) { - throw new SupaStripeStackError({ - message: - "User should have an email. Should not happen because we use email auth.", - metadata: { - userId: supabaseAuthSession.user.id, - }, - tag: "Auth mappers 🔐", - }); - } + if (!supabaseAuthSession.user?.email) { + throw new SupaStripeStackError({ + message: + "User should have an email. Should not happen because we use email auth.", + metadata: { + userId: supabaseAuthSession.user.id, + }, + tag: "Auth mappers 🔐", + }); + } - return { - accessToken: supabaseAuthSession.access_token, - refreshToken: supabaseAuthSession.refresh_token, - userId: supabaseAuthSession.user.id, - email: supabaseAuthSession.user.email, - expiresIn: supabaseAuthSession.expires_in ?? -1, - expiresAt: supabaseAuthSession.expires_at ?? -1, - }; + return { + accessToken: supabaseAuthSession.access_token, + refreshToken: supabaseAuthSession.refresh_token, + userId: supabaseAuthSession.user.id, + email: supabaseAuthSession.user.email, + expiresIn: supabaseAuthSession.expires_in ?? -1, + expiresAt: supabaseAuthSession.expires_at ?? -1, + }; } diff --git a/app/modules/auth/service.server.ts b/app/modules/auth/service.server.ts index a0eede2..8024eba 100644 --- a/app/modules/auth/service.server.ts +++ b/app/modules/auth/service.server.ts @@ -9,192 +9,192 @@ const tag = "Auth service 🔐"; // For demo purpose, we assert that email is confirmed. // Note that the user will not be able to sign in until email is confirmed. export async function createEmailAuthAccount(email: string, password: string) { - try { - const { data, error } = await supabaseAdmin().auth.admin.createUser({ - email, - password, - email_confirm: true, - }); - - if (error) { - throw error; - } - - const { id, created_at } = data.user; - - return { id, createdAt: created_at }; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: `Failed to create user account`, - metadata: { email }, - tag, - }); - } + try { + const { data, error } = await supabaseAdmin().auth.admin.createUser({ + email, + password, + email_confirm: true, + }); + + if (error) { + throw error; + } + + const { id, created_at } = data.user; + + return { id, createdAt: created_at }; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: `Failed to create user account`, + metadata: { email }, + tag, + }); + } } export async function signInWithEmail(email: string, password: string) { - try { - const { data, error } = await supabaseAdmin().auth.signInWithPassword({ - email, - password, - }); - - if (error) { - throw error; - } - - const { session } = data; - - if (!session) { - throw new SupaStripeStackError({ - message: - "The signed in with email session returned by Supabase is null", - }); - } - - return mapAuthSession(session); - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: `Failed to sign in with email`, - metadata: { email }, - tag, - }); - } + try { + const { data, error } = await supabaseAdmin().auth.signInWithPassword({ + email, + password, + }); + + if (error) { + throw error; + } + + const { session } = data; + + if (!session) { + throw new SupaStripeStackError({ + message: + "The signed in with email session returned by Supabase is null", + }); + } + + return mapAuthSession(session); + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: `Failed to sign in with email`, + metadata: { email }, + tag, + }); + } } async function getUserByEmail(email: string) { - try { - const { data, error } = await supabaseAdmin().auth.admin.listUsers(); - - if (error) { - throw error; - } - - const user = data.users.find((user) => user.email === email); - - if (!user) { - throw new SupaStripeStackError({ - message: `No user found with email`, - }); - } - - return user; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: `Failed to get user by email`, - metadata: { email }, - tag, - }); - } + try { + const { data, error } = await supabaseAdmin().auth.admin.listUsers(); + + if (error) { + throw error; + } + + const user = data.users.find((user) => user.email === email); + + if (!user) { + throw new SupaStripeStackError({ + message: `No user found with email`, + }); + } + + return user; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: `Failed to get user by email`, + metadata: { email }, + tag, + }); + } } export async function deleteAuthAccount(userId: string) { - try { - const { error } = await supabaseAdmin().auth.admin.deleteUser(userId); - - if (error) { - throw error; - } - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: `Failed to delete user account. Please manually delete the user account in the Supabase dashboard.`, - metadata: { userId }, - tag, - }); - } + try { + const { error } = await supabaseAdmin().auth.admin.deleteUser(userId); + + if (error) { + throw error; + } + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: `Failed to delete user account. Please manually delete the user account in the Supabase dashboard.`, + metadata: { userId }, + tag, + }); + } } export async function deleteAuthAccountByEmail(email: string) { - try { - const { id } = await getUserByEmail(email); - - await deleteAuthAccount(id); - } catch (cause) { - Logger.error( - new SupaStripeStackError({ - cause, - message: `Failed to delete user account. Please manually delete the user account in the Supabase dashboard.`, - metadata: { email }, - tag, - }) - ); - } + try { + const { id } = await getUserByEmail(email); + + await deleteAuthAccount(id); + } catch (cause) { + Logger.error( + new SupaStripeStackError({ + cause, + message: `Failed to delete user account. Please manually delete the user account in the Supabase dashboard.`, + metadata: { email }, + tag, + }), + ); + } } /** * Try to refresh the access token and return the new auth session or null. */ export async function refreshAccessToken(refreshToken?: string) { - try { - if (!refreshToken) { - throw new SupaStripeStackError({ - message: `No refresh token provided`, - }); - } - - const { data, error } = await supabaseAdmin().auth.refreshSession({ - refresh_token: refreshToken, - }); - - if (error) { - throw error; - } - - const { session } = data; - - if (!session) { - throw new SupaStripeStackError({ - message: "The refreshed session returned by Supabase is null", - }); - } - - return mapAuthSession(session); - } catch (cause) { - Logger.error( - new SupaStripeStackError({ - cause, - message: `Failed to refresh access token`, - metadata: { refreshToken }, - tag, - }) - ); - - return null; - } + try { + if (!refreshToken) { + throw new SupaStripeStackError({ + message: `No refresh token provided`, + }); + } + + const { data, error } = await supabaseAdmin().auth.refreshSession({ + refresh_token: refreshToken, + }); + + if (error) { + throw error; + } + + const { session } = data; + + if (!session) { + throw new SupaStripeStackError({ + message: "The refreshed session returned by Supabase is null", + }); + } + + return mapAuthSession(session); + } catch (cause) { + Logger.error( + new SupaStripeStackError({ + cause, + message: `Failed to refresh access token`, + metadata: { refreshToken }, + tag, + }), + ); + + return null; + } } export async function verifyAuthSession( - authSession: AuthSession, - { skip }: { skip: boolean } + authSession: AuthSession, + { skip }: { skip: boolean }, ) { - try { - if (skip) { - return { success: true }; - } - - const { error } = await supabaseAdmin().auth.getUser( - authSession.accessToken - ); - - if (error) { - throw error; - } - - return { success: true }; - } catch (cause) { - Logger.error( - new SupaStripeStackError({ - cause, - message: "Failed to verify auth session", - metadata: { userId: authSession.userId }, - tag, - }) - ); - - return { success: false }; - } + try { + if (skip) { + return { success: true }; + } + + const { error } = await supabaseAdmin().auth.getUser( + authSession.accessToken, + ); + + if (error) { + throw error; + } + + return { success: true }; + } catch (cause) { + Logger.error( + new SupaStripeStackError({ + cause, + message: "Failed to verify auth session", + metadata: { userId: authSession.userId }, + tag, + }), + ); + + return { success: false }; + } } diff --git a/app/modules/auth/session.server.ts b/app/modules/auth/session.server.ts index b3e4cbf..ebbb48d 100644 --- a/app/modules/auth/session.server.ts +++ b/app/modules/auth/session.server.ts @@ -2,12 +2,12 @@ import { createCookieSessionStorage } from "@remix-run/node"; import type { SessionWithCookie } from "~/utils"; import { - response, - makeRedirectToFromHere, - NODE_ENV, - safeRedirect, - SESSION_SECRET, - Logger, + response, + makeRedirectToFromHere, + NODE_ENV, + safeRedirect, + SESSION_SECRET, + Logger, } from "~/utils"; import { refreshAccessToken, verifyAuthSession } from "./service.server"; @@ -24,117 +24,119 @@ const REFRESH_ACCESS_TOKEN_THRESHOLD = 60 * 1; // 1 minute left before token exp */ const sessionStorage = createCookieSessionStorage({ - cookie: { - name: "__authSession", - httpOnly: true, - path: "/", - sameSite: "lax", - secrets: [SESSION_SECRET], - secure: NODE_ENV === "production", - }, + cookie: { + name: "__authSession", + httpOnly: true, + path: "/", + sameSite: "lax", + secrets: [SESSION_SECRET], + secure: NODE_ENV === "production", + }, }); export async function createAuthSession({ - request, - authSession, - redirectTo, + request, + authSession, + redirectTo, }: { - request: Request; - authSession: AuthSession; - redirectTo: string; + request: Request; + authSession: AuthSession; + redirectTo: string; }) { - return response.redirect(safeRedirect(redirectTo), { - authSession: { - ...authSession, - cookie: await commitAuthSession(request, authSession, { - flashErrorMessage: null, - }), - }, - }); + return response.redirect(safeRedirect(redirectTo), { + authSession: { + ...authSession, + cookie: await commitAuthSession(request, authSession, { + flashErrorMessage: null, + }), + }, + }); } async function getSession(request: Request) { - const cookie = request.headers.get("Cookie"); - const session = await sessionStorage.getSession(cookie); + const cookie = request.headers.get("Cookie"); + const session = await sessionStorage.getSession(cookie); - return session; + return session; } async function getAuthSession(request: Request): Promise { - const session = await getSession(request); - const authSession = session.get(SESSION_KEY); + const session = await getSession(request); + const authSession = session.get(SESSION_KEY); - if (!authSession) { - return null; - } + if (!authSession) { + return null; + } - return authSession; + return authSession; } export async function isAnonymousSession(request: Request): Promise { - const authSession = await getAuthSession(request); + const authSession = await getAuthSession(request); - return Boolean(!authSession); + return Boolean(!authSession); } async function commitAuthSession( - request: Request, - authSession: AuthSession | null, - options: { - flashErrorMessage?: string | null; - } = {} + request: Request, + authSession: AuthSession | null, + options: { + flashErrorMessage?: string | null; + } = {}, ) { - const session = await getSession(request); + const session = await getSession(request); - // allow user session to be null. - // useful you want to clear session and display a message explaining why - if (authSession !== undefined) { - session.set(SESSION_KEY, authSession); - } + // allow user session to be null. + // useful you want to clear session and display a message explaining why + if (authSession !== undefined) { + session.set(SESSION_KEY, authSession); + } - session.flash(SESSION_ERROR_KEY, options.flashErrorMessage); + session.flash(SESSION_ERROR_KEY, options.flashErrorMessage); - return sessionStorage.commitSession(session, { - maxAge: SESSION_MAX_AGE, - }); + return sessionStorage.commitSession(session, { + maxAge: SESSION_MAX_AGE, + }); } export async function destroyAuthSession(request: Request) { - const session = await getSession(request); + const session = await getSession(request); - return response.redirect("/", { - authSession: null, - headers: [["Set-Cookie", await sessionStorage.destroySession(session)]], - }); + return response.redirect("/", { + authSession: null, + headers: [["Set-Cookie", await sessionStorage.destroySession(session)]], + }); } async function assertAuthSession( - request: Request, - { onFailRedirectTo }: { onFailRedirectTo?: string } = {} + request: Request, + { onFailRedirectTo }: { onFailRedirectTo?: string } = {}, ) { - const authSession = await getAuthSession(request); - - // If there is no user session: Fly, You Fools! 🧙‍♂️ - if (!authSession) { - Logger.dev("No user session found"); - - throw response.redirect( - `${onFailRedirectTo || LOGIN_URL}?${makeRedirectToFromHere(request)}`, - { - authSession: null, - headers: [ - [ - "Set-Cookie", - await commitAuthSession(request, null, { - flashErrorMessage: "no-user-session", - }), - ], - ], - } - ); - } - - return authSession; + const authSession = await getAuthSession(request); + + // If there is no user session: Fly, You Fools! 🧙‍♂️ + if (!authSession) { + Logger.dev("No user session found"); + + throw response.redirect( + `${onFailRedirectTo || LOGIN_URL}?${makeRedirectToFromHere( + request, + )}`, + { + authSession: null, + headers: [ + [ + "Set-Cookie", + await commitAuthSession(request, null, { + flashErrorMessage: "no-user-session", + }), + ], + ], + }, + ); + } + + return authSession; } /** @@ -154,74 +156,74 @@ async function assertAuthSession( * return response.ok({ ... }, { authSession: null }) */ export async function requireAuthSession( - request: Request, - { - onFailRedirectTo, - verify, - }: { onFailRedirectTo?: string; verify: boolean } = { verify: false } + request: Request, + { + onFailRedirectTo, + verify, + }: { onFailRedirectTo?: string; verify: boolean } = { verify: false }, ): Promise> { - // hello there - const authSession = await assertAuthSession(request, { - onFailRedirectTo, - }); - - // ok, let's challenge its access token. - const validation = await verifyAuthSession(authSession, { - // by default, we don't verify the access token from supabase auth api to save some time - // this is still safe because we verify the refresh token on expires and all of this comes from a secure signed cookie - skip: !verify, - }); - - // damn, access token is not valid or expires soon - // let's try to refresh, in case of 🧐 - if (!validation.success || isExpiringSoon(authSession.expiresAt)) { - return refreshAuthSession(request); - } - - // finally, we have a valid session, let's return it - return { - ...authSession, - // the cookie to set in the response - cookie: await commitAuthSession(request, authSession), - }; + // hello there + const authSession = await assertAuthSession(request, { + onFailRedirectTo, + }); + + // ok, let's challenge its access token. + const validation = await verifyAuthSession(authSession, { + // by default, we don't verify the access token from supabase auth api to save some time + // this is still safe because we verify the refresh token on expires and all of this comes from a secure signed cookie + skip: !verify, + }); + + // damn, access token is not valid or expires soon + // let's try to refresh, in case of 🧐 + if (!validation.success || isExpiringSoon(authSession.expiresAt)) { + return refreshAuthSession(request); + } + + // finally, we have a valid session, let's return it + return { + ...authSession, + // the cookie to set in the response + cookie: await commitAuthSession(request, authSession), + }; } function isExpiringSoon(expiresAt: number) { - return (expiresAt - REFRESH_ACCESS_TOKEN_THRESHOLD) * 1000 < Date.now(); + return (expiresAt - REFRESH_ACCESS_TOKEN_THRESHOLD) * 1000 < Date.now(); } async function refreshAuthSession( - request: Request + request: Request, ): Promise> { - const authSession = await getAuthSession(request); - - const refreshedAuthSession = await refreshAccessToken( - authSession?.refreshToken - ); - - // 👾 game over, log in again - // yes, arbitrary, but it's a good way to don't let an illegal user here with an expired token - if (!refreshedAuthSession) { - const redirectUrl = `${LOGIN_URL}?${makeRedirectToFromHere(request)}`; - - // here we throw instead of return because this function promise a AuthSession and not a response object - // https://remix.run/docs/en/v1/guides/constraints#higher-order-functions - throw response.redirect(redirectUrl, { - authSession: null, - headers: [ - [ - "Set-Cookie", - await commitAuthSession(request, null, { - flashErrorMessage: "fail-refresh-auth-session", - }), - ], - ], - }); - } - - return { - ...refreshedAuthSession, - // the cookie to set in the response - cookie: await commitAuthSession(request, refreshedAuthSession), - }; + const authSession = await getAuthSession(request); + + const refreshedAuthSession = await refreshAccessToken( + authSession?.refreshToken, + ); + + // 👾 game over, log in again + // yes, arbitrary, but it's a good way to don't let an illegal user here with an expired token + if (!refreshedAuthSession) { + const redirectUrl = `${LOGIN_URL}?${makeRedirectToFromHere(request)}`; + + // here we throw instead of return because this function promise a AuthSession and not a response object + // https://remix.run/docs/en/v1/guides/constraints#higher-order-functions + throw response.redirect(redirectUrl, { + authSession: null, + headers: [ + [ + "Set-Cookie", + await commitAuthSession(request, null, { + flashErrorMessage: "fail-refresh-auth-session", + }), + ], + ], + }); + } + + return { + ...refreshedAuthSession, + // the cookie to set in the response + cookie: await commitAuthSession(request, refreshedAuthSession), + }; } diff --git a/app/modules/auth/types.ts b/app/modules/auth/types.ts index cbfbf39..a7a55a8 100644 --- a/app/modules/auth/types.ts +++ b/app/modules/auth/types.ts @@ -1,8 +1,8 @@ export type AuthSession = { - accessToken: string; - refreshToken: string; - userId: string; - email: string; - expiresIn: number; - expiresAt: number; + accessToken: string; + refreshToken: string; + userId: string; + email: string; + expiresIn: number; + expiresAt: number; }; diff --git a/app/modules/billing-portal/service.server.ts b/app/modules/billing-portal/service.server.ts index 8519508..929ce3f 100644 --- a/app/modules/billing-portal/service.server.ts +++ b/app/modules/billing-portal/service.server.ts @@ -4,19 +4,19 @@ import { SERVER_URL, SupaStripeStackError } from "~/utils"; const tag = "Billing portal service 📊"; export async function createBillingPortalSession(customerId: string) { - try { - const { url } = await stripe.billingPortal.sessions.create({ - customer: customerId, - return_url: `${SERVER_URL}/subscription`, - }); + try { + const { url } = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `${SERVER_URL}/subscription`, + }); - return { url }; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: "Unable to create billing portal session", - metadata: { customerId }, - tag, - }); - } + return { url }; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: "Unable to create billing portal session", + metadata: { customerId }, + tag, + }); + } } diff --git a/app/modules/checkout/service.server.ts b/app/modules/checkout/service.server.ts index 55eda90..7304890 100644 --- a/app/modules/checkout/service.server.ts +++ b/app/modules/checkout/service.server.ts @@ -4,41 +4,41 @@ import { SERVER_URL, SupaStripeStackError } from "~/utils"; const tag = "Checkout service 🛒"; export async function createCheckoutSession({ - customerId, - priceId, + customerId, + priceId, }: { - customerId: string; - priceId: string; + customerId: string; + priceId: string; }) { - try { - const { url } = await stripe.checkout.sessions.create({ - customer: customerId, - line_items: [ - { - price: priceId, - quantity: 1, - }, - ], - mode: "subscription", - payment_method_types: ["card"], - success_url: `${SERVER_URL}/checkout`, - cancel_url: `${SERVER_URL}/subscription`, - }); + try { + const { url } = await stripe.checkout.sessions.create({ + customer: customerId, + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + mode: "subscription", + payment_method_types: ["card"], + success_url: `${SERVER_URL}/checkout`, + cancel_url: `${SERVER_URL}/subscription`, + }); - if (!url) { - throw new SupaStripeStackError({ - cause: null, - message: "Checkout session url is null", - }); - } + if (!url) { + throw new SupaStripeStackError({ + cause: null, + message: "Checkout session url is null", + }); + } - return { url }; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: "Unable to create checkout session", - metadata: { customerId, priceId }, - tag, - }); - } + return { url }; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: "Unable to create checkout session", + metadata: { customerId, priceId }, + tag, + }); + } } diff --git a/app/modules/note/service.server.ts b/app/modules/note/service.server.ts index fe09454..8fcc0f7 100644 --- a/app/modules/note/service.server.ts +++ b/app/modules/note/service.server.ts @@ -6,89 +6,89 @@ import type { Note } from "./types"; const tag = "Note service 📝"; export async function getNotes({ userId }: Pick) { - try { - const result = await db.note.findMany({ - where: { userId }, - select: { id: true, content: true }, - orderBy: { updatedAt: "desc" }, - }); + try { + const result = await db.note.findMany({ + where: { userId }, + select: { id: true, content: true }, + orderBy: { updatedAt: "desc" }, + }); - return result; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: `Unable to get notes`, - status: 404, - metadata: { userId }, - tag, - }); - } + return result; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: `Unable to get notes`, + status: 404, + metadata: { userId }, + tag, + }); + } } export async function createNote({ - content, - userId, + content, + userId, }: Pick) { - try { - const result = await db.note.create({ - data: { - content, - userId, - }, + try { + const result = await db.note.create({ + data: { + content, + userId, + }, - select: { id: true, updatedAt: true }, - }); + select: { id: true, updatedAt: true }, + }); - return result; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: `Unable to create the note`, - metadata: { userId, content }, - tag, - }); - } + return result; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: `Unable to create the note`, + metadata: { userId, content }, + tag, + }); + } } export async function updateNote({ - content, - userId, - id, + content, + userId, + id, }: Pick) { - try { - const result = await db.note.update({ - where: { id, userId }, - data: { - content, - }, - select: { id: true, updatedAt: true }, - }); + try { + const result = await db.note.update({ + where: { id, userId }, + data: { + content, + }, + select: { id: true, updatedAt: true }, + }); - return result; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: `Unable to create the note`, - metadata: { userId, content }, - tag, - }); - } + return result; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: `Unable to create the note`, + metadata: { userId, content }, + tag, + }); + } } export async function deleteNote({ id, userId }: Pick) { - try { - const result = await db.note.delete({ - where: { id, userId }, - select: { id: true }, - }); + try { + const result = await db.note.delete({ + where: { id, userId }, + select: { id: true }, + }); - return result; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: `Unable to delete the note`, - metadata: { userId, id }, - tag, - }); - } + return result; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: `Unable to delete the note`, + metadata: { userId, id }, + tag, + }); + } } diff --git a/app/modules/price/components/pricing-table.tsx b/app/modules/price/components/pricing-table.tsx index 861449a..4abe8d5 100644 --- a/app/modules/price/components/pricing-table.tsx +++ b/app/modules/price/components/pricing-table.tsx @@ -10,150 +10,177 @@ import { isFormProcessing, tw, useLocales } from "~/utils"; import type { PricingPlan, Interval } from "../types"; export function PricingTable({ - pricingPlan, - userTierId, - defaultDisplayAnnual = false, + pricingPlan, + userTierId, + defaultDisplayAnnual = false, }: { - pricingPlan: PricingPlan; - userTierId?: TierId; - defaultDisplayAnnual?: boolean; + pricingPlan: PricingPlan; + userTierId?: TierId; + defaultDisplayAnnual?: boolean; }) { - const [displayAnnual, setDisplayAnnual] = useState(defaultDisplayAnnual); - const subscribeFetcher = useFetcher(); - const { locales } = useLocales(); - const isProcessing = isFormProcessing(subscribeFetcher.state); - const processingTierId = subscribeFetcher.submission?.formData.get( - "tierId" - ) as TierId; - const intervalFilter = (displayAnnual ? "year" : "month") satisfies Interval; + const [displayAnnual, setDisplayAnnual] = useState(defaultDisplayAnnual); + const subscribeFetcher = useFetcher(); + const { locales } = useLocales(); + const isProcessing = isFormProcessing(subscribeFetcher.state); + const processingTierId = subscribeFetcher.formData?.get("tierId") as TierId; + const intervalFilter = ( + displayAnnual ? "year" : "month" + ) satisfies Interval; - return ( -
-
- - - - - - Annual billing - - (2 months free) - - -
-
- {pricingPlan - .filter(({ interval }) => interval === intervalFilter) - .map( - ({ - interval, - id, - active, - amount, - currency, - description, - featuresList, - name, - priceId, - }) => { - const isCurrentTier = id === userTierId; - const intervalLabel = interval === "month" ? "mo" : "yr"; + return ( +
+
+ + + + + + Annual billing + + + {" "} + (2 months free) + + + +
+
+ {pricingPlan + .filter(({ interval }) => interval === intervalFilter) + .map( + ({ + interval, + id, + active, + amount, + currency, + description, + featuresList, + name, + priceId, + }) => { + const isCurrentTier = id === userTierId; + const intervalLabel = + interval === "month" ? "mo" : "yr"; - return ( -
-
-

- {name} - - {!active ? ( - Not available - ) : null} -

-

{description}

-

- - {new Intl.NumberFormat(locales, { - currency, - style: "currency", - }).format(amount / 100)} - {" "} - - /{intervalLabel} - -

- {userTierId === "free" && id !== "free" ? ( - - - - - ) : null} -
-
-

- What's included -

-
    - {featuresList.map((feature) => ( -
  • -
  • - ))} -
-
-
- ); - } - )} -
-
- ); + return ( +
+
+

+ {name} + + {!active ? ( + + Not available + + ) : null} +

+

+ {description} +

+

+ + {new Intl.NumberFormat( + locales, + { + currency, + style: "currency", + }, + ).format(amount / 100)} + {" "} + + /{intervalLabel} + +

+ {userTierId === "free" && + id !== "free" ? ( + + + + + ) : null} +
+
+

+ What's included +

+
    + {featuresList.map((feature) => ( +
  • +
  • + ))} +
+
+
+ ); + }, + )} +
+
+ ); } diff --git a/app/modules/price/service.server.ts b/app/modules/price/service.server.ts index 1b0d426..ea49c30 100644 --- a/app/modules/price/service.server.ts +++ b/app/modules/price/service.server.ts @@ -6,60 +6,60 @@ import type { Currency } from "./types"; const tag = "Price service 💰"; export type PricingPlan = NonNullable< - Awaited> + Awaited> >; export async function getPricingPlan(currency: Currency) { - try { - const result = await db.price.findMany({ - where: { active: true }, - select: { - interval: true, - tier: { - select: { - id: true, - name: true, - description: true, - featuresList: true, - active: true, - }, - }, - currencies: { - where: { currency }, - orderBy: { amount: "asc" }, - }, - }, - orderBy: { - tierId: "asc", - }, - }); + try { + const result = await db.price.findMany({ + where: { active: true }, + select: { + interval: true, + tier: { + select: { + id: true, + name: true, + description: true, + featuresList: true, + active: true, + }, + }, + currencies: { + where: { currency }, + orderBy: { amount: "asc" }, + }, + }, + orderBy: { + tierId: "asc", + }, + }); - const pricingPlan = result.map(({ tier, interval, currencies }) => { - const price = currencies[0]; + const pricingPlan = result.map(({ tier, interval, currencies }) => { + const price = currencies[0]; - if (!price) { - throw new SupaStripeStackError({ - cause: null, - message: "No price found. This should not happen.", - metadata: { currency, interval, tierId: tier.id }, - tag, - }); - } + if (!price) { + throw new SupaStripeStackError({ + cause: null, + message: "No price found. This should not happen.", + metadata: { currency, interval, tierId: tier.id }, + tag, + }); + } - return { - interval, - ...tier, - ...price, - }; - }); + return { + interval, + ...tier, + ...price, + }; + }); - return pricingPlan; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: `Unable to get pricing plan`, - metadata: { currency }, - tag, - }); - } + return pricingPlan; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: `Unable to get pricing plan`, + metadata: { currency }, + tag, + }); + } } diff --git a/app/modules/subscription/service.server.ts b/app/modules/subscription/service.server.ts index 9ca40bf..cd8d3a5 100644 --- a/app/modules/subscription/service.server.ts +++ b/app/modules/subscription/service.server.ts @@ -13,240 +13,240 @@ import { SubscriptionStatus } from "./types"; const tag = "Subscription service 🧾"; const StripeSubscriptionSchema = z - .object({ - current_period_start: z.number(), - current_period_end: z.number(), - cancel_at_period_end: z.boolean(), - customer: z.string(), - status: z.nativeEnum(SubscriptionStatus), - currency: z.nativeEnum(Currency), - items: z.object({ - data: z - .array( - z.object({ - id: z.string(), - price: z.object({ - id: z.string(), - product: z.nativeEnum(TierId), - }), - }) - ) - .length(1), - }), - }) - .transform( - ({ - customer: customerId, - status, - currency, - items: { - data: [ - { - id: itemId, - price: { id: priceId, product: tierId }, - }, - ], - }, - cancel_at_period_end: cancelAtPeriodEnd, - current_period_end: currentPeriodEnd, - current_period_start: currentPeriodStart, - }) => ({ - customerId, - tierId, - itemId, - priceId, - currentPeriodEnd: toDate(currentPeriodEnd), - currentPeriodStart: toDate(currentPeriodStart), - cancelAtPeriodEnd, - currency, - status, - }) - ); + .object({ + current_period_start: z.number(), + current_period_end: z.number(), + cancel_at_period_end: z.boolean(), + customer: z.string(), + status: z.nativeEnum(SubscriptionStatus), + currency: z.nativeEnum(Currency), + items: z.object({ + data: z + .array( + z.object({ + id: z.string(), + price: z.object({ + id: z.string(), + product: z.nativeEnum(TierId), + }), + }), + ) + .length(1), + }), + }) + .transform( + ({ + customer: customerId, + status, + currency, + items: { + data: [ + { + id: itemId, + price: { id: priceId, product: tierId }, + }, + ], + }, + cancel_at_period_end: cancelAtPeriodEnd, + current_period_end: currentPeriodEnd, + current_period_start: currentPeriodStart, + }) => ({ + customerId, + tierId, + itemId, + priceId, + currentPeriodEnd: toDate(currentPeriodEnd), + currentPeriodStart: toDate(currentPeriodStart), + cancelAtPeriodEnd, + currency, + status, + }), + ); export async function fetchSubscription(id: string) { - try { - const subscription = await parseData( - await stripe.subscriptions.retrieve(id), - StripeSubscriptionSchema, - "Stripe subscription fetch result is malformed" - ); - - return subscription; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: "Unable to retrieve subscription", - metadata: { id }, - tag, - }); - } + try { + const subscription = await parseData( + await stripe.subscriptions.retrieve(id), + StripeSubscriptionSchema, + "Stripe subscription fetch result is malformed", + ); + + return subscription; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: "Unable to retrieve subscription", + metadata: { id }, + tag, + }); + } } export async function getSubscription(userId: string) { - try { - const subscription = await db.subscription.findFirst({ - where: { userId }, - select: { - id: true, - tierId: true, - priceId: true, - status: true, - cancelAtPeriodEnd: true, - currentPeriodEnd: true, - price: { select: { interval: true } }, - }, - }); - - if (!subscription) { - return null; - } - - const { - price: { interval }, - ...userSubscription - } = subscription; - - return { interval, ...userSubscription }; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: "Unable to get subscription", - metadata: { userId }, - tag, - }); - } + try { + const subscription = await db.subscription.findFirst({ + where: { userId }, + select: { + id: true, + tierId: true, + priceId: true, + status: true, + cancelAtPeriodEnd: true, + currentPeriodEnd: true, + price: { select: { interval: true } }, + }, + }); + + if (!subscription) { + return null; + } + + const { + price: { interval }, + ...userSubscription + } = subscription; + + return { interval, ...userSubscription }; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: "Unable to get subscription", + metadata: { userId }, + tag, + }); + } } export async function createSubscription({ - customerId, - tierId, - id, - priceId, - itemId, - currentPeriodStart, - currentPeriodEnd, + customerId, + tierId, + id, + priceId, + itemId, + currentPeriodStart, + currentPeriodEnd, }: Pick< - Subscription, - | "id" - | "tierId" - | "priceId" - | "itemId" - | "currentPeriodStart" - | "currentPeriodEnd" + Subscription, + | "id" + | "tierId" + | "priceId" + | "itemId" + | "currentPeriodStart" + | "currentPeriodEnd" > & - Pick) { - try { - const newSubscription = await db.user.update({ - where: { customerId }, - data: { - tierId, - subscription: { - create: { - id, - status: "active", - tierId, - priceId, - itemId, - currentPeriodStart, - currentPeriodEnd, - }, - }, - }, - select: { createdAt: true, id: true }, - }); - - return newSubscription; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: "Unable to create subscription", - metadata: { customerId, tierId, id, priceId, itemId }, - tag, - }); - } + Pick) { + try { + const newSubscription = await db.user.update({ + where: { customerId }, + data: { + tierId, + subscription: { + create: { + id, + status: "active", + tierId, + priceId, + itemId, + currentPeriodStart, + currentPeriodEnd, + }, + }, + }, + select: { createdAt: true, id: true }, + }); + + return newSubscription; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: "Unable to create subscription", + metadata: { customerId, tierId, id, priceId, itemId }, + tag, + }); + } } export async function updateSubscription({ - customerId, - tierId, - status, - priceId, - itemId, - currency, - currentPeriodStart, - currentPeriodEnd, - cancelAtPeriodEnd, + customerId, + tierId, + status, + priceId, + itemId, + currency, + currentPeriodStart, + currentPeriodEnd, + cancelAtPeriodEnd, }: Pick & - Pick< - Subscription, - | "tierId" - | "status" - | "priceId" - | "itemId" - | "currentPeriodStart" - | "currentPeriodEnd" - | "cancelAtPeriodEnd" - >) { - try { - const updatedSubscription = await db.user.update({ - where: { customerId }, - data: { - tierId, - currency, - subscription: { - update: { - status, - tierId, - priceId, - itemId, - currentPeriodStart, - currentPeriodEnd, - cancelAtPeriodEnd, - }, - }, - }, - select: { updatedAt: true, id: true }, - }); - - return updatedSubscription; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: "Unable to update subscription", - metadata: { - customerId, - tierId, - status, - priceId, - itemId, - currency, - }, - tag, - }); - } + Pick< + Subscription, + | "tierId" + | "status" + | "priceId" + | "itemId" + | "currentPeriodStart" + | "currentPeriodEnd" + | "cancelAtPeriodEnd" + >) { + try { + const updatedSubscription = await db.user.update({ + where: { customerId }, + data: { + tierId, + currency, + subscription: { + update: { + status, + tierId, + priceId, + itemId, + currentPeriodStart, + currentPeriodEnd, + cancelAtPeriodEnd, + }, + }, + }, + select: { updatedAt: true, id: true }, + }); + + return updatedSubscription; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: "Unable to update subscription", + metadata: { + customerId, + tierId, + status, + priceId, + itemId, + currency, + }, + tag, + }); + } } export async function deleteSubscription({ - id, - customerId, + id, + customerId, }: Pick & Pick) { - try { - await db.$transaction([ - db.user.update({ - where: { customerId }, - data: { tierId: "free" }, - select: { updatedAt: true }, - }), - db.subscription.delete({ where: { id }, select: { id: true } }), - ]); - - return { id, deleted: true }; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: "Unable to cancel subscription", - metadata: { id, customerId }, - tag, - }); - } + try { + await db.$transaction([ + db.user.update({ + where: { customerId }, + data: { tierId: "free" }, + select: { updatedAt: true }, + }), + db.subscription.delete({ where: { id }, select: { id: true } }), + ]); + + return { id, deleted: true }; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: "Unable to cancel subscription", + metadata: { id, customerId }, + tag, + }); + } } diff --git a/app/modules/tier/service.server.ts b/app/modules/tier/service.server.ts index bdaba86..7fd17e3 100644 --- a/app/modules/tier/service.server.ts +++ b/app/modules/tier/service.server.ts @@ -6,29 +6,33 @@ import type { TierId, Tier } from "./types"; const tag = "Tier service 📊"; export async function updateTier( - tierId: TierId, - { name, active, description }: Pick + tierId: TierId, + { + name, + active, + description, + }: Pick, ) { - try { - const { id, updatedAt } = await db.tier.update({ - where: { - id: tierId, - }, - data: { - name, - active, - description, - }, - select: { updatedAt: true, id: true }, - }); + try { + const { id, updatedAt } = await db.tier.update({ + where: { + id: tierId, + }, + data: { + name, + active, + description, + }, + select: { updatedAt: true, id: true }, + }); - return { id, updatedAt }; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: "Unable to update tier", - metadata: { tierId, name, active, description }, - tag, - }); - } + return { id, updatedAt }; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: "Unable to update tier", + metadata: { tierId, name, active, description }, + tag, + }); + } } diff --git a/app/modules/user/service.server.ts b/app/modules/user/service.server.ts index 7cb482e..30370d2 100644 --- a/app/modules/user/service.server.ts +++ b/app/modules/user/service.server.ts @@ -2,9 +2,9 @@ import { db } from "~/database"; import { stripe } from "~/integrations/stripe"; import type { AuthSession } from "~/modules/auth"; import { - createEmailAuthAccount, - signInWithEmail, - deleteAuthAccount, + createEmailAuthAccount, + signInWithEmail, + deleteAuthAccount, } from "~/modules/auth"; import { SupaStripeStackError } from "~/utils"; @@ -14,170 +14,172 @@ import { deleteAuthAccountByEmail } from "../auth/service.server"; const tag = "User service 🧑"; type UserCreatePayload = Pick & - Pick; + Pick; export async function getUserByEmail(email: User["email"]) { - try { - const user = await db.user.findUnique({ - where: { email: email.toLowerCase() }, - }); - - return user; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: "Unable to get user by email", - status: 404, - metadata: { email }, - tag, - }); - } + try { + const user = await db.user.findUnique({ + where: { email: email.toLowerCase() }, + }); + + return user; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: "Unable to get user by email", + status: 404, + metadata: { email }, + tag, + }); + } } async function createUser({ email, userId, name }: UserCreatePayload) { - try { - const { id: customerId } = await stripe.customers.create({ - email, - name, - }); - - const user = await db.user.create({ - data: { - email, - id: userId, - customerId, - name, - tierId: "free", - }, - }); - - return user; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: "Unable to create user in database or stripe", - metadata: { email, userId, name }, - tag, - }); - } + try { + const { id: customerId } = await stripe.customers.create({ + email, + name, + }); + + const user = await db.user.create({ + data: { + email, + id: userId, + customerId, + name, + tierId: "free", + }, + }); + + return user; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: "Unable to create user in database or stripe", + metadata: { email, userId, name }, + tag, + }); + } } export async function createUserAccount(payload: { - email: string; - password: string; - name: string; + email: string; + password: string; + name: string; }) { - const { email, password, name } = payload; - - try { - const { id: userId } = await createEmailAuthAccount(email, password); - const authSession = await signInWithEmail(email, password); - await createUser({ - email, - userId, - name, - }); - - return authSession; - } catch (cause) { - // We should delete the user account to allow retry create account again - // We mostly trust that it will be deleted. - // If it's not the case, the user will face on a "user already exists" kind of error. - // It'll require manual intervention to remove the account in Supabase Auth dashboard. - await deleteAuthAccountByEmail(email); - - throw new SupaStripeStackError({ - cause, - message: "Unable to create user account", - metadata: { email, name }, - tag, - }); - } + const { email, password, name } = payload; + + try { + const { id: userId } = await createEmailAuthAccount(email, password); + const authSession = await signInWithEmail(email, password); + await createUser({ + email, + userId, + name, + }); + + return authSession; + } catch (cause) { + // We should delete the user account to allow retry create account again + // We mostly trust that it will be deleted. + // If it's not the case, the user will face on a "user already exists" kind of error. + // It'll require manual intervention to remove the account in Supabase Auth dashboard. + await deleteAuthAccountByEmail(email); + + throw new SupaStripeStackError({ + cause, + message: "Unable to create user account", + metadata: { email, name }, + tag, + }); + } } export async function getUserTierLimit(id: User["id"]) { - try { - const { - tier: { tierLimit }, - } = await db.user.findUniqueOrThrow({ - where: { id }, - select: { - tier: { - include: { tierLimit: { select: { maxNumberOfNotes: true } } }, - }, - }, - }); - - return tierLimit; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: "Unable to find user tier limit", - status: 404, - metadata: { id }, - tag, - }); - } + try { + const { + tier: { tierLimit }, + } = await db.user.findUniqueOrThrow({ + where: { id }, + select: { + tier: { + include: { + tierLimit: { select: { maxNumberOfNotes: true } }, + }, + }, + }, + }); + + return tierLimit; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: "Unable to find user tier limit", + status: 404, + metadata: { id }, + tag, + }); + } } export async function getUserTier(id: User["id"]) { - try { - const { tier } = await db.user.findUniqueOrThrow({ - where: { id }, - select: { - tier: { select: { id: true, name: true } }, - }, - }); - - return tier; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: "Unable to find user tier", - status: 404, - metadata: { id }, - tag, - }); - } + try { + const { tier } = await db.user.findUniqueOrThrow({ + where: { id }, + select: { + tier: { select: { id: true, name: true } }, + }, + }); + + return tier; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: "Unable to find user tier", + status: 404, + metadata: { id }, + tag, + }); + } } export async function getBillingInfo(id: User["id"]) { - try { - const { customerId, currency } = await db.user.findUniqueOrThrow({ - where: { id }, - select: { - customerId: true, - currency: true, - }, - }); - - return { customerId, currency }; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: "Unable to get billing info", - status: 404, - metadata: { id }, - tag, - }); - } + try { + const { customerId, currency } = await db.user.findUniqueOrThrow({ + where: { id }, + select: { + customerId: true, + currency: true, + }, + }); + + return { customerId, currency }; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: "Unable to get billing info", + status: 404, + metadata: { id }, + tag, + }); + } } export async function deleteUser(id: User["id"]) { - try { - const { customerId } = await getBillingInfo(id); - - await stripe.customers.del(customerId); - await deleteAuthAccount(id); - await db.user.delete({ where: { id } }); - - return { success: true }; - } catch (cause) { - throw new SupaStripeStackError({ - cause, - message: "Oups, unable to delete your test account", - metadata: { id }, - tag, - }); - } + try { + const { customerId } = await getBillingInfo(id); + + await stripe.customers.del(customerId); + await deleteAuthAccount(id); + await db.user.delete({ where: { id } }); + + return { success: true }; + } catch (cause) { + throw new SupaStripeStackError({ + cause, + message: "Oups, unable to delete your test account", + metadata: { id }, + tag, + }); + } } diff --git a/app/root.tsx b/app/root.tsx index 0d7be20..0297ba9 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -2,20 +2,25 @@ import { Fragment, useEffect, useState } from "react"; import { Dialog, Transition } from "@headlessui/react"; import { Bars3Icon, XCircleIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import type { LinksFunction, LoaderArgs, MetaFunction } from "@remix-run/node"; +import { cssBundleHref } from "@remix-run/css-bundle"; +import type { + LinksFunction, + LoaderArgs, + V2_MetaFunction as MetaFunction, +} from "@remix-run/node"; import { - Form, - Link, - Links, - LiveReload, - Meta, - NavLink, - Outlet, - Scripts, - ScrollRestoration, - useFetchers, - useLoaderData, - useLocation, + Form, + Link, + Links, + LiveReload, + Meta, + NavLink, + Outlet, + Scripts, + ScrollRestoration, + useFetchers, + useLoaderData, + useLocation, } from "@remix-run/react"; import type { CatchResponse } from "~/utils"; @@ -26,404 +31,432 @@ import { getUserTier } from "./modules/user"; import tailwindStylesheetUrl from "./styles/tailwind.css"; export const links: LinksFunction = () => [ - { rel: "stylesheet", href: tailwindStylesheetUrl }, + { rel: "preload", href: tailwindStylesheetUrl, as: "style" }, + { rel: "stylesheet", href: tailwindStylesheetUrl, as: "style" }, + ...(cssBundleHref + ? [ + { rel: "preload", href: cssBundleHref, as: "style" }, + { rel: "stylesheet", href: cssBundleHref }, + ] + : []), ]; -export const meta: MetaFunction = () => ({ - charset: "utf-8", - title: "Notee", - viewport: "width=device-width,initial-scale=1", -}); +export const meta: MetaFunction = () => [ + { title: "Notee" }, + { name: "description", content: "Notee App" }, +]; export async function loader({ request }: LoaderArgs) { - const isAnonymous = await isAnonymousSession(request); + const isAnonymous = await isAnonymousSession(request); - if (isAnonymous) { - return response.ok( - { - env: getBrowserEnv(), - email: null, - userTier: null, - }, - { authSession: null } - ); - } + if (isAnonymous) { + return response.ok( + { + env: getBrowserEnv(), + email: null, + userTier: null, + }, + { authSession: null }, + ); + } - const authSession = await requireAuthSession(request); - const { userId, email } = authSession; + const authSession = await requireAuthSession(request); + const { userId, email } = authSession; - try { - const userTier = await getUserTier(userId); + try { + const userTier = await getUserTier(userId); - return response.ok( - { - env: getBrowserEnv(), - email, - userTier, - }, - { authSession } - ); - } catch (cause) { - throw response.error(cause, { authSession }); - } + return response.ok( + { + env: getBrowserEnv(), + email, + userTier, + }, + { authSession }, + ); + } catch (cause) { + throw response.error(cause, { authSession }); + } } export default function App() { - const { env } = useLoaderData(); + const { env } = useLoaderData(); - return ( - - - - - - - - -