From 8ad438ed44a657de9006199ec212af7f7ba68312 Mon Sep 17 00:00:00 2001 From: Dev XO Date: Sat, 15 Jul 2023 21:19:59 +0200 Subject: [PATCH] v3.2.0 --- .dockerignore | 7 + .env.example | 27 ++ .eslintrc.js | 7 + .github/dependabot.yml | 20 + .github/workflows/deploy.yml | 186 ++++++++++ .gitignore | 28 ++ .npmrc | 2 + .prettierignore | 7 + .prettierrc | 14 + Dockerfile | 58 +++ LICENSE | 21 ++ README.md | 189 ++++++++++ app/components/footer.tsx | 55 +++ app/components/navigation.tsx | 114 ++++++ app/components/stripe/checkout-button.tsx | 57 +++ .../stripe/customer-portal-button.tsx | 16 + app/entry.client.tsx | 22 ++ app/entry.server.tsx | 109 ++++++ app/models/plan/create-plan.ts | 8 + app/models/plan/delete-plan.ts | 8 + app/models/plan/get-plan.ts | 21 ++ app/models/plan/update-plan.ts | 9 + .../subscription/create-subscription.ts | 10 + .../subscription/delete-subscription.ts | 8 + app/models/subscription/get-subscription.ts | 14 + .../subscription/update-subscription.ts | 22 ++ app/models/user/create-user.ts | 8 + app/models/user/delete-user.ts | 8 + app/models/user/get-user.ts | 22 ++ app/models/user/update-user.ts | 9 + app/root.css | 122 ++++++ app/root.tsx | 77 ++++ app/routes/_layout+/_index.tsx | 140 +++++++ app/routes/_layout+/_layout.tsx | 45 +++ app/routes/_layout+/account.tsx | 198 ++++++++++ app/routes/_layout+/checkout.tsx | 118 ++++++ app/routes/_layout+/login.email.tsx | 165 +++++++++ app/routes/_layout+/login.index.tsx | 40 ++ app/routes/_layout+/login.tsx | 54 +++ app/routes/_layout+/plans.tsx | 180 +++++++++ app/routes/_layout+/register.name.tsx | 104 ++++++ app/routes/_layout+/register.tsx | 20 + app/routes/_layout+/support.tsx | 65 ++++ app/routes/api+/healthcheck.ts | 26 ++ app/routes/api+/webhook.ts | 138 +++++++ app/routes/auth+/$provider.callback.tsx | 19 + app/routes/auth+/$provider.tsx | 19 + app/routes/auth+/logout.tsx | 14 + app/routes/auth+/magic.tsx | 17 + .../resources+/stripe.create-checkout.ts | 47 +++ .../stripe.create-customer-portal.ts | 30 ++ .../resources+/stripe.create-customer.ts | 30 ++ .../resources+/stripe.create-subscription.ts | 57 +++ app/routes/resources+/user.delete.ts | 37 ++ app/services/auth/config.server.ts | 141 +++++++ app/services/auth/session.server.ts | 14 + app/services/email/config.server.ts | 29 ++ .../stripe/api/configure-customer-portal.ts | 34 ++ app/services/stripe/api/create-checkout.ts | 27 ++ .../stripe/api/create-customer-portal.ts | 17 + app/services/stripe/api/create-customer.ts | 7 + app/services/stripe/api/create-price.ts | 24 ++ app/services/stripe/api/create-product.ts | 18 + .../stripe/api/create-subscription.ts | 18 + app/services/stripe/api/delete-customer.ts | 9 + .../stripe/api/retrieve-subscription.ts | 11 + app/services/stripe/config.server.ts | 8 + app/services/stripe/plans.ts | 110 ++++++ app/utils/date.ts | 20 + app/utils/db.ts | 17 + app/utils/envs.ts | 49 +++ app/utils/hooks.ts | 24 ++ app/utils/http.ts | 4 + app/utils/locales.ts | 11 + fly.toml | 52 +++ package.json | 63 ++++ playwright.config.ts | 83 +++++ postcss.config.js | 6 + prisma/schema.prisma | 102 +++++ prisma/seed.ts | 95 +++++ prisma/seed/app/models/plan/get-plan.js | 23 ++ .../stripe/api/configure-customer-portal.js | 30 ++ .../app/services/stripe/api/create-price.js | 20 + .../app/services/stripe/api/create-product.js | 15 + .../seed/app/services/stripe/config.server.js | 13 + prisma/seed/app/services/stripe/plans.js | 59 +++ prisma/seed/app/utils/db.js | 15 + prisma/seed/prisma/seed.js | 79 ++++ public/favicon.ico | Bin 0 -> 16958 bytes remix.config.js | 25 ++ remix.env.d.ts | 2 + remix.init/.gitignore | 7 + remix.init/index.js | 347 ++++++++++++++++++ remix.init/lib/postgres/.env.example | 27 ++ remix.init/lib/postgres/Dockerfile | 53 +++ remix.init/lib/postgres/deploy.yml | 186 ++++++++++ remix.init/lib/postgres/docker-compose.yml | 13 + remix.init/lib/postgres/fly.toml | 52 +++ remix.init/package.json | 14 + start.sh | 8 + tailwind.config.js | 10 + tests/e2e/app.spec.ts | 8 + tsconfig.json | 21 ++ tsconfig.seed.json | 18 + 104 files changed, 4886 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .eslintrc.js create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/components/footer.tsx create mode 100644 app/components/navigation.tsx create mode 100644 app/components/stripe/checkout-button.tsx create mode 100644 app/components/stripe/customer-portal-button.tsx create mode 100644 app/entry.client.tsx create mode 100644 app/entry.server.tsx create mode 100644 app/models/plan/create-plan.ts create mode 100644 app/models/plan/delete-plan.ts create mode 100644 app/models/plan/get-plan.ts create mode 100644 app/models/plan/update-plan.ts create mode 100644 app/models/subscription/create-subscription.ts create mode 100644 app/models/subscription/delete-subscription.ts create mode 100644 app/models/subscription/get-subscription.ts create mode 100644 app/models/subscription/update-subscription.ts create mode 100644 app/models/user/create-user.ts create mode 100644 app/models/user/delete-user.ts create mode 100644 app/models/user/get-user.ts create mode 100644 app/models/user/update-user.ts create mode 100644 app/root.css create mode 100644 app/root.tsx create mode 100644 app/routes/_layout+/_index.tsx create mode 100644 app/routes/_layout+/_layout.tsx create mode 100644 app/routes/_layout+/account.tsx create mode 100644 app/routes/_layout+/checkout.tsx create mode 100644 app/routes/_layout+/login.email.tsx create mode 100644 app/routes/_layout+/login.index.tsx create mode 100644 app/routes/_layout+/login.tsx create mode 100644 app/routes/_layout+/plans.tsx create mode 100644 app/routes/_layout+/register.name.tsx create mode 100644 app/routes/_layout+/register.tsx create mode 100644 app/routes/_layout+/support.tsx create mode 100644 app/routes/api+/healthcheck.ts create mode 100644 app/routes/api+/webhook.ts create mode 100644 app/routes/auth+/$provider.callback.tsx create mode 100644 app/routes/auth+/$provider.tsx create mode 100644 app/routes/auth+/logout.tsx create mode 100644 app/routes/auth+/magic.tsx create mode 100644 app/routes/resources+/stripe.create-checkout.ts create mode 100644 app/routes/resources+/stripe.create-customer-portal.ts create mode 100644 app/routes/resources+/stripe.create-customer.ts create mode 100644 app/routes/resources+/stripe.create-subscription.ts create mode 100644 app/routes/resources+/user.delete.ts create mode 100644 app/services/auth/config.server.ts create mode 100644 app/services/auth/session.server.ts create mode 100644 app/services/email/config.server.ts create mode 100644 app/services/stripe/api/configure-customer-portal.ts create mode 100644 app/services/stripe/api/create-checkout.ts create mode 100644 app/services/stripe/api/create-customer-portal.ts create mode 100644 app/services/stripe/api/create-customer.ts create mode 100644 app/services/stripe/api/create-price.ts create mode 100644 app/services/stripe/api/create-product.ts create mode 100644 app/services/stripe/api/create-subscription.ts create mode 100644 app/services/stripe/api/delete-customer.ts create mode 100644 app/services/stripe/api/retrieve-subscription.ts create mode 100644 app/services/stripe/config.server.ts create mode 100644 app/services/stripe/plans.ts create mode 100644 app/utils/date.ts create mode 100644 app/utils/db.ts create mode 100644 app/utils/envs.ts create mode 100644 app/utils/hooks.ts create mode 100644 app/utils/http.ts create mode 100644 app/utils/locales.ts create mode 100644 fly.toml create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 postcss.config.js create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 prisma/seed/app/models/plan/get-plan.js create mode 100644 prisma/seed/app/services/stripe/api/configure-customer-portal.js create mode 100644 prisma/seed/app/services/stripe/api/create-price.js create mode 100644 prisma/seed/app/services/stripe/api/create-product.js create mode 100644 prisma/seed/app/services/stripe/config.server.js create mode 100644 prisma/seed/app/services/stripe/plans.js create mode 100644 prisma/seed/app/utils/db.js create mode 100644 prisma/seed/prisma/seed.js create mode 100644 public/favicon.ico create mode 100644 remix.config.js create mode 100644 remix.env.d.ts create mode 100644 remix.init/.gitignore create mode 100644 remix.init/index.js create mode 100644 remix.init/lib/postgres/.env.example create mode 100644 remix.init/lib/postgres/Dockerfile create mode 100644 remix.init/lib/postgres/deploy.yml create mode 100644 remix.init/lib/postgres/docker-compose.yml create mode 100644 remix.init/lib/postgres/fly.toml create mode 100644 remix.init/package.json create mode 100755 start.sh create mode 100644 tailwind.config.js create mode 100644 tests/e2e/app.spec.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.seed.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..709870d9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +/node_modules +*.log +.DS_Store +.env +/.cache +/public/build +/build \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..a8a2acb3 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Base. +NODE_ENV="development" +SESSION_SECRET="SESSION_SECRET" +ENCRYPTION_SECRET="ENCRYPTION_SECRET" + +# Host. +DEV_HOST_URL="http://localhost:3000" +PROD_HOST_URL="https://example.com" + +# Environment variables declared in this file are automatically made available to Prisma. +# More details: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema +DATABASE_URL="file:./data.db?connection_limit=1" + +# [Optional] Social Authentification. +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" + +# [Optional] Email Provider. +EMAIL_PROVIDER_API_KEY="" + +# Stripe. +STRIPE_PUBLIC_KEY="STRIPE_PUBLIC_KEY" +STRIPE_SECRET_KEY="STRIPE_SECRET_KEY" + +# Stripe Webhook. +DEV_STRIPE_WEBHOOK_ENDPOINT="" +PROD_STRIPE_WEBHOOK_ENDPOINT="" \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..dba78373 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,7 @@ +/** + * @type {import('eslint').Linter.Config} + */ +module.exports = { + extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node', 'prettier'], + ignorePatterns: ['build'], +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..1db57d5c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +# Learn about Dependabot: +# https://docs.github.com/en/code-security/dependabot + +version: 2 +updates: + # Enable version updates for npm. + - package-ecosystem: 'npm' + # Look for `package.json` and `lock` files in the `root` directory. + directory: '/' + # Check the npm registry for updates every day. + schedule: + interval: 'daily' + + # Enable version updates for Github-Actions. + - package-ecosystem: github-actions + # Look in the `root` directory. + directory: / + # Check for updates every day (weekdays) + schedule: + interval: daily diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..f9b9f9f1 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,186 @@ +name: ๐Ÿš€ Deploy +on: + push: + branches: + - main + - dev + pull_request: {} + +permissions: + 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 Repository + uses: actions/checkout@v3 + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: Install Dependencies + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: Run 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 Repository + uses: actions/checkout@v3 + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: Install Dependencies + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: Run Typechecking + run: npm run typecheck --if-present + + playwright: + name: ๐ŸŽญ Playwright + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Copy Environment Variables + run: cp .env.example .env + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: Install Dependencies + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: Install Playwright Browsers + run: npx playwright install --with-deps + + - name: Setup Database + run: npx prisma migrate reset --force --skip-seed + + - name: Build + run: npm run build + + - name: Run Playwright Tests + run: npx playwright test + + - name: Upload Report + uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + + build: + name: ๐Ÿณ Build + # Only build / deploy main branch on pushes. + if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} + runs-on: ubuntu-latest + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Read App Name + uses: SebRollen/toml-action@v1.0.2 + id: app_name + with: + file: 'fly.toml' + field: 'app' + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + with: + version: v0.9.1 + + - name: Cache Docker Layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Fly Registry Auth + uses: docker/login-action@v2 + with: + registry: registry.fly.io + username: x + password: ${{ secrets.FLY_API_TOKEN }} + + - name: Docker build + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }} + build-args: | + COMMIT_SHA=${{ github.sha }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new + + - name: Move Cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + + deploy: + name: ๐Ÿš€ Deploy + runs-on: ubuntu-latest + needs: [lint, typecheck, playwright, build] + # Only build / deploy main branch on pushes. + if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} + + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Read App Name + uses: SebRollen/toml-action@v1.0.2 + id: app_name + with: + file: 'fly.toml' + field: 'app' + + - name: Deploy Production + if: ${{ github.ref == 'refs/heads/main' }} + uses: superfly/flyctl-actions@1.3 + with: + args: 'deploy --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}' + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e2108fd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Package Managers. +package-lock.json +yarn.lock +pnpm-lock.yaml +pnpm-lock.yml +node_modules + +# Editor Configs. +.idea +.vscode +.DS_Store + +# Miscelaneous. +/.cache +/build +/public/build +.env + +# Tests. +/coverage + +# Prisma. +/prisma/data.db +/prisma/data.db-journal +/prisma/migrations + +# Docker PostgreSQL. +postgres-data \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..8051a481 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +auto-install-peers=true +strict-peer-dependencies=false \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..08f7cc3c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules + +/build +/public/build +.env + +/app/styles/tailwind.css \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..bbb278a7 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,14 @@ +{ + "tabWidth": 2, + "printWidth": 90, + "semi": false, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": true, + "singleQuote": true, + "jsxSingleQuote": false, + "singleAttributePerLine": false, + "arrowParens": "always", + "trailingComma": "all", + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..8c6dfb1f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,58 @@ +# Base node image. +FROM node:16-bullseye-slim as base + +# Set global environment variables. +ENV PORT="8080" +ENV NODE_ENV="production" +ENV DATABASE_URL=file:/data/sqlite.db + +# Install openssl for Prisma. +RUN apt-get update && apt-get install -y sqlite3 + +# Install all node_modules, including dev dependencies. +FROM base as deps + +WORKDIR /myapp + +ADD package.json ./ +RUN npm install --production=false + +# Setup production node_modules. +FROM base as production-deps + +WORKDIR /myapp +COPY --from=deps /myapp/node_modules /myapp/node_modules + +ADD package.json ./ +RUN npm prune --production + +# Build the app. +FROM base as build + +WORKDIR /myapp +COPY --from=deps /myapp/node_modules /myapp/node_modules + +ADD prisma . +RUN npx prisma generate + +ADD . . +RUN npm run build + +# Finally, build the production image with minimal footprint. +FROM base + +# Add shortcut for connecting to database CLI. +RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli + +WORKDIR /myapp + +COPY --from=production-deps /myapp/node_modules /myapp/node_modules +COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma + +COPY --from=build /myapp/build /myapp/build +COPY --from=build /myapp/public /myapp/public +COPY --from=build /myapp/prisma /myapp/prisma +COPY --from=build /myapp/package.json /myapp/package.json +COPY --from=build /myapp/start.sh /myapp/start.sh + +ENTRYPOINT [ "./start.sh" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f130a4fd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Daniel Kanem + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..eece5804 --- /dev/null +++ b/README.md @@ -0,0 +1,189 @@ +![GitHub-Mark-Light](https://raw.githubusercontent.com/dev-xo/dev-xo/main/stripe-stack/assets/images/Intro-Light.png#gh-light-mode-only) +![GitHub-Mark-Dark ](https://raw.githubusercontent.com/dev-xo/dev-xo/main/stripe-stack/assets/images/Intro-Dark.png#gh-dark-mode-only) + +

+

+ Live Demo + ยท + Deployment Documentation + ยท + Twitter +
+
+ A Stripe focused Remix Stack that integrates User Subscriptions, Authentication and Testing. Driven by Prisma ORM. Deploys to Fly.io +

+

+ +## Features + +Template features are divided into two categories: **Base Features** and **Stack Features**. + +### Base Features + +- Database ORM with [Prisma.](https://www.prisma.io) +- Production Ready with [SQLite](https://sqlite.org/index.html) and [PostgreSQL.](https://www.postgresql.org) +- [Fly app Deployment](https://fly.io) with [Docker.](https://www.docker.com/products/docker-desktop) +- [GitHub Actions](https://github.com/features/actions) for Deploy on merge to Production and Staging environments. +- HealthCheck Endpoint for [Fly Backups.](https://fly.io/docs/reference/configuration/#services-http_checks) +- Styling with [TailwindCSS](https://tailwindcss.com) + [Tailwind Prettier-Plugin.](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) +- End-to-End testing with [Playwright.](https://playwright.dev) +- Linting with [ESLint.](https://eslint.org) +- Code formatting with [Prettier.](https://prettier.io) +- Static Types with [TypeScript.](https://www.typescriptlang.org) +- Support for Javascript developers based on `remix.init`. + +### Stack Features + +- [Stripe Subscriptions](https://stripe.com/docs/billing/subscriptions/overview) with support for [Billing Cycles](https://stripe.com/docs/billing/subscriptions/billing-cycle), [Multi Currency](https://stripe.com/docs/currencies) and [Customer Portal.](https://stripe.com/docs/billing/subscriptions/integrating-customer-portal) +- Authentication Ready with [Remix-Auth](https://www.npmjs.com/package/remix-auth), [Remix Auth OTP](https://github.com/dev-xo/remix-auth-otp) and [Socials Strategies.](https://github.com/TheRealFlyingCoder/remix-auth-socials) +- Flat Routes with [Remix Flat Routes.](https://github.com/kiliman/remix-flat-routes) + +Learn more about [Remix Stacks.](https://remix.run/stacks) + +> Stripe Stack v3 has been released after the integration of Supa-Stripe-Stack from [rphlmr](https://github.com/rphlmr). Special thanks to him for his great work and a big recommendation to check his [implementation](https://github.com/rphlmr/supa-stripe-stack). + +## Live Demo + +We've created a simple demo that displays all template provided features. Feel free to test it [here](https://stripe-stack-dev.fly.dev). + +[![Remix Auth OTP Stack](https://raw.githubusercontent.com/dev-xo/dev-xo/main/stripe-stack/assets/images/Thumbnail-Min.png)](https://stripe-stack-dev.fly.dev) + +Here's a simple workflow you can follow to test the template: + +1. Visit the [Live Demo](https://stripe-stack-dev.fly.dev). +2. Log in with your preferred authentication method. +3. Select a Subscription Plan and fill the Stripe Checkout inputs with its test values. _Check Notes._ + +> **Note** +> Stripe test mode uses the following number: `4242` as valid values for Card Information. +> Type it as much times as you can on each available input to successfully complete the checkout step. + +--- + +## Getting Started + +Before starting our development or even deploying our template, we'll require to setup a few things. + +## Template + +Stripe Stack has support for multiple database choices based on Prisma. The installer will prompt a selector allowing you to choose the database your project will run on. + +To get started, run the following commands in your console: + +```sh +# Initialize template into your workspace: +npx create-remix@latest --template dev-xo/stripe-stack + +# Select the database your project will run on: +> SQLite or PostgreSQL + +# Done! ๐Ÿ’ฟ Please, keep reading the documentation to Get Started. +``` + +> **Note** +> Cloning the repository instead of initializing it with the above commands, will result in a inappropriate experience. Stripe Stack uses `remix.init` to configure itself and prepare your environment. + +## Environment + +We'll require a few environment variables to get our app up and running. + +### Authentication Envs + +Stripe Stack has support for [Email Code](https://github.com/dev-xo/remix-auth-otp) _(Includes Magic Link)_ and [Socials](https://github.com/TheRealFlyingCoder/remix-auth-socials) Authentication Strategies. Feel free to visit the links to learn more about each one and how to configure them. + +### Stripe Envs + +In order to use Stripe Subscriptions and seed our database, we'll require to get the secret keys from our Stripe Dashboard. + +1. Create a [Stripe Account](https://dashboard.stripe.com/login) or use an existing one. +2. Visit [API Keys](https://dashboard.stripe.com/test/apikeys) section and copy the `Publishable` and `Secret` keys. +3. Paste each one of them into your `.env` file as `STRIPE_PUBLIC_KEY` and `STRIPE_SECRET_KEY` respectively. + +### Stripe Webhook Envs + +In order to start receiving Stripe Events to our Webhook Endpoint, we'll require to install the [Stripe CLI.](https://stripe.com/docs/stripe-cli) Once installed run the following command in your console: + +```sh +stripe listen --forward-to localhost:3000/api/webhook +``` + +This should give you a Webhook Secret Key. Copy and paste it into your `.env` file as `DEV_STRIPE_WEBHOOK_ENDPOINT`. + +> **Note** +> This command should be running in your console while developing. + +## Database + +Before starting our development, we'll require to setup our Prisma Migrations. First, ensure that your Prisma Schema is configured accordingly to your needs. Check `/prisma/schema.prisma` to learn more about it. + +Once you're done, run the following command in your console: + +```sh +npx prisma migrate dev --name init --skip-seed +``` + +### Seeding Database + +Now that we have our database initialized, we'll require to seed it with our Stripe Plans. Check `/services/stripe/plans` to learn more about it. + +Once you're done, run the following command in your console: + +```sh +npx prisma db seed +``` + +> **Warning** +> You'll require to have your Stripe Envs already configured and no Stripe Products created with the same `id` as the ones in `/services/stripe/plans`. + +## Development Server + +Now that we have everything configured, we can start our development server. Run the following command in your console: + +```sh +npm run dev +``` + +You should be able to access your app at ๐ŸŽ‰ [http://localhost:3000](http://localhost:3000). + +## Deployment + +Stripe Stack has support for SQLite and PostgreSQL databases. In order to keep a better track and an easier maintenance of each deployment documentation, we moved each one to its own file. + +Visit the [Deployment Docs](https://github.com/dev-xo/dev-xo/tree/main/stripe-stack/docs) in order to get your app to production. + +## GitHub Actions + +GitHub Actions are used for continuous integration and deployment. Anything that gets into the `main` branch will be deployed to production after running tests, build, etc. Anything in the `dev` branch will be deployed to staging. + +## Testing + +### Playwright + +We use Playwright for our End-to-End tests. You'll find those in `tests/e2e` directory. +To run your tests in development use `npm run test:e2e:dev`. + +### Type Checking + +It's recommended to get TypeScript set up for your editor _(if your template uses it)_ to get a really great in-editor experience with type checking and auto-complete. To run type checking across the whole project use `npm run typecheck`. + +### Linting + +This project uses ESLint for linting. That is configured in `.eslintrc.js`. +To run linting across the whole project use `npm run lint`. + +### Formatting + +We use [Prettier](https://prettier.io/) for auto-formatting. It's recommended to install an editor plugin to get auto-formatting on save. +To run formatting across the whole project use `npm run format`. + +This template has pre-configured prettier settings inside `.prettierrc`. +Feel free to update each value with your preferred work style and tun the above command to format your project. + +## Support + +If you find this template useful, support it with a [Star โญ](https://github.com/dev-xo/stripe-stack)
+It helps the repository grow and gives me motivation to keep working on it. Thank you! + +## License + +Licensed under the [MIT License](https://github.com/dev-xo/stripe-stack/blob/main/LICENSE). diff --git a/app/components/footer.tsx b/app/components/footer.tsx new file mode 100644 index 00000000..6fdc9f5e --- /dev/null +++ b/app/components/footer.tsx @@ -0,0 +1,55 @@ +import { Link, useLocation } from '@remix-run/react' + +export function Footer() { + const location = useLocation() + + return location.pathname !== '/support' ? ( + + ) : ( + + ) +} diff --git a/app/components/navigation.tsx b/app/components/navigation.tsx new file mode 100644 index 00000000..f1ca67e2 --- /dev/null +++ b/app/components/navigation.tsx @@ -0,0 +1,114 @@ +import type { User } from '@prisma/client' +import { Link, Form, useLocation } from '@remix-run/react' + +type NavigationProps = { + user: User | null +} + +export function Navigation({ user }: NavigationProps) { + const location = useLocation() + + return ( +
+ {/* Left Menu. */} + + Stripe Stack +
+ + v3.2 + + + + {/* Right Menu. */} +
+ + Plans + +
+ + + Docs + + + {!user && + location && + (location.pathname === '/' || location.pathname === '/plans') && ( + <> +
+ + Log In + + + )} + + {/* Log Out Form Button. */} + {user && ( + <> +
+
+ +
+ + )} + + {/* Divider. */} + + + {/* Socials. */} + +
+ ) +} diff --git a/app/components/stripe/checkout-button.tsx b/app/components/stripe/checkout-button.tsx new file mode 100644 index 00000000..aa06aba2 --- /dev/null +++ b/app/components/stripe/checkout-button.tsx @@ -0,0 +1,57 @@ +import type { Plan } from '@prisma/client' +import type { Interval } from '~/services/stripe/plans' + +import { PlanId } from '~/services/stripe/plans' +import { useFetcher } from '@remix-run/react' + +type CheckoutButtonProps = { + currentPlanId: Plan['id'] | null + planId: Plan['id'] + planName: Plan['name'] + planInterval: Interval | string +} + +export function CheckoutButton({ + currentPlanId, + planId, + planName, + planInterval, +}: CheckoutButtonProps) { + const fetcher = useFetcher() + const isLoading = fetcher.state !== 'idle' + + const buttonClassName = () => { + switch (planId) { + case PlanId.FREE: + return 'bg-yellow-500 hover:bg-yellow-400' + case PlanId.STARTER: + return 'bg-green-500 hover:bg-green-400' + case PlanId.PRO: + return 'bg-violet-500 hover:bg-violet-400' + } + } + + if (planId === currentPlanId) { + return ( + + ) + } + + return ( + + + + ) +} diff --git a/app/components/stripe/customer-portal-button.tsx b/app/components/stripe/customer-portal-button.tsx new file mode 100644 index 00000000..55dcea3d --- /dev/null +++ b/app/components/stripe/customer-portal-button.tsx @@ -0,0 +1,16 @@ +import { useFetcher } from '@remix-run/react' + +export function CustomerPortalButton() { + const fetcher = useFetcher() + const isLoading = fetcher.state !== 'idle' + + return ( + + + + ) +} diff --git a/app/entry.client.tsx b/app/entry.client.tsx new file mode 100644 index 00000000..8338545d --- /dev/null +++ b/app/entry.client.tsx @@ -0,0 +1,22 @@ +import { RemixBrowser } from "@remix-run/react"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; + +function hydrate() { + startTransition(() => { + hydrateRoot( + document, + + + + ); + }); +} + +if (typeof requestIdleCallback === "function") { + requestIdleCallback(hydrate); +} else { + // Safari doesn't support requestIdleCallback + // https://caniuse.com/requestidlecallback + setTimeout(hydrate, 1); +} diff --git a/app/entry.server.tsx b/app/entry.server.tsx new file mode 100644 index 00000000..3885e77f --- /dev/null +++ b/app/entry.server.tsx @@ -0,0 +1,109 @@ +import type { EntryContext } from '@remix-run/node' + +import { PassThrough } from 'stream' +import { Response } from '@remix-run/node' +import { RemixServer } from '@remix-run/react' +import { renderToPipeableStream } from 'react-dom/server' +import { getSharedEnvs } from './utils/envs' + +import isbot from 'isbot' + +/** + * Global Shared Envs. + */ +global.ENV = getSharedEnvs() + +const ABORT_DELAY = 5000 + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return isbot(request.headers.get('user-agent')) + ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext) + : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext) +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let didError = false + + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + const body = new PassThrough() + + responseHeaders.set('Content-Type', 'text/html') + + resolve( + new Response(body, { + headers: responseHeaders, + status: didError ? 500 : responseStatusCode, + }), + ) + + pipe(body) + }, + onShellError(error: unknown) { + reject(error) + }, + onError(error: unknown) { + didError = true + + console.error(error) + }, + }, + ) + + setTimeout(abort, ABORT_DELAY) + }) +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let didError = false + + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + const body = new PassThrough() + + responseHeaders.set('Content-Type', 'text/html') + + resolve( + new Response(body, { + headers: responseHeaders, + status: didError ? 500 : responseStatusCode, + }), + ) + + pipe(body) + }, + onShellError(err: unknown) { + reject(err) + }, + onError(error: unknown) { + didError = true + + console.error(error) + }, + }, + ) + + setTimeout(abort, ABORT_DELAY) + }) +} diff --git a/app/models/plan/create-plan.ts b/app/models/plan/create-plan.ts new file mode 100644 index 00000000..4a911b91 --- /dev/null +++ b/app/models/plan/create-plan.ts @@ -0,0 +1,8 @@ +import type { Plan } from '@prisma/client' +import { db } from '~/utils/db' + +export async function createPlan(plan: Omit) { + return db.plan.create({ + data: { ...plan }, + }) +} diff --git a/app/models/plan/delete-plan.ts b/app/models/plan/delete-plan.ts new file mode 100644 index 00000000..f01f575d --- /dev/null +++ b/app/models/plan/delete-plan.ts @@ -0,0 +1,8 @@ +import type { Plan } from '@prisma/client' +import { db } from '~/utils/db' + +export async function deletePlanById(id: Plan['id']) { + return db.plan.delete({ + where: { id }, + }) +} diff --git a/app/models/plan/get-plan.ts b/app/models/plan/get-plan.ts new file mode 100644 index 00000000..c539bef2 --- /dev/null +++ b/app/models/plan/get-plan.ts @@ -0,0 +1,21 @@ +import type { Prisma, Plan } from '@prisma/client' +import { db } from '~/utils/db' + +export async function getPlanById(id: Plan['id'], include?: Prisma.PlanInclude) { + return db.plan.findUnique({ + where: { id }, + include: { + ...include, + prices: include?.prices || false, + }, + }) +} + +export async function getAllPlans(include?: Prisma.PlanInclude) { + return db.plan.findMany({ + include: { + ...include, + prices: include?.prices || false, + }, + }) +} diff --git a/app/models/plan/update-plan.ts b/app/models/plan/update-plan.ts new file mode 100644 index 00000000..49834457 --- /dev/null +++ b/app/models/plan/update-plan.ts @@ -0,0 +1,9 @@ +import type { Plan } from '@prisma/client' +import { db } from '~/utils/db' + +export async function updatePlanById(id: Plan['id'], plan: Partial) { + return db.plan.update({ + where: { id }, + data: { ...plan }, + }) +} diff --git a/app/models/subscription/create-subscription.ts b/app/models/subscription/create-subscription.ts new file mode 100644 index 00000000..345912b2 --- /dev/null +++ b/app/models/subscription/create-subscription.ts @@ -0,0 +1,10 @@ +import type { Subscription } from '@prisma/client' +import { db } from '~/utils/db' + +export async function createSubscription( + subscription: Omit, +) { + return db.subscription.create({ + data: { ...subscription }, + }) +} diff --git a/app/models/subscription/delete-subscription.ts b/app/models/subscription/delete-subscription.ts new file mode 100644 index 00000000..0309cfb3 --- /dev/null +++ b/app/models/subscription/delete-subscription.ts @@ -0,0 +1,8 @@ +import type { Subscription } from '@prisma/client' +import { db } from '~/utils/db' + +export async function deleteSubscriptionById(id: Subscription['id']) { + return db.subscription.delete({ + where: { id }, + }) +} diff --git a/app/models/subscription/get-subscription.ts b/app/models/subscription/get-subscription.ts new file mode 100644 index 00000000..2749dda7 --- /dev/null +++ b/app/models/subscription/get-subscription.ts @@ -0,0 +1,14 @@ +import type { User, Subscription } from '@prisma/client' +import { db } from '~/utils/db' + +export async function getSubscriptionById(id: Subscription['id']) { + return db.subscription.findUnique({ + where: { id }, + }) +} + +export async function getSubscriptionByUserId(userId: User['id']) { + return db.subscription.findUnique({ + where: { userId }, + }) +} diff --git a/app/models/subscription/update-subscription.ts b/app/models/subscription/update-subscription.ts new file mode 100644 index 00000000..8b44e3f9 --- /dev/null +++ b/app/models/subscription/update-subscription.ts @@ -0,0 +1,22 @@ +import type { Subscription } from '@prisma/client' +import { db } from '~/utils/db' + +export async function updateSubscriptionById( + id: Subscription['id'], + subscription: Partial, +) { + return db.subscription.update({ + where: { id }, + data: { ...subscription }, + }) +} + +export async function updateSubscriptionByUserId( + userId: Subscription['userId'], + subscription: Partial, +) { + return db.subscription.update({ + where: { userId }, + data: { ...subscription }, + }) +} diff --git a/app/models/user/create-user.ts b/app/models/user/create-user.ts new file mode 100644 index 00000000..7c143ee1 --- /dev/null +++ b/app/models/user/create-user.ts @@ -0,0 +1,8 @@ +import type { User } from '@prisma/client' +import { db } from '~/utils/db' + +export async function createUser(user: Pick) { + return db.user.create({ + data: { ...user }, + }) +} diff --git a/app/models/user/delete-user.ts b/app/models/user/delete-user.ts new file mode 100644 index 00000000..d46518d2 --- /dev/null +++ b/app/models/user/delete-user.ts @@ -0,0 +1,8 @@ +import type { User } from '@prisma/client' +import { db } from '~/utils/db' + +export async function deleteUserById(id: User['id']) { + return db.user.delete({ + where: { id }, + }) +} diff --git a/app/models/user/get-user.ts b/app/models/user/get-user.ts new file mode 100644 index 00000000..ce515e94 --- /dev/null +++ b/app/models/user/get-user.ts @@ -0,0 +1,22 @@ +import type { User } from '@prisma/client' +import { db } from '~/utils/db' + +export async function getUserById(id: User['id']) { + return db.user.findUnique({ + where: { id }, + }) +} + +export async function getUserByEmail(email: User['email']) { + return db.user.findUnique({ + where: { email }, + }) +} + +export async function getUserByCustomerId(customerId: User['customerId']) { + if (!customerId) throw new Error('Missing required parameters to retrieve User.') + + return db.user.findUnique({ + where: { customerId }, + }) +} diff --git a/app/models/user/update-user.ts b/app/models/user/update-user.ts new file mode 100644 index 00000000..3f245f4b --- /dev/null +++ b/app/models/user/update-user.ts @@ -0,0 +1,9 @@ +import type { User } from '@prisma/client' +import { db } from '~/utils/db' + +export async function updateUserById(id: User['id'], user: Partial) { + return db.user.update({ + where: { id }, + data: { ...user }, + }) +} diff --git a/app/root.css b/app/root.css new file mode 100644 index 00000000..e6dffa09 --- /dev/null +++ b/app/root.css @@ -0,0 +1,122 @@ +/* +* This file will initialize TailwindCSS. +* Docs: https://tailwindcss.com/docs/guides/remix +*/ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* +* Custom Body. +*/ +html { + scroll-behavior: smooth; +} + +body { + color: #e5e7eb; + background-color: #0d1117; + + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +textarea:focus, +input:focus { + outline: none; +} + +/* +* Custom Scrollbar. +*/ +:root { + scrollbar-color: rgba(255, 255, 255, 0.2) transparent; +} + +::-webkit-scrollbar { + width: 8px; + background-color: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 8px; +} + +/* +* Custom Selection Background. +*/ +::-moz-selection { + color: #fff; + background: #7b61ff; +} + +::selection { + color: #fff; + background: #7b61ff; +} + +/* +* Custom Background. +*/ +.blobs { + z-index: -1; + max-width: 640px; + background-image: radial-gradient(at 27% 37%, #000 0, transparent 0), + radial-gradient(at 97% 21%, #8b5cf6 0, transparent 50%), + radial-gradient(at 52% 99%, #6f16ff 0, transparent 50%), + radial-gradient(at 10% 29%, #8b5cf6 0, transparent 50%), + radial-gradient(at 97% 96%, #000 0, transparent 50%), + radial-gradient(at 33% 50%, #000 0, transparent 50%), + radial-gradient(at 79% 53%, #000 0, transparent 50%); + position: absolute; + content: ''; + width: 50%; + height: 50%; + filter: blur(100px) saturate(150%); + top: 25%; + opacity: 0.4; +} + +/* +* Animations. +*/ +@keyframes pulse { + from { + transform: scale3d(1, 1, 1); + } + + 50% { + transform: scale3d(1.2, 1.2, 1.2); + } + + to { + transform: scale3d(1, 1, 1); + } +} + +@keyframes float { + 0% { + transform: translatey(0px); + } + 50% { + transform: translatey(-20px); + } + 100% { + transform: translatey(0px); + } +} + +.pulse { + animation-name: pulse; + animation-duration: 4s; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; +} + +.float { + animation-name: float; + animation-duration: 6s; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; +} diff --git a/app/root.tsx b/app/root.tsx new file mode 100644 index 00000000..17fa4fe4 --- /dev/null +++ b/app/root.tsx @@ -0,0 +1,77 @@ +import type { LinksFunction, MetaFunction, DataFunctionArgs } from '@remix-run/node' +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, +} from '@remix-run/react' + +import { getSharedEnvs } from './utils/envs' +import TailwindCSS from './root.css' + +export const links: LinksFunction = () => { + return [{ rel: 'stylesheet', href: TailwindCSS }] +} + +export const meta: MetaFunction = () => { + return { + viewport: 'width=device-width, initial-scale=1', + charset: 'utf-8', + title: 'Stripe Stack - Remix', + description: `A Stripe focused Remix Stack that integrates User Subscriptions, + Authentication and Testing. Driven by Prisma ORM. Deploys to Fly.io`, + keywords: + 'remix, stripe, remix-stack, typescript, sqlite, postgresql, prisma, tailwindcss, fly.io', + 'og:title': 'Stripe Stack - Remix', + 'og:type': 'website', + 'og:url': 'https://stripe-stack.fly.dev', + 'og:image': + 'https://raw.githubusercontent.com/dev-xo/dev-xo/main/stripe-stack/assets/images/Stripe-Thumbnail.png', + 'og:card': 'summary_large_image', + 'og:creator': '@DanielKanem', + 'og:site': 'https://stripe-stack.fly.dev', + 'og:description': `A Stripe focused Remix Stack that integrates User Subscriptions, + Authentication and Testing. Driven by Prisma ORM. Deploys to Fly.io`, + 'twitter:image': + 'https://raw.githubusercontent.com/dev-xo/dev-xo/main/stripe-stack/assets/images/Stripe-Thumbnail.png', + 'twitter:card': 'summary_large_image', + 'twitter:creator': '@DanielKanem', + 'twitter:title': 'Stripe Stack - Remix', + 'twitter:description': `A Stripe focused Remix Stack that integrates User Subscriptions, + Authentication and Testing. Driven by Prisma ORM. Deploys to Fly.io`, + } +} + +export function loader({ request }: DataFunctionArgs) { + return { ENV: getSharedEnvs() } +} + +export default function App() { + const { ENV } = useLoaderData() + + return ( + + + + + + + + + + + {/* Global Shared Envs. */} +