diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..df91955 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.env +/.idea +/cypress/videos +/cypress/screenshots +/coverage + +# build artifacts +/.cache +/public/build +/build +server.js \ No newline at end of file diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index ad8f0ba..0000000 --- a/.editorconfig +++ /dev/null @@ -1,11 +0,0 @@ -# https://EditorConfig.org - -root = true - -[*] -charset = utf-8 -end_of_line = lf -indent_size = 4 -indent_style = tab -insert_final_newline = true -trim_trailing_whitespace = true diff --git a/.env.e2e b/.env.e2e new file mode 100644 index 0000000..de05b41 --- /dev/null +++ b/.env.e2e @@ -0,0 +1 @@ +DATABASE_URL=postgresql://pgremixtape:pgpassword@localhost:5432/remixtape_e2e diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..42d6a71 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +APP_BASE_URL=http://localhost:3000 + +INVITATION_TOKEN_SECRET=0ded075524fd19fe467eb00480b8d5d4 +SESSION_SECRET=754a554f4cbf9254e50fda87b48ee52b + +POSTGRES_USER=pgremixtape +POSTGRES_PASSWORD=pgpassword +POSTGRES_DB=remixtape +DATABASE_URL=postgresql://pgremixtape:pgpassword@localhost:5432/remixtape + +REDIS_URL=redis://localhost:6379 +REDIS_PASSWORD= + +# Grab those from https://console.aws.amazon.com/ses/home +AWS_SES_REGION=eu-central-1 +AWS_SES_ACCESS_KEY_ID=TODO +AWS_SES_ACCESS_KEY_SECRET=TODO +AWS_SES_FROM_EMAIL=remixtape@fake.app + +# Grab those from https://dashboard.stripe.com/ +STRIPE_SECRET_API_KEY=sk_TODO +STRIPE_MONTHLY_PRICE_ID=price_TODO +STRIPE_YEARLY_PRICE_ID=price_TODO +# Grab this one from the Stripe CLI logs, within the `stripe` container if you're using Docker +STRIPE_WEBHOOK_SECRET=whsec_TODO diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 65ce98a..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - extends: ["blitz"], -}; diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f2a491c..71ee2cb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,91 +1,84 @@ -name: Deployment pipeline +name: CI on: [push, pull_request] jobs: lint: name: Lint - timeout-minutes: 4 runs-on: ubuntu-latest - env: - HUSKY_SKIP_INSTALL: 1 steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: node-version: 16 + cache: "npm" - run: npm ci - run: npm run lint - test: - if: false == true - name: Test - timeout-minutes: 4 + e2e: + name: E2E tests runs-on: ubuntu-latest + services: + postgres: + image: postgres:13-alpine + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + POSTGRES_USER: pgremixtape + POSTGRES_PASSWORD: pgpassword + POSTGRES_DB: remixtape + ports: + - "5432:5432" + redis: + image: redis:6-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - "6379:6379" env: - HUSKY_SKIP_INSTALL: 1 - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: 16 - - run: npm ci - - run: npm test - - build: - name: Compile - timeout-minutes: 6 - runs-on: ubuntu-latest - env: - HUSKY_SKIP_INSTALL: 1 + DATABASE_URL: postgresql://pgremixtape:pgpassword@localhost:5432/remixtape + REDIS_URL: redis://localhost:6379 + CI: true steps: - uses: actions/checkout@v2 + - run: cp .env.example .env - uses: actions/setup-node@v2 with: node-version: 16 + cache: "npm" - run: npm ci + - run: npm run db:setup - run: npm run build - env: - DATOCMS_API_TOKEN: ${{ secrets.DATOCMS_API_TOKEN }} - QUIRREL_BASE_URL: doesntmatter.shellphone.app + - run: npx dotenv npm run e2e:ci - deploy_dev: + typecheck: + name: Typecheck + runs-on: ubuntu-latest + env: + HUSKY_SKIP_INSTALL: 1 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: 16 + - run: npm ci + - run: npx tsc + + deploy: if: github.ref == 'refs/heads/master' - needs: [lint, test, build] - name: Deploy dev.shellphone.app + needs: [lint, e2e, typecheck] + name: Deploy to Fly.io runs-on: ubuntu-latest - env: - HUSKY_SKIP_INSTALL: 1 steps: - uses: actions/checkout@v2 - uses: superfly/flyctl-actions@master env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} with: - args: "deploy -c ./fly.dev.toml --build-arg PANELBEAR_SITE_ID=${{ secrets.PANELBEAR_SITE_ID_DEV }} --build-arg DATOCMS_API_TOKEN=${{ secrets.DATOCMS_API_TOKEN }}" - - uses: appleboy/discord-action@master - with: - webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }} - webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }} - args: "https://dev.shellphone.app deployed with commit `${{ github.event.head_commit.message }}` (`${{ github.sha }}`) from branch `${{ github.ref }}`" - - deploy_prod: - if: github.ref == 'refs/heads/production' - needs: [lint, test, build] - name: Deploy www.shellphone.app - runs-on: ubuntu-latest - env: - HUSKY_SKIP_INSTALL: 1 - steps: - - uses: actions/checkout@v2 - - uses: superfly/flyctl-actions@master - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - with: - args: "deploy -c ./fly.prod.toml --build-arg PANELBEAR_SITE_ID=${{ secrets.PANELBEAR_SITE_ID_PROD }} --build-arg DATOCMS_API_TOKEN=${{ secrets.DATOCMS_API_TOKEN }}" - - uses: appleboy/discord-action@master - with: - webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }} - webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }} - args: "https://www.shellphone.app deployed with commit `${{ github.event.head_commit.message }}` (`${{ github.sha }}`) from branch `${{ github.ref }}`" -# TODO: on pull_request, deploy 24hour-long deployment at {commit_short_hash}.shellphone.app, provision db and seed data + args: "deploy --strategy rolling" diff --git a/.gitignore b/.gitignore index d7e1864..fff689d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,55 +1,16 @@ -# dependencies node_modules -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.pnp.* -.npm -web_modules/ -# blitz -/.blitz/ -/.next/ -*.sqlite -*.sqlite-journal -.now -.blitz** -blitz-log.log +/.cache +/server/build +/public/build +/build +server.js +/app/styles/tailwind.css -# misc -.DS_Store +/.idea -# local env files -.env.local -.env.*.local -.envrc +.env -# Logs -logs -*.log - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Testing -.coverage -*.lcov -.nyc_output -lib-cov - -# Caches -*.tsbuildinfo -.eslintcache -.node_repl_history -.yarn-integrity - -# Serverless directories -.serverless/ - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -.idea/ +/cypress/videos +/cypress/screenshots +/coverage \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index dd4268e..36af219 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,4 +2,3 @@ . "$(dirname "$0")/_/husky.sh" npx lint-staged -npx pretty-quick --staged diff --git a/.husky/pre-push b/.husky/pre-push deleted file mode 100755 index 7ed3bdb..0000000 --- a/.husky/pre-push +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npx tsc -npm run lint -#npm run test diff --git a/.npmrc b/.npmrc index 1b78f1c..cffe8cd 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1 @@ save-exact=true -legacy-peer-deps=true diff --git a/.prettierignore b/.prettierignore index d26a413..7a2a23f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,8 +1,6 @@ .gitkeep -.env* -*.ico -*.lock -db/migrations -.next -.blitz -mailers/**/*.html +.env +.cache +build +package-lock.json +app/tailwind.css \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 900a577..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "recommendations": [ - "dbaeumer.vscode-eslint", - "editorconfig.editorconfig", - "esbenp.prettier-vscode", - "mikestead.dotenv", - "mgmcdermott.vscode-language-babel", - "orta.vscode-jest", - "prisma.prisma" - ], - "unwantedRecommendations": [] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 8d19091..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.fixAll.eslint": true - } -} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..080f4ad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# base node image +FROM node:16-bullseye-slim as base + +# set for base and all layer that inherit from it +ENV NODE_ENV=production + +# Install openssl for Prisma +RUN apt-get update && apt-get install -y openssl + +# Install all node_modules, including dev dependencies +FROM base as deps + +RUN mkdir /app +WORKDIR /app + +ADD package.json package-lock.json ./ +RUN npm install --production=false + +# Setup production node_modules +FROM base as production-deps + +RUN mkdir /app +WORKDIR /app + +COPY --from=deps /app/node_modules /app/node_modules +ADD package.json package-lock.json ./ +RUN npm prune --production + +# Build the app +FROM base as build + +RUN mkdir /app +WORKDIR /app + +COPY --from=deps /app/node_modules /app/node_modules + +# Cache the prisma schema +ADD prisma . +RUN npx prisma generate + +ADD . . +RUN npm run build + +# Finally, build the production image with minimal footprint +FROM base + +RUN mkdir /app +WORKDIR /app + +COPY --from=production-deps /app/node_modules /app/node_modules +COPY --from=build /app/node_modules/.prisma /app/node_modules/.prisma +COPY --from=build /app/build /app/build +COPY --from=build /app/public /app/public +COPY --from=build /app/server.js /app/server.js +ADD . . + +CMD ["npm", "run", "start"] diff --git a/app/api/ddd.ts b/app/api/ddd.ts deleted file mode 100644 index 5ff1e9a..0000000 --- a/app/api/ddd.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { BlitzApiRequest, BlitzApiResponse } from "blitz"; - -import db from "db"; -import twilio from "twilio"; -import setTwilioWebhooks from "../settings/api/queue/set-twilio-webhooks"; -import backup from "../../db/backup"; -import { sendEmail } from "../../integrations/aws-ses"; - -export default async function ddd(req: BlitzApiRequest, res: BlitzApiResponse) { - /*await Promise.all([ - db.message.deleteMany(), - db.phoneCall.deleteMany(), - db.phoneNumber.deleteMany(), - ]); - await db.customer.deleteMany(); - await db.user.deleteMany();*/ - - const accountSid = "ACa886d066be0832990d1cf43fb1d53362"; - const authToken = "8696a59a64b94bb4eba3548ed815953b"; - /*const ddd = await twilio(accountSid, authToken) - .lookups - .v1 - // .phoneNumbers("+33613370787") - .phoneNumbers("+33476982071") - .fetch();*/ - /*try { - await twilio(accountSid, authToken).messages.create({ - body: "content", - to: "+213744123789", - from: "+33757592025", - }); - } catch (error) { - console.log(error.code); - console.log(error.moreInfo); - console.log(error.details); - // console.log(JSON.stringify(Object.keys(error))); - }*/ - /*const ddd = await twilio(accountSid, authToken).messages.create({ - body: "cccccasdasd", - to: "+33757592025", - from: "+33757592722", - });*/ - /*const [messagesSent, messagesReceived] = await Promise.all([ - twilio(accountSid, authToken).messages.list({ - from: "+33757592025", - }), - twilio(accountSid, authToken).messages.list({ - to: "+33757592025", - }), - ]); - - console.log("messagesReceived", messagesReceived.sort((a, b) => a.dateCreated.getTime() - b.dateCreated.getTime())); - // console.log("messagesReceived", messagesReceived);*/ - - /*setTwilioWebhooks.enqueue({ - phoneNumberId: "PNb77c9690c394368bdbaf20ea6fe5e9fc", - organizationId: "95267d60-3d35-4c36-9905-8543ecb4f174", - });*/ - /*try { - const before = Date.now(); - await backup("daily"); - console.log(`took ${Date.now() - before}ms`); - } catch (error) { - console.error("dddd error", error); - res.status(500).end(); - }*/ - - // setTimeout(() => { - res.status(200).end(); - // }, 1000 * 60 * 5); -} diff --git a/app/api/debug/cancel-subscription.ts b/app/api/debug/cancel-subscription.ts deleted file mode 100644 index 08eae7d..0000000 --- a/app/api/debug/cancel-subscription.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { BlitzApiHandler } from "blitz"; - -import { cancelPaddleSubscription } from "integrations/paddle"; -import appLogger from "integrations/logger"; - -const logger = appLogger.child({ route: "/api/debug/cancel-subscription" }); - -const cancelSubscriptionHandler: BlitzApiHandler = async (req, res) => { - const { subscriptionId } = req.body; - - logger.debug(`cancelling subscription for subscriptionId="${subscriptionId}"`); - await cancelPaddleSubscription({ subscriptionId }); - logger.debug(`cancelled subscription for subscriptionId="${subscriptionId}"`); - - res.status(200).end(); -}; - -export default cancelSubscriptionHandler; diff --git a/app/api/debug/get-payments.ts b/app/api/debug/get-payments.ts deleted file mode 100644 index 35d28d0..0000000 --- a/app/api/debug/get-payments.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { BlitzApiHandler } from "blitz"; - -import { getPayments } from "integrations/paddle"; -import appLogger from "integrations/logger"; -import db from "db"; - -const logger = appLogger.child({ route: "/api/debug/cancel-subscription" }); - -const cancelSubscriptionHandler: BlitzApiHandler = async (req, res) => { - const { organizationId } = req.body; - - logger.debug(`fetching payments for organizationId="${organizationId}"`); - const subscriptions = await db.subscription.findMany({ where: { organizationId } }); - if (subscriptions.length === 0) { - res.status(200).send([]); - } - console.log("subscriptions", subscriptions); - - const paymentsBySubscription = await Promise.all( - subscriptions.map((subscription) => getPayments({ subscriptionId: subscription.paddleSubscriptionId })), - ); - const payments = paymentsBySubscription.flat(); - const result = Array.from(payments).sort((a, b) => b.payout_date.localeCompare(a.payout_date)); - logger.debug(result); - - res.status(200).send(result); -}; - -export default cancelSubscriptionHandler; diff --git a/app/api/debug/get-subscription.ts b/app/api/debug/get-subscription.ts deleted file mode 100644 index d3c9652..0000000 --- a/app/api/debug/get-subscription.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { BlitzApiHandler } from "blitz"; - -import db from "db"; -import appLogger from "integrations/logger"; - -const logger = appLogger.child({ route: "/api/debug/get-subscription" }); - -const cancelSubscriptionHandler: BlitzApiHandler = async (req, res) => { - const { organizationId } = req.body; - - logger.debug(`fetching subscription for organizationId="${organizationId}"`); - const subscription = await db.subscription.findFirst({ where: { organizationId } }); - console.debug(subscription); - - res.status(200).send(subscription); -}; - -export default cancelSubscriptionHandler; diff --git a/app/auth/components/auth-form.tsx b/app/auth/components/auth-form.tsx deleted file mode 100644 index aaa0da7..0000000 --- a/app/auth/components/auth-form.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { useState, ReactNode, PropsWithoutRef } from "react"; -import { FormProvider, useForm, UseFormProps } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import clsx from "clsx"; - -import Alert from "app/core/components/alert"; -import Logo from "app/core/components/logo"; - -export interface FormProps> - extends Omit, "onSubmit"> { - /** All your form fields */ - children?: ReactNode; - texts: { - title: string; - subtitle: ReactNode; - submit: string; - }; - schema?: S; - onSubmit: (values: z.infer) => Promise; - initialValues?: UseFormProps>["defaultValues"]; -} - -interface OnSubmitResult { - FORM_ERROR?: string; - - [prop: string]: any; -} - -export const FORM_ERROR = "FORM_ERROR"; - -export function AuthForm>({ - children, - texts, - schema, - initialValues, - onSubmit, - ...props -}: FormProps) { - const ctx = useForm>({ - mode: "onBlur", - resolver: schema ? zodResolver(schema) : undefined, - defaultValues: initialValues, - }); - const [formError, setFormError] = useState(null); - - return ( -
-
- -

{texts.title}

-

{texts.subtitle}

-
- -
- -
{ - const result = (await onSubmit(values)) || {}; - for (const [key, value] of Object.entries(result)) { - if (key === FORM_ERROR) { - setFormError(value); - } else { - ctx.setError(key as any, { - type: "submit", - message: value, - }); - } - } - })} - className="form" - {...props} - > - {formError ? ( -
- -
- ) : null} - - {children} - - {texts.submit ? ( - - ) : null} -
-
-
-
- ); -} - -export default AuthForm; diff --git a/app/auth/components/labeled-text-field.tsx b/app/auth/components/labeled-text-field.tsx deleted file mode 100644 index 1f450b5..0000000 --- a/app/auth/components/labeled-text-field.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { forwardRef, PropsWithoutRef } from "react"; -import { Link, Routes } from "blitz"; -import { useFormContext } from "react-hook-form"; -import clsx from "clsx"; - -export interface LabeledTextFieldProps extends PropsWithoutRef { - /** Field name. */ - name: string; - /** Field label. */ - label: string; - /** Field type. Doesn't include radio buttons and checkboxes */ - type?: "text" | "password" | "email" | "number"; - showForgotPasswordLabel?: boolean; -} - -export const LabeledTextField = forwardRef( - ({ label, name, showForgotPasswordLabel, ...props }, ref) => { - const { - register, - formState: { isSubmitting, errors }, - } = useFormContext(); - const error = Array.isArray(errors[name]) ? errors[name].join(", ") : errors[name]?.message || errors[name]; - - return ( -
- -
- -
- - {error ? ( -
- {error} -
- ) : null} -
- ); - }, -); - -export default LabeledTextField; diff --git a/app/auth/mutations/forgot-password.ts b/app/auth/mutations/forgot-password.ts deleted file mode 100644 index 7e081dd..0000000 --- a/app/auth/mutations/forgot-password.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { resolver, generateToken, hash256 } from "blitz"; - -import db, { User } from "../../../db"; -import { forgotPasswordMailer } from "../../../mailers/forgot-password-mailer"; -import { ForgotPassword } from "../validations"; - -const RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS = 24; - -export default resolver.pipe(resolver.zod(ForgotPassword), async ({ email }) => { - const user = await db.user.findFirst({ where: { email: email.toLowerCase() } }); - - // always wait the same amount of time so attackers can't tell the difference whether a user is found - await Promise.all([updatePassword(user), new Promise((resolve) => setTimeout(resolve, 750))]); - - // return the same result whether a password reset email was sent or not - return; -}); - -async function updatePassword(user: User | null) { - if (!user) { - return; - } - - const token = generateToken(); - const hashedToken = hash256(token); - const expiresAt = new Date(); - expiresAt.setHours(expiresAt.getHours() + RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS); - - await db.token.deleteMany({ where: { type: "RESET_PASSWORD", userId: user.id } }); - await db.token.create({ - data: { - user: { connect: { id: user.id } }, - type: "RESET_PASSWORD", - expiresAt, - hashedToken, - sentTo: user.email, - }, - }); - await ( - await forgotPasswordMailer({ - to: user.email, - token, - userName: user.fullName, - }) - ).send(); -} diff --git a/app/auth/mutations/login.ts b/app/auth/mutations/login.ts deleted file mode 100644 index 7252714..0000000 --- a/app/auth/mutations/login.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { resolver, SecurePassword, AuthenticationError } from "blitz"; - -import db from "../../../db"; -import { Login } from "../validations"; - -export const authenticateUser = async (rawEmail: string, rawPassword: string) => { - const email = rawEmail.toLowerCase().trim(); - const password = rawPassword.trim(); - const user = await db.user.findFirst({ - where: { email }, - include: { - memberships: { - include: { organization: true }, - }, - }, - }); - if (!user) throw new AuthenticationError(); - - const result = await SecurePassword.verify(user.hashedPassword, password); - - if (result === SecurePassword.VALID_NEEDS_REHASH) { - // Upgrade hashed password with a more secure hash - const improvedHash = await SecurePassword.hash(password); - await db.user.update({ - where: { id: user.id }, - data: { hashedPassword: improvedHash }, - }); - } - - const { hashedPassword, ...rest } = user; - return rest; -}; - -export default resolver.pipe(resolver.zod(Login), async ({ email, password }, ctx) => { - // This throws an error if credentials are invalid - const user = await authenticateUser(email, password); - - const organization = user.memberships[0]!.organization; - await ctx.session.$create({ - userId: user.id, - roles: [user.role, user.memberships[0]!.role], - orgId: organization.id, - }); - - return user; -}); diff --git a/app/auth/mutations/logout.ts b/app/auth/mutations/logout.ts deleted file mode 100644 index 1622650..0000000 --- a/app/auth/mutations/logout.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Ctx } from "blitz"; - -export default async function logout(_ = null, ctx: Ctx) { - return await ctx.session.$revoke(); -} diff --git a/app/auth/mutations/reset-password.ts b/app/auth/mutations/reset-password.ts deleted file mode 100644 index abf312e..0000000 --- a/app/auth/mutations/reset-password.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { resolver, SecurePassword, hash256 } from "blitz"; - -import db from "../../../db"; -import { ResetPassword } from "../validations"; -import login from "./login"; - -export class ResetPasswordError extends Error { - name = "ResetPasswordError"; - message = "Reset password link is invalid or it has expired."; -} - -export default resolver.pipe(resolver.zod(ResetPassword), async ({ password, token }, ctx) => { - // 1. Try to find this token in the database - const hashedToken = hash256(token); - const possibleToken = await db.token.findFirst({ - where: { hashedToken, type: "RESET_PASSWORD" }, - include: { user: true }, - }); - - // 2. If token not found, error - if (!possibleToken) { - throw new ResetPasswordError(); - } - const savedToken = possibleToken; - - // 3. Delete token so it can't be used again - await db.token.delete({ where: { id: savedToken.id } }); - - // 4. If token has expired, error - if (savedToken.expiresAt < new Date()) { - throw new ResetPasswordError(); - } - - // 5. Since token is valid, now we can update the user's password - const hashedPassword = await SecurePassword.hash(password.trim()); - const user = await db.user.update({ - where: { id: savedToken.userId }, - data: { hashedPassword }, - }); - - // 6. Revoke all existing login sessions for this user - await db.session.deleteMany({ where: { userId: user.id } }); - - // 7. Now log the user in with the new credentials - await login({ email: user.email, password }, ctx); - - return true; -}); diff --git a/app/auth/mutations/signup.ts b/app/auth/mutations/signup.ts deleted file mode 100644 index d201b0d..0000000 --- a/app/auth/mutations/signup.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { resolver, SecurePassword } from "blitz"; - -import db, { GlobalRole, MembershipRole } from "db"; -import { Signup } from "../validations"; -import { computeEncryptionKey } from "db/_encryption"; -import { welcomeMailer } from "mailers/welcome-mailer"; - -export default resolver.pipe(resolver.zod(Signup), async ({ email, password, fullName }, ctx) => { - const hashedPassword = await SecurePassword.hash(password.trim()); - const encryptionKey = computeEncryptionKey(email.toLowerCase().trim()).toString("hex"); - const user = await db.user.create({ - data: { - fullName: fullName.trim(), - email: email.toLowerCase().trim(), - hashedPassword, - role: GlobalRole.CUSTOMER, - memberships: { - create: { - role: MembershipRole.OWNER, - organization: { - create: { - encryptionKey, - }, - }, - }, - }, - }, - include: { memberships: true }, - }); - - await ctx.session.$create({ - userId: user.id, - roles: [user.role, user.memberships[0]!.role], - orgId: user.memberships[0]!.organizationId, - shouldShowWelcomeMessage: true, - }); - - await ( - await welcomeMailer({ - to: user.email, - userName: user.fullName, - }) - ).send(); - - return user; -}); diff --git a/app/auth/pages/forgot-password.tsx b/app/auth/pages/forgot-password.tsx deleted file mode 100644 index 22e3c26..0000000 --- a/app/auth/pages/forgot-password.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { BlitzPage } from "blitz"; -import { Routes, useMutation } from "blitz"; - -import BaseLayout from "app/core/layouts/base-layout"; -import { AuthForm as Form, FORM_ERROR } from "../components/auth-form"; -import { LabeledTextField } from "../components/labeled-text-field"; -import { ForgotPassword } from "../validations"; -import forgotPassword from "app/auth/mutations/forgot-password"; - -const ForgotPasswordPage: BlitzPage = () => { - const [forgotPasswordMutation, { isSuccess, reset }] = useMutation(forgotPassword); - - return ( -
{ - try { - reset(); - await forgotPasswordMutation(values); - } catch (error: any) { - return { - [FORM_ERROR]: "Sorry, we had an unexpected error. Please try again.", - }; - } - }} - > - {isSuccess ? ( -

- If your email is in our system, you will receive instructions to reset your password shortly. -

- ) : ( - - )} - - ); -}; - -ForgotPasswordPage.redirectAuthenticatedTo = Routes.Messages(); - -ForgotPasswordPage.getLayout = (page) => {page}; - -export default ForgotPasswordPage; diff --git a/app/auth/pages/reset-password.tsx b/app/auth/pages/reset-password.tsx deleted file mode 100644 index e8daee1..0000000 --- a/app/auth/pages/reset-password.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import type { BlitzPage, GetServerSideProps } from "blitz"; -import { useRouterQuery, Link, useMutation, Routes } from "blitz"; - -import BaseLayout from "../../core/layouts/base-layout"; -import { AuthForm as Form, FORM_ERROR } from "../components/auth-form"; -import { LabeledTextField } from "../components/labeled-text-field"; -import { ResetPassword } from "../validations"; -import resetPassword from "../../auth/mutations/reset-password"; - -const ResetPasswordPage: BlitzPage = () => { - const query = useRouterQuery(); - const [resetPasswordMutation, { isSuccess }] = useMutation(resetPassword); - - return ( -
{ - try { - await resetPasswordMutation(values); - } catch (error: any) { - if (error.name === "ResetPasswordError") { - return { - [FORM_ERROR]: error.message, - }; - } else { - return { - [FORM_ERROR]: "Sorry, we had an unexpected error. Please try again.", - }; - } - } - }} - > - {isSuccess ? ( -

- Go to the homepage -

- ) : ( - <> - - - - )} - - ); -}; - -ResetPasswordPage.redirectAuthenticatedTo = Routes.Messages(); - -ResetPasswordPage.getLayout = (page) => {page}; - -export const getServerSideProps: GetServerSideProps = async (context) => { - if (!context.query.token) { - return { - redirect: { - destination: Routes.ForgotPasswordPage().pathname, - permanent: false, - }, - }; - } - - return { props: {} }; -}; - -export default ResetPasswordPage; diff --git a/app/auth/pages/sign-in.tsx b/app/auth/pages/sign-in.tsx deleted file mode 100644 index 3834bf0..0000000 --- a/app/auth/pages/sign-in.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { BlitzPage } from "blitz"; -import { useRouter, Routes, AuthenticationError, Link, useMutation } from "blitz"; - -import BaseLayout from "../../core/layouts/base-layout"; -import { AuthForm as Form, FORM_ERROR } from "../components/auth-form"; -import { Login } from "../validations"; -import { LabeledTextField } from "../components/labeled-text-field"; -import login from "../mutations/login"; - -const SignIn: BlitzPage = () => { - const router = useRouter(); - const [loginMutation] = useMutation(login); - - return ( -
- Need an account?  - - - Create yours for free - - - - ), - submit: "Sign in", - }} - schema={Login} - initialValues={{ email: "", password: "" }} - onSubmit={async (values) => { - try { - await loginMutation(values); - const next = router.query.next - ? decodeURIComponent(router.query.next as string) - : Routes.Messages(); - router.push(next); - } catch (error: any) { - if (error instanceof AuthenticationError) { - return { [FORM_ERROR]: "Sorry, those credentials are invalid" }; - } else { - return { - [FORM_ERROR]: "Sorry, we had an unexpected error. Please try again. - " + error.toString(), - }; - } - } - }} - > - - - - ); -}; - -SignIn.redirectAuthenticatedTo = Routes.Messages(); - -SignIn.getLayout = (page) => {page}; - -export default SignIn; diff --git a/app/auth/pages/sign-up.tsx b/app/auth/pages/sign-up.tsx deleted file mode 100644 index 9fbb6cd..0000000 --- a/app/auth/pages/sign-up.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { BlitzPage } from "blitz"; -import { useRouter, Routes, useMutation, Link } from "blitz"; - -import BaseLayout from "../../core/layouts/base-layout"; -import { AuthForm as Form, FORM_ERROR } from "../components/auth-form"; -import { LabeledTextField } from "../components/labeled-text-field"; -import signup from "../mutations/signup"; -import { Signup } from "../validations"; - -const SignUp: BlitzPage = () => { - const router = useRouter(); - const [signupMutation] = useMutation(signup); - - return ( -
- - Already have an account? - - - ), - submit: "Sign up", - }} - schema={Signup} - initialValues={{ email: "", password: "" }} - onSubmit={async (values) => { - try { - await signupMutation(values); - await router.push(Routes.Welcome()); - } catch (error: any) { - if (error.code === "P2002" && error.meta?.target?.includes("email")) { - // This error comes from Prisma - return { email: "This email is already being used" }; - } else { - return { [FORM_ERROR]: error.toString() }; - } - } - }} - > - - - - - ); -}; - -SignUp.redirectAuthenticatedTo = ({ session }) => { - if (session.shouldShowWelcomeMessage) { - return Routes.Welcome(); - } - - return Routes.Messages(); -}; - -SignUp.getLayout = (page) => {page}; - -export default SignUp; diff --git a/app/auth/pages/welcome.tsx b/app/auth/pages/welcome.tsx deleted file mode 100644 index 073c43a..0000000 --- a/app/auth/pages/welcome.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { BlitzPage, GetServerSideProps } from "blitz"; -import { getSession, Routes, useRouter } from "blitz"; - -// TODO: make this page feel more welcoming lol - -const Welcome: BlitzPage = () => { - const router = useRouter(); - - return ( -
-

Thanks for joining Shellphone

-

Let us know if you need our help

-

Make sure to set up your phone number

- -
- ); -}; - -export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { - const session = await getSession(req, res); - await session.$setPublicData({ shouldShowWelcomeMessage: undefined }); - - return { - props: {}, - }; -}; - -export default Welcome; diff --git a/app/blog/api/articles/exit-preview.ts b/app/blog/api/articles/exit-preview.ts deleted file mode 100644 index 8f00795..0000000 --- a/app/blog/api/articles/exit-preview.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { BlitzApiRequest, BlitzApiResponse } from "blitz"; - -export default async function preview(req: BlitzApiRequest, res: BlitzApiResponse) { - // Exit the current user from "Preview Mode". This function accepts no args. - res.clearPreviewData(); - - // Redirect the user back to the index page. - res.writeHead(307, { Location: "/" }); - res.end(); -} diff --git a/app/blog/api/articles/preview.ts b/app/blog/api/articles/preview.ts deleted file mode 100644 index 3521453..0000000 --- a/app/blog/api/articles/preview.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { BlitzApiRequest, BlitzApiResponse } from "blitz"; -import { getConfig } from "blitz"; - -import { getPreviewPostBySlug } from "../../../../integrations/datocms"; - -const { serverRuntimeConfig } = getConfig(); - -export default async function preview(req: BlitzApiRequest, res: BlitzApiResponse) { - // Check the secret and next parameters - // This secret should only be known to this API route and the CMS - if ( - req.query.secret !== serverRuntimeConfig.datoCms.previewSecret || - !req.query.slug || - Array.isArray(req.query.slug) - ) { - return res.status(401).json({ message: "Invalid token" }); - } - - // Fetch the headless CMS to check if the provided `slug` exists - const post = await getPreviewPostBySlug(req.query.slug); - - // If the slug doesn't exist prevent preview mode from being enabled - if (!post) { - return res.status(401).json({ message: "Invalid slug" }); - } - - // Enable Preview Mode by setting the cookies - res.setPreviewData({}); - - // Redirect to the path from the fetched post - // We don't redirect to req.query.slug as that might lead to open redirect vulnerabilities - res.writeHead(307, { Location: `/posts/${post.slug}` }); - res.end(); -} diff --git a/app/blog/components/avatar.tsx b/app/blog/components/avatar.tsx deleted file mode 100644 index 2cd1da8..0000000 --- a/app/blog/components/avatar.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import Image from "next/image"; - -export default function Avatar({ name, picture }: any) { - return ( -
-
- {name} -
-
{name}
-
- ); -} diff --git a/app/blog/components/cover-image.tsx b/app/blog/components/cover-image.tsx deleted file mode 100644 index 5b74971..0000000 --- a/app/blog/components/cover-image.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Image } from "react-datocms"; -import clsx from "clsx"; -import Link from "next/link"; - -export default function CoverImage({ title, responsiveImage, slug }: any) { - const image = ( - // eslint-disable-next-line jsx-a11y/alt-text - - ); - return ( -
- {slug ? ( - - {image} - - ) : ( - image - )} -
- ); -} diff --git a/app/blog/components/date.tsx b/app/blog/components/date.tsx deleted file mode 100644 index 77a5ebf..0000000 --- a/app/blog/components/date.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { formatDate } from "../../core/helpers/date-formatter"; - -export default function DateComponent({ dateString }: any) { - const date = new Date(dateString); - return ; -} diff --git a/app/blog/components/more-stories.tsx b/app/blog/components/more-stories.tsx deleted file mode 100644 index 277c7dd..0000000 --- a/app/blog/components/more-stories.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Link, Routes } from "blitz"; - -import type { Post } from "../../../integrations/datocms"; -import { formatDate } from "../../core/helpers/date-formatter"; - -import PostPreview from "./post-preview"; - -type Props = { - posts: Post[]; -}; - -export default function MoreStories({ posts }: Props) { - return ( - - ); -} diff --git a/app/blog/components/post-body.tsx b/app/blog/components/post-body.tsx deleted file mode 100644 index ddc602d..0000000 --- a/app/blog/components/post-body.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import styles from "../styles/post-body.module.css"; - -type Props = { - content: string; -}; - -export default function PostBody({ content }: Props) { - return ( -
-
-
- ); -} diff --git a/app/blog/components/post-preview.tsx b/app/blog/components/post-preview.tsx deleted file mode 100644 index fc51888..0000000 --- a/app/blog/components/post-preview.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import Link from "next/link"; - -import Avatar from "./avatar"; -import Date from "./date"; -import CoverImage from "./cover-image"; - -export default function PostPreview({ title, coverImage, date, excerpt, author, slug }: any) { - return ( -
-
- -
-

- - {title} - -

-
- -
-

{excerpt}

- -
- ); -} diff --git a/app/blog/components/section-separator.tsx b/app/blog/components/section-separator.tsx deleted file mode 100644 index cb7ee4e..0000000 --- a/app/blog/components/section-separator.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function SectionSeparator() { - return
; -} diff --git a/app/blog/pages/articles/[slug].tsx b/app/blog/pages/articles/[slug].tsx deleted file mode 100644 index 25a44b2..0000000 --- a/app/blog/pages/articles/[slug].tsx +++ /dev/null @@ -1,166 +0,0 @@ -import type { BlitzPage, GetStaticPaths, GetStaticProps } from "blitz"; -import { useRouter } from "blitz"; -import ErrorPage from "next/error"; - -import type { Post } from "integrations/datocms"; -import { getAllPostsWithSlug, getPostAndMorePosts, markdownToHtml } from "integrations/datocms"; -import { formatDate } from "../../../core/helpers/date-formatter"; - -import Header from "../../../public-area/components/header"; -import PostBody from "../../components/post-body"; -import SectionSeparator from "../../components/section-separator"; -import MoreStories from "../../components/more-stories"; - -type Props = { - post: Post; - morePosts: Post[]; - preview: boolean; -}; - -const PostPage: BlitzPage = ({ post, morePosts, preview }) => { - const router = useRouter(); - if (!router.isFallback && !post?.slug) { - return ; - } - console.log("post", post); - - return ( -
-
- -
-
- {/* Background image */} -
- {post.coverImage.responsiveImage.alt - - -
-
-
-
- {/* Article header */} -
- {/* Title and excerpt */} -
-

{post.title}

-

{post.excerpt}

-
- {/* Article meta */} -
- {/* Author meta */} -
- Author 04 -
- By - - {post.author.name} - - - {" "} - · {formatDate(new Date(post.date))} - -
-
-
-
-
- - {/* Article content */} -
- -
-
- - - {morePosts.length > 0 && } -
-
-
-
-
-
- ); - - /*return ( - - -
- {router.isFallback ? ( - Loading… - ) : ( - <> -
- - - {post.title} | Next.js Blog Example with {CMS_NAME} - - - - - -
- - {morePosts.length > 0 && } - - )} - - - );*/ -}; - -export default PostPage; - -export const getStaticProps: GetStaticProps = async ({ params, preview = false }) => { - if (!params || !params.slug || Array.isArray(params.slug)) { - return { - notFound: true, - }; - } - - const data = await getPostAndMorePosts(params.slug, preview); - const content = await markdownToHtml(data.post.content || ""); - - return { - props: { - preview, - post: { - ...data.post, - content, - }, - morePosts: data.morePosts, - }, - }; -}; - -export const getStaticPaths: GetStaticPaths = async () => { - const allPosts = await getAllPostsWithSlug(); - return { - paths: allPosts.map((post) => `/articles/${post.slug}`), - fallback: true, - }; -}; diff --git a/app/blog/pages/blog.tsx b/app/blog/pages/blog.tsx deleted file mode 100644 index 27ef342..0000000 --- a/app/blog/pages/blog.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { BlitzPage } from "blitz"; - -import Layout from "../../public-area/components/layout"; - -const Blog: BlitzPage = () => { - return
Coming soon.
; -}; - -Blog.getLayout = (page) => {page}; -Blog.suppressFirstRenderFlicker = true; - -export default Blog; diff --git a/app/blog/styles/post-body.module.css b/app/blog/styles/post-body.module.css deleted file mode 100644 index bbef4f5..0000000 --- a/app/blog/styles/post-body.module.css +++ /dev/null @@ -1,18 +0,0 @@ -.markdown { - @apply text-lg leading-relaxed; -} - -.markdown p, -.markdown ul, -.markdown ol, -.markdown blockquote { - @apply my-6; -} - -.markdown h2 { - @apply text-3xl mt-12 mb-4 leading-snug; -} - -.markdown h3 { - @apply text-2xl mt-8 mb-4 leading-snug; -} diff --git a/app/config/config.client.ts b/app/config/config.client.ts new file mode 100644 index 0000000..ff8b4c5 --- /dev/null +++ b/app/config/config.client.ts @@ -0,0 +1 @@ +export default {}; diff --git a/app/config/config.server.ts b/app/config/config.server.ts new file mode 100644 index 0000000..d253b23 --- /dev/null +++ b/app/config/config.server.ts @@ -0,0 +1,40 @@ +import invariant from "tiny-invariant"; + +invariant(typeof process.env.APP_BASE_URL === "string", `Please define the "APP_BASE_URL" environment variable`); +invariant( + typeof process.env.INVITATION_TOKEN_SECRET === "string", + `Please define the "INVITATION_TOKEN_SECRET" environment variable`, +); +invariant(typeof process.env.SESSION_SECRET === "string", `Please define the "SESSION_SECRET" environment variable`); +invariant(typeof process.env.AWS_SES_REGION === "string", `Please define the "AWS_SES_REGION" environment variable`); +invariant( + typeof process.env.AWS_SES_ACCESS_KEY_ID === "string", + `Please define the "AWS_SES_ACCESS_KEY_ID" environment variable`, +); +invariant( + typeof process.env.AWS_SES_ACCESS_KEY_SECRET === "string", + `Please define the "AWS_SES_ACCESS_KEY_SECRET" environment variable`, +); +invariant( + typeof process.env.AWS_SES_FROM_EMAIL === "string", + `Please define the "AWS_SES_FROM_EMAIL" environment variable`, +); +invariant(typeof process.env.REDIS_URL === "string", `Please define the "REDIS_URL" environment variable`); + +export default { + app: { + baseUrl: process.env.APP_BASE_URL, + invitationTokenSecret: process.env.INVITATION_TOKEN_SECRET, + sessionSecret: process.env.SESSION_SECRET, + }, + awsSes: { + awsRegion: process.env.AWS_SES_REGION, + accessKeyId: process.env.AWS_SES_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SES_ACCESS_KEY_SECRET, + fromEmail: process.env.AWS_SES_FROM_EMAIL, + }, + redis: { + url: process.env.REDIS_URL, + password: process.env.REDIS_PASSWORD, + }, +}; diff --git a/app/core/api/cron/daily-backup.ts b/app/core/api/cron/daily-backup.ts deleted file mode 100644 index 3af42af..0000000 --- a/app/core/api/cron/daily-backup.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { CronJob } from "quirrel/blitz"; - -import backup from "../../../../db/backup"; - -export default CronJob("api/cron/daily-backup", "0 0 * * *", async () => backup("daily")); diff --git a/app/core/api/cron/monthly-backup.ts b/app/core/api/cron/monthly-backup.ts deleted file mode 100644 index 43048b9..0000000 --- a/app/core/api/cron/monthly-backup.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { CronJob } from "quirrel/blitz"; - -import backup from "../../../../db/backup"; - -export default CronJob("api/cron/monthly-backup", "0 0 1 * *", async () => backup("monthly")); diff --git a/app/core/api/cron/weekly-backup.ts b/app/core/api/cron/weekly-backup.ts deleted file mode 100644 index 8d3adc6..0000000 --- a/app/core/api/cron/weekly-backup.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { CronJob } from "quirrel/blitz"; - -import backup from "../../../../db/backup"; - -export default CronJob("api/cron/weekly-backup", "0 0 * * 0", async () => backup("weekly")); diff --git a/app/core/components/alert.tsx b/app/core/components/alert.tsx deleted file mode 100644 index c3806f2..0000000 --- a/app/core/components/alert.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import type { ReactElement, ReactChild } from "react"; - -type AlertVariant = "error" | "success" | "info" | "warning"; - -type AlertVariantProps = { - backgroundColor: string; - icon: ReactElement; - titleTextColor: string; - messageTextColor: string; -}; - -type Props = { - title: ReactChild; - message: ReactChild; - variant: AlertVariant; -}; - -const ALERT_VARIANTS: Record = { - error: { - backgroundColor: "bg-red-50", - icon: ( - - - - ), - titleTextColor: "text-red-800", - messageTextColor: "text-red-700", - }, - success: { - backgroundColor: "bg-green-50", - icon: ( - - - - ), - titleTextColor: "text-green-800", - messageTextColor: "text-green-700", - }, - info: { - backgroundColor: "bg-primary-50", - icon: ( - - - - ), - titleTextColor: "text-primary-800", - messageTextColor: "text-primary-700", - }, - warning: { - backgroundColor: "bg-yellow-50", - icon: ( - - - - ), - titleTextColor: "text-yellow-800", - messageTextColor: "text-yellow-700", - }, -}; - -export default function Alert({ title, message, variant }: Props) { - const variantProperties = ALERT_VARIANTS[variant]; - - return ( -
-
-
{variantProperties.icon}
-
-

{title}

-
{message}
-
-
-
- ); -} diff --git a/app/core/components/error-component.tsx b/app/core/components/error-component.tsx deleted file mode 100644 index ab89ca1..0000000 --- a/app/core/components/error-component.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { ErrorProps } from "next/error"; -import { ErrorComponent as DefaultErrorComponent } from "blitz"; - -import Sentry from "../../../integrations/sentry"; - -type ExtraProps = { - hasGetInitialPropsRun?: boolean; - err?: any; -}; - -class ErrorComponent extends DefaultErrorComponent { - render() { - const { statusCode, hasGetInitialPropsRun, err } = this.props; - if (!hasGetInitialPropsRun && err) { - // getInitialProps is not called in case of - // https://github.com/vercel/next.js/issues/8592. As a workaround, we pass - // err via _app.js so it can be captured - Sentry.captureException(err); - } - - return ; - } -} - -ErrorComponent.getInitialProps = async (ctx) => { - const errorInitialProps: ErrorProps & ExtraProps = await DefaultErrorComponent.getInitialProps(ctx); - - // Workaround for https://github.com/vercel/next.js/issues/8592, mark when - // getInitialProps has run - errorInitialProps.hasGetInitialPropsRun = true; - - // Running on the server, the response object (`res`) is available. - // Next.js will pass an err on the server if a page's data fetching methods - // threw or returned a Promise that rejected - // - // Running on the client (browser), Next.js will provide an err if: - // - a page's `getInitialProps` threw or returned a Promise that rejected - // - an exception was thrown somewhere in the React lifecycle (render, - // componentDidMount, etc) that was caught by Next.js's React Error - // Boundary. Read more about what types of exceptions are caught by Error - // Boundaries: https://reactjs.org/docs/error-boundaries.html - - if (ctx.res?.statusCode === 404) { - // Opinionated: do not record an exception in Sentry for 404 - return { statusCode: 404 }; - } - - if (ctx.err) { - Sentry.captureException(ctx.err); - await Sentry.flush(2000); - return errorInitialProps; - } - - // If this point is reached, getInitialProps was called without any - // information about what the error might be. This is unexpected and may - // indicate a bug introduced in Next.js, so record it in Sentry - Sentry.captureException(new Error(`_error.js getInitialProps missing data at path: ${ctx.asPath}`)); - await Sentry.flush(2000); - - return errorInitialProps; -}; - -export default ErrorComponent; diff --git a/app/core/components/labeled-text-field.tsx b/app/core/components/labeled-text-field.tsx deleted file mode 100644 index 5c53d1b..0000000 --- a/app/core/components/labeled-text-field.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { forwardRef, PropsWithoutRef } from "react"; -import { useFormContext } from "react-hook-form"; - -export interface LabeledTextFieldProps extends PropsWithoutRef { - /** Field name. */ - name: string; - /** Field label. */ - label: string; - /** Field type. Doesn't include radio buttons and checkboxes */ - type?: "text" | "password" | "email" | "number"; - outerProps?: PropsWithoutRef; -} - -export const LabeledTextField = forwardRef( - ({ label, outerProps, name, ...props }, ref) => { - const { - register, - formState: { isSubmitting, errors }, - } = useFormContext(); - const error = Array.isArray(errors[name]) ? errors[name].join(", ") : errors[name]?.message || errors[name]; - - return ( -
- - - {error && ( -
- {error} -
- )} - - -
- ); - }, -); - -export default LabeledTextField; diff --git a/app/core/components/spinner.tsx b/app/core/components/spinner.tsx deleted file mode 100644 index a53049f..0000000 --- a/app/core/components/spinner.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import clsx from "clsx"; - -import styles from "./spinner.module.css"; - -export default function Spinner() { - return ( -
-
-
- ); -} diff --git a/app/core/hooks/use-current-phone-number.ts b/app/core/hooks/use-current-phone-number.ts deleted file mode 100644 index c483614..0000000 --- a/app/core/hooks/use-current-phone-number.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useQuery } from "blitz"; - -import getCurrentPhoneNumber from "app/phone-numbers/queries/get-current-phone-number"; -import useCurrentUser from "./use-current-user"; - -export default function useUserPhoneNumber() { - const { hasFilledTwilioCredentials } = useCurrentUser(); - const [phoneNumber] = useQuery(getCurrentPhoneNumber, {}, { enabled: hasFilledTwilioCredentials }); - - return phoneNumber; -} diff --git a/app/core/hooks/use-current-user.ts b/app/core/hooks/use-current-user.ts deleted file mode 100644 index f6b3749..0000000 --- a/app/core/hooks/use-current-user.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useSession, useQuery } from "blitz"; - -import getCurrentUser from "../../users/queries/get-current-user"; -import getCurrentPhoneNumber from "../../phone-numbers/queries/get-current-phone-number"; - -export default function useCurrentUser() { - const session = useSession(); - const [user, userQuery] = useQuery(getCurrentUser, null, { enabled: Boolean(session.userId) }); - const organization = user?.memberships[0]!.organization; - const hasFilledTwilioCredentials = Boolean(organization?.twilioAccountSid && organization?.twilioAuthToken); - const [phoneNumber] = useQuery(getCurrentPhoneNumber, {}, { enabled: hasFilledTwilioCredentials }); - - return { - user, - organization, - hasFilledTwilioCredentials, - hasPhoneNumber: Boolean(phoneNumber), - hasOngoingSubscription: organization && organization.subscriptions.length > 0, - refetch: userQuery.refetch, - }; -} diff --git a/app/core/hooks/use-notifications.ts b/app/core/hooks/use-notifications.ts deleted file mode 100644 index 228fd4b..0000000 --- a/app/core/hooks/use-notifications.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { getConfig, useMutation } from "blitz"; -import { useEffect, useMemo, useState } from "react"; - -import setNotificationSubscription from "../mutations/set-notification-subscription"; -import useCurrentPhoneNumber from "./use-current-phone-number"; - -const { publicRuntimeConfig } = getConfig(); - -export default function useNotifications() { - const isServiceWorkerSupported = useMemo(() => "serviceWorker" in navigator, []); - const [subscription, setSubscription] = useState(null); - const [setNotificationSubscriptionMutation] = useMutation(setNotificationSubscription); - const phoneNumber = useCurrentPhoneNumber(); - - useEffect(() => { - (async () => { - if (!isServiceWorkerSupported) { - return; - } - - const registration = await navigator.serviceWorker.ready; - const subscription = await registration.pushManager.getSubscription(); - setSubscription(subscription); - })(); - }, [isServiceWorkerSupported]); - - async function subscribe() { - if (!isServiceWorkerSupported || !phoneNumber) { - return; - } - - const registration = await navigator.serviceWorker.ready; - const subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(publicRuntimeConfig.webPush.publicKey), - }); - setSubscription(subscription); - await setNotificationSubscriptionMutation({ - phoneNumberId: phoneNumber.id, - subscription: subscription.toJSON() as any, - }); // TODO remove as any - } - - async function unsubscribe() { - if (!isServiceWorkerSupported) { - return; - } - - return subscription?.unsubscribe(); - } - - return { - isServiceWorkerSupported, - subscription, - subscribe, - unsubscribe, - }; -} - -function urlBase64ToUint8Array(base64String: string) { - const padding = "=".repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/"); - - const rawData = window.atob(base64); - const outputArray = new Uint8Array(rawData.length); - - for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i); - } - return outputArray; -} diff --git a/app/core/hooks/use-panelbear.ts b/app/core/hooks/use-panelbear.ts deleted file mode 100644 index 605c1fa..0000000 --- a/app/core/hooks/use-panelbear.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect } from "react"; -import { useRouter } from "next/router"; -import * as Panelbear from "@panelbear/panelbear-js"; -import type { PanelbearConfig } from "@panelbear/panelbear-js"; - -export const usePanelbear = (siteId?: string, config: PanelbearConfig = {}) => { - const router = useRouter(); - - useEffect(() => { - if (!siteId) { - return; - } - - Panelbear.load(siteId, { scriptSrc: "/bear.js", ...config }); - Panelbear.trackPageview(); - const handleRouteChange = () => Panelbear.trackPageview(); - router.events.on("routeChangeComplete", handleRouteChange); - - return () => router.events.off("routeChangeComplete", handleRouteChange); - // eslint-disable-next-line - }, [siteId]); -}; diff --git a/app/core/hooks/use-subscription.ts b/app/core/hooks/use-subscription.ts deleted file mode 100644 index bac7218..0000000 --- a/app/core/hooks/use-subscription.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { useQuery, useMutation, useSession } from "blitz"; - -import type { Subscription } from "db"; -import { SubscriptionStatus } from "db"; -import getSubscription from "app/settings/queries/get-subscription"; -import updateSubscription from "app/settings/mutations/update-subscription"; -import usePaddle from "app/settings/hooks/use-paddle"; -import useCurrentUser from "app/core/hooks/use-current-user"; - -type Params = { - initialData?: Subscription; -}; - -export default function useSubscription({ initialData }: Params = {}) { - const session = useSession(); - const { user } = useCurrentUser(); - const [isWaitingForSubChange, setIsWaitingForSubChange] = useState(false); - const [subscription] = useQuery(getSubscription, null, { - initialData, - refetchInterval: isWaitingForSubChange ? 1500 : false, - }); - const [updateSubscriptionMutation] = useMutation(updateSubscription); - - const resolve = useRef<() => void>(); - const promise = useRef>(); - - const { Paddle } = usePaddle({ - eventCallback(data) { - if (["Checkout.Close", "Checkout.Complete"].includes(data.event)) { - resolve.current!(); - promise.current = new Promise((r) => (resolve.current = r)); - } - }, - }); - - // cancel subscription polling when we get a new subscription - useEffect(() => setIsWaitingForSubChange(false), [subscription?.paddleSubscriptionId, subscription?.status]); - - useEffect(() => { - promise.current = new Promise((r) => (resolve.current = r)); - }, []); - - type BuyParams = { - planId: number; - coupon?: string; - }; - - async function subscribe(params: BuyParams) { - if (!user || !session.orgId) { - return; - } - - const { planId, coupon } = params; - const checkoutOpenParams = { - email: user.email, - product: planId, - allowQuantity: false, - passthrough: JSON.stringify({ organizationId: session.orgId }), - coupon: "", - }; - - if (coupon) { - checkoutOpenParams.coupon = coupon; - } - - Paddle.Checkout.open(checkoutOpenParams); - setIsWaitingForSubChange(true); - - return promise.current; - } - - async function updatePaymentMethod({ updateUrl }: { updateUrl: string }) { - const checkoutOpenParams = { override: updateUrl }; - - Paddle.Checkout.open(checkoutOpenParams); - setIsWaitingForSubChange(true); - - return promise.current; - } - - async function cancelSubscription({ cancelUrl }: { cancelUrl: string }) { - const checkoutOpenParams = { override: cancelUrl }; - - Paddle.Checkout.open(checkoutOpenParams); - setIsWaitingForSubChange(true); - - return promise.current; - } - - type ChangePlanParams = { - planId: number; - }; - - async function changePlan({ planId }: ChangePlanParams) { - try { - await updateSubscriptionMutation({ planId }); - setIsWaitingForSubChange(true); - } catch (error) { - console.log("error", error); - } - } - - const hasActiveSubscription = Boolean(subscription && subscription?.status !== SubscriptionStatus.deleted); - - return { - subscription, - hasActiveSubscription, - subscribe, - updatePaymentMethod, - cancelSubscription, - changePlan, - }; -} diff --git a/app/core/layouts/base-layout.tsx b/app/core/layouts/base-layout.tsx deleted file mode 100644 index 9e6b5fd..0000000 --- a/app/core/layouts/base-layout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { ReactNode } from "react"; -import { Head } from "blitz"; - -type LayoutProps = { - title?: string; - children: ReactNode; -}; - -const BaseLayout = ({ title, children }: LayoutProps) => { - return ( - <> - - {title ? `${title} | Shellphone` : "Shellphone"} - - - - {children} - - ); -}; - -export default BaseLayout; diff --git a/app/core/layouts/layout/footer.tsx b/app/core/layouts/layout/footer.tsx deleted file mode 100644 index bce6ef2..0000000 --- a/app/core/layouts/layout/footer.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { ReactNode } from "react"; -import { Link, useRouter } from "blitz"; -import { IoCall, IoKeypad, IoChatbubbles, IoSettings } from "react-icons/io5"; -import clsx from "clsx"; - -export default function Footer() { - return ( -
- } /> - } /> - } /> - } /> -
- ); -} - -type NavLinkProps = { - path: string; - label: string; - icon: ReactNode; -}; - -function NavLink({ path, label, icon }: NavLinkProps) { - const router = useRouter(); - const isActiveRoute = router.pathname.startsWith(path); - - return ( - - ); -} diff --git a/app/core/layouts/layout/index.tsx b/app/core/layouts/layout/index.tsx deleted file mode 100644 index 45f2782..0000000 --- a/app/core/layouts/layout/index.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import type { ErrorInfo } from "react"; -import { Component, Suspense } from "react"; -import type { BlitzLayout } from "blitz"; -import { - Head, - withRouter, - AuthenticationError, - AuthorizationError, - CSRFTokenMismatchError, - NotFoundError, - RedirectError, - Routes, -} from "blitz"; -import type { WithRouterProps } from "next/dist/client/with-router"; - -import appLogger from "integrations/logger"; - -import Footer from "./footer"; -import Loader from "./loader"; - -type Props = { - title: string; - pageTitle?: string; - hideFooter?: true; -}; - -const logger = appLogger.child({ module: "Layout" }); - -const AppLayout: BlitzLayout = ({ children, title, pageTitle = title, hideFooter = false }) => { - return ( - <> - {pageTitle ? ( - - {pageTitle} | Shellphone - - ) : null} - - }> -
-
-
-
- {children} -
-
- {!hideFooter ?
: null} -
-
-
- - ); -}; - -AppLayout.authenticate = { redirectTo: Routes.SignIn() }; - -type ErrorBoundaryState = - | { - isError: false; - } - | { - isError: true; - errorMessage: string; - }; - -const blitzErrors = [RedirectError, AuthenticationError, AuthorizationError, CSRFTokenMismatchError, NotFoundError]; - -const ErrorBoundary = withRouter( - class ErrorBoundary extends Component { - public readonly state = { - isError: false, - } as const; - - static getDerivedStateFromError(error: Error): ErrorBoundaryState { - return { - isError: true, - errorMessage: error.message, - }; - } - - public componentDidCatch(error: Error, errorInfo: ErrorInfo) { - console.trace("ddd"); - logger.error(error, errorInfo.componentStack); - if (blitzErrors.some((blitzError) => error instanceof blitzError)) { - // let Blitz ErrorBoundary handle this one - throw error; - } - - // if network error and connection lost, display the auto-reload page with countdown - } - - public render() { - if (this.state.isError) { - return ( - <> -

- Oops, something went wrong. -

-

- Would you like to{" "} - {" "} - ? -

- - ); - } - - return this.props.children; - } - }, -); - -export default AppLayout; diff --git a/app/core/layouts/layout/loader-gradient.js b/app/core/layouts/layout/loader-gradient.js deleted file mode 100644 index c2e4344..0000000 --- a/app/core/layouts/layout/loader-gradient.js +++ /dev/null @@ -1,749 +0,0 @@ -/* - * Stripe WebGl Gradient Animation - * All Credits to Stripe.com - * ScrollObserver functionality to disable animation when not scrolled into view has been disabled and - * commented out for now. - * https://kevinhufnagl.com - */ - -//Converting colors to proper format -function normalizeColor(hexCode) { - return [((hexCode >> 16) & 255) / 255, ((hexCode >> 8) & 255) / 255, (255 & hexCode) / 255]; -} -["SCREEN", "LINEAR_LIGHT"].reduce( - (hexCode, t, n) => - Object.assign(hexCode, { - [t]: n, - }), - {}, -); - -//Essential functionality of WebGl -//t = width -//n = height -class MiniGl { - constructor(canvas, width, height, debug = false) { - const _miniGl = this, - debug_output = -1 !== document.location.search.toLowerCase().indexOf("debug=webgl"); - (_miniGl.canvas = canvas), - (_miniGl.gl = _miniGl.canvas.getContext("webgl", { - antialias: true, - })), - (_miniGl.meshes = []); - const context = _miniGl.gl; - width && height && this.setSize(width, height), - _miniGl.lastDebugMsg, - (_miniGl.debug = - debug && debug_output - ? function (e) { - const t = new Date(); - t - _miniGl.lastDebugMsg > 1e3 && console.log("---"), - console.log( - t.toLocaleTimeString() + Array(Math.max(0, 32 - e.length)).join(" ") + e + ": ", - ...Array.from(arguments).slice(1), - ), - (_miniGl.lastDebugMsg = t); - } - : () => {}), - Object.defineProperties(_miniGl, { - Material: { - enumerable: false, - value: class { - constructor(vertexShaders, fragments, uniforms = {}) { - const material = this; - function getShaderByType(type, source) { - const shader = context.createShader(type); - return ( - context.shaderSource(shader, source), - context.compileShader(shader), - context.getShaderParameter(shader, context.COMPILE_STATUS) || - console.error(context.getShaderInfoLog(shader)), - _miniGl.debug("Material.compileShaderSource", { - source: source, - }), - shader - ); - } - function getUniformVariableDeclarations(uniforms, type) { - return Object.entries(uniforms) - .map(([uniform, value]) => value.getDeclaration(uniform, type)) - .join("\n"); - } - (material.uniforms = uniforms), (material.uniformInstances = []); - - const prefix = "\n precision highp float;\n "; - (material.vertexSource = `\n ${prefix}\n attribute vec4 position;\n attribute vec2 uv;\n attribute vec2 uvNorm;\n ${getUniformVariableDeclarations( - _miniGl.commonUniforms, - "vertex", - )}\n ${getUniformVariableDeclarations( - uniforms, - "vertex", - )}\n ${vertexShaders}\n `), - (material.Source = `\n ${prefix}\n ${getUniformVariableDeclarations( - _miniGl.commonUniforms, - "fragment", - )}\n ${getUniformVariableDeclarations( - uniforms, - "fragment", - )}\n ${fragments}\n `), - (material.vertexShader = getShaderByType(context.VERTEX_SHADER, material.vertexSource)), - (material.fragmentShader = getShaderByType(context.FRAGMENT_SHADER, material.Source)), - (material.program = context.createProgram()), - context.attachShader(material.program, material.vertexShader), - context.attachShader(material.program, material.fragmentShader), - context.linkProgram(material.program), - context.getProgramParameter(material.program, context.LINK_STATUS) || - console.error(context.getProgramInfoLog(material.program)), - context.useProgram(material.program), - material.attachUniforms(void 0, _miniGl.commonUniforms), - material.attachUniforms(void 0, material.uniforms); - } - //t = uniform - attachUniforms(name, uniforms) { - //n = material - const material = this; - void 0 === name - ? Object.entries(uniforms).forEach(([name, uniform]) => { - material.attachUniforms(name, uniform); - }) - : "array" == uniforms.type - ? uniforms.value.forEach((uniform, i) => - material.attachUniforms(`${name}[${i}]`, uniform), - ) - : "struct" == uniforms.type - ? Object.entries(uniforms.value).forEach(([uniform, i]) => - material.attachUniforms(`${name}.${uniform}`, i), - ) - : (_miniGl.debug("Material.attachUniforms", { - name: name, - uniform: uniforms, - }), - material.uniformInstances.push({ - uniform: uniforms, - location: context.getUniformLocation(material.program, name), - })); - } - }, - }, - Uniform: { - enumerable: !1, - value: class { - constructor(e) { - (this.type = "float"), Object.assign(this, e); - (this.typeFn = - { - float: "1f", - int: "1i", - vec2: "2fv", - vec3: "3fv", - vec4: "4fv", - mat4: "Matrix4fv", - }[this.type] || "1f"), - this.update(); - } - update(value) { - void 0 !== this.value && - context[`uniform${this.typeFn}`]( - value, - 0 === this.typeFn.indexOf("Matrix") ? this.transpose : this.value, - 0 === this.typeFn.indexOf("Matrix") ? this.value : null, - ); - } - //e - name - //t - type - //n - length - getDeclaration(name, type, length) { - const uniform = this; - if (uniform.excludeFrom !== type) { - if ("array" === uniform.type) - return ( - uniform.value[0].getDeclaration(name, type, uniform.value.length) + - `\nconst int ${name}_length = ${uniform.value.length};` - ); - if ("struct" === uniform.type) { - let name_no_prefix = name.replace("u_", ""); - return ( - (name_no_prefix = - name_no_prefix.charAt(0).toUpperCase() + name_no_prefix.slice(1)), - `uniform struct ${name_no_prefix} - {\n` + - Object.entries(uniform.value) - .map(([name, uniform]) => - uniform.getDeclaration(name, type).replace(/^uniform/, ""), - ) - .join("") + - `\n} ${name}${length > 0 ? `[${length}]` : ""};` - ); - } - return `uniform ${uniform.type} ${name}${length > 0 ? `[${length}]` : ""};`; - } - } - }, - }, - PlaneGeometry: { - enumerable: !1, - value: class { - constructor(width, height, n, i, orientation) { - context.createBuffer(), - (this.attributes = { - position: new _miniGl.Attribute({ - target: context.ARRAY_BUFFER, - size: 3, - }), - uv: new _miniGl.Attribute({ - target: context.ARRAY_BUFFER, - size: 2, - }), - uvNorm: new _miniGl.Attribute({ - target: context.ARRAY_BUFFER, - size: 2, - }), - index: new _miniGl.Attribute({ - target: context.ELEMENT_ARRAY_BUFFER, - size: 3, - type: context.UNSIGNED_SHORT, - }), - }), - this.setTopology(n, i), - this.setSize(width, height, orientation); - } - setTopology(e = 1, t = 1) { - const n = this; - (n.xSegCount = e), - (n.ySegCount = t), - (n.vertexCount = (n.xSegCount + 1) * (n.ySegCount + 1)), - (n.quadCount = n.xSegCount * n.ySegCount * 2), - (n.attributes.uv.values = new Float32Array(2 * n.vertexCount)), - (n.attributes.uvNorm.values = new Float32Array(2 * n.vertexCount)), - (n.attributes.index.values = new Uint16Array(3 * n.quadCount)); - for (let e = 0; e <= n.ySegCount; e++) - for (let t = 0; t <= n.xSegCount; t++) { - const i = e * (n.xSegCount + 1) + t; - if ( - ((n.attributes.uv.values[2 * i] = t / n.xSegCount), - (n.attributes.uv.values[2 * i + 1] = 1 - e / n.ySegCount), - (n.attributes.uvNorm.values[2 * i] = (t / n.xSegCount) * 2 - 1), - (n.attributes.uvNorm.values[2 * i + 1] = 1 - (e / n.ySegCount) * 2), - t < n.xSegCount && e < n.ySegCount) - ) { - const s = e * n.xSegCount + t; - (n.attributes.index.values[6 * s] = i), - (n.attributes.index.values[6 * s + 1] = i + 1 + n.xSegCount), - (n.attributes.index.values[6 * s + 2] = i + 1), - (n.attributes.index.values[6 * s + 3] = i + 1), - (n.attributes.index.values[6 * s + 4] = i + 1 + n.xSegCount), - (n.attributes.index.values[6 * s + 5] = i + 2 + n.xSegCount); - } - } - n.attributes.uv.update(), - n.attributes.uvNorm.update(), - n.attributes.index.update(), - _miniGl.debug("Geometry.setTopology", { - uv: n.attributes.uv, - uvNorm: n.attributes.uvNorm, - index: n.attributes.index, - }); - } - setSize(width = 1, height = 1, orientation = "xz") { - const geometry = this; - (geometry.width = width), - (geometry.height = height), - (geometry.orientation = orientation), - (geometry.attributes.position.values && - geometry.attributes.position.values.length === 3 * geometry.vertexCount) || - (geometry.attributes.position.values = new Float32Array(3 * geometry.vertexCount)); - const o = width / -2, - r = height / -2, - segment_width = width / geometry.xSegCount, - segment_height = height / geometry.ySegCount; - for (let yIndex = 0; yIndex <= geometry.ySegCount; yIndex++) { - const t = r + yIndex * segment_height; - for (let xIndex = 0; xIndex <= geometry.xSegCount; xIndex++) { - const r = o + xIndex * segment_width, - l = yIndex * (geometry.xSegCount + 1) + xIndex; - (geometry.attributes.position.values[3 * l + "xyz".indexOf(orientation[0])] = r), - (geometry.attributes.position.values[3 * l + "xyz".indexOf(orientation[1])] = - -t); - } - } - geometry.attributes.position.update(), - _miniGl.debug("Geometry.setSize", { - position: geometry.attributes.position, - }); - } - }, - }, - Mesh: { - enumerable: !1, - value: class { - constructor(geometry, material) { - const mesh = this; - (mesh.geometry = geometry), - (mesh.material = material), - (mesh.wireframe = !1), - (mesh.attributeInstances = []), - Object.entries(mesh.geometry.attributes).forEach(([e, attribute]) => { - mesh.attributeInstances.push({ - attribute: attribute, - location: attribute.attach(e, mesh.material.program), - }); - }), - _miniGl.meshes.push(mesh), - _miniGl.debug("Mesh.constructor", { - mesh: mesh, - }); - } - draw() { - context.useProgram(this.material.program), - this.material.uniformInstances.forEach(({ uniform: e, location: t }) => e.update(t)), - this.attributeInstances.forEach(({ attribute: e, location: t }) => e.use(t)), - context.drawElements( - this.wireframe ? context.LINES : context.TRIANGLES, - this.geometry.attributes.index.values.length, - context.UNSIGNED_SHORT, - 0, - ); - } - remove() { - _miniGl.meshes = _miniGl.meshes.filter((e) => e != this); - } - }, - }, - Attribute: { - enumerable: !1, - value: class { - constructor(e) { - (this.type = context.FLOAT), - (this.normalized = !1), - (this.buffer = context.createBuffer()), - Object.assign(this, e), - this.update(); - } - update() { - void 0 !== this.values && - (context.bindBuffer(this.target, this.buffer), - context.bufferData(this.target, this.values, context.STATIC_DRAW)); - } - attach(e, t) { - const n = context.getAttribLocation(t, e); - return ( - this.target === context.ARRAY_BUFFER && - (context.enableVertexAttribArray(n), - context.vertexAttribPointer(n, this.size, this.type, this.normalized, 0, 0)), - n - ); - } - use(e) { - context.bindBuffer(this.target, this.buffer), - this.target === context.ARRAY_BUFFER && - (context.enableVertexAttribArray(e), - context.vertexAttribPointer(e, this.size, this.type, this.normalized, 0, 0)); - } - }, - }, - }); - const a = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; - _miniGl.commonUniforms = { - projectionMatrix: new _miniGl.Uniform({ - type: "mat4", - value: a, - }), - modelViewMatrix: new _miniGl.Uniform({ - type: "mat4", - value: a, - }), - resolution: new _miniGl.Uniform({ - type: "vec2", - value: [1, 1], - }), - aspectRatio: new _miniGl.Uniform({ - type: "float", - value: 1, - }), - }; - } - setSize(e = 640, t = 480) { - (this.width = e), - (this.height = t), - (this.canvas.width = e), - (this.canvas.height = t), - this.gl.viewport(0, 0, e, t), - (this.commonUniforms.resolution.value = [e, t]), - (this.commonUniforms.aspectRatio.value = e / t), - this.debug("MiniGL.setSize", { - width: e, - height: t, - }); - } - //left, right, top, bottom, near, far - setOrthographicCamera(e = 0, t = 0, n = 0, i = -2e3, s = 2e3) { - (this.commonUniforms.projectionMatrix.value = [ - 2 / this.width, - 0, - 0, - 0, - 0, - 2 / this.height, - 0, - 0, - 0, - 0, - 2 / (i - s), - 0, - e, - t, - n, - 1, - ]), - this.debug("setOrthographicCamera", this.commonUniforms.projectionMatrix.value); - } - render() { - this.gl.clearColor(0, 0, 0, 0), this.gl.clearDepth(1), this.meshes.forEach((e) => e.draw()); - } -} - -//Sets initial properties -function e(object, propertyName, val) { - return ( - propertyName in object - ? Object.defineProperty(object, propertyName, { - value: val, - enumerable: !0, - configurable: !0, - writable: !0, - }) - : (object[propertyName] = val), - object - ); -} - -//Gradient object -class Gradient { - constructor(...t) { - e(this, "el", void 0), - e(this, "cssVarRetries", 0), - e(this, "maxCssVarRetries", 200), - e(this, "angle", 0), - e(this, "isLoadedClass", !1), - e(this, "isScrolling", !1), - /*e(this, "isStatic", o.disableAmbientAnimations()),*/ e(this, "scrollingTimeout", void 0), - e(this, "scrollingRefreshDelay", 200), - e(this, "isIntersecting", !1), - e(this, "shaderFiles", void 0), - e(this, "vertexShader", void 0), - e(this, "sectionColors", void 0), - e(this, "computedCanvasStyle", void 0), - e(this, "conf", void 0), - e(this, "uniforms", void 0), - e(this, "t", 1253106), - e(this, "last", 0), - e(this, "width", void 0), - e(this, "minWidth", 1111), - e(this, "height", 600), - e(this, "xSegCount", void 0), - e(this, "ySegCount", void 0), - e(this, "mesh", void 0), - e(this, "material", void 0), - e(this, "geometry", void 0), - e(this, "minigl", void 0), - e(this, "scrollObserver", void 0), - e(this, "amp", 320), - e(this, "seed", 5), - e(this, "freqX", 14e-5), - e(this, "freqY", 29e-5), - e(this, "freqDelta", 1e-5), - e(this, "activeColors", [1, 1, 1, 1]), - e(this, "isMetaKey", !1), - e(this, "isGradientLegendVisible", !1), - e(this, "isMouseDown", !1), - e(this, "handleScroll", () => { - clearTimeout(this.scrollingTimeout), - (this.scrollingTimeout = setTimeout(this.handleScrollEnd, this.scrollingRefreshDelay)), - this.isGradientLegendVisible && this.hideGradientLegend(), - this.conf.playing && ((this.isScrolling = !0), this.pause()); - }), - e(this, "handleScrollEnd", () => { - (this.isScrolling = !1), this.isIntersecting && this.play(); - }), - e(this, "resize", () => { - (this.width = window.innerWidth), - this.minigl.setSize(this.width, this.height), - this.minigl.setOrthographicCamera(), - (this.xSegCount = Math.ceil(this.width * this.conf.density[0])), - (this.ySegCount = Math.ceil(this.height * this.conf.density[1])), - this.mesh.geometry.setTopology(this.xSegCount, this.ySegCount), - this.mesh.geometry.setSize(this.width, this.height), - (this.mesh.material.uniforms.u_shadow_power.value = this.width < 600 ? 5 : 6); - }), - e(this, "handleMouseDown", (e) => { - this.isGradientLegendVisible && - ((this.isMetaKey = e.metaKey), - (this.isMouseDown = !0), - !1 === this.conf.playing && requestAnimationFrame(this.animate)); - }), - e(this, "handleMouseUp", () => { - this.isMouseDown = !1; - }), - e(this, "animate", (e) => { - if (!this.shouldSkipFrame(e) || this.isMouseDown) { - if (((this.t += Math.min(e - this.last, 1e3 / 15)), (this.last = e), this.isMouseDown)) { - let e = 160; - this.isMetaKey && (e = -160), (this.t += e); - } - (this.mesh.material.uniforms.u_time.value = this.t), this.minigl.render(); - } - if (0 !== this.last && this.isStatic) return this.minigl.render(), void this.disconnect(); - /*this.isIntersecting && */ (this.conf.playing || this.isMouseDown) && - requestAnimationFrame(this.animate); - }), - e(this, "addIsLoadedClass", () => { - /*this.isIntersecting && */ !this.isLoadedClass && - ((this.isLoadedClass = !0), - this.el.classList.add("isLoaded"), - setTimeout(() => { - this.el.parentElement.classList.add("isLoaded"); - }, 3e3)); - }), - e(this, "pause", () => { - this.conf.playing = false; - }), - e(this, "play", () => { - requestAnimationFrame(this.animate), (this.conf.playing = true); - }), - e(this, "initGradient", (selector) => { - this.el = document.querySelector(selector); - this.connect(); - return this; - }); - } - async connect() { - (this.shaderFiles = { - vertex: "varying vec3 v_color;\n\nvoid main() {\n float time = u_time * u_global.noiseSpeed;\n\n vec2 noiseCoord = resolution * uvNorm * u_global.noiseFreq;\n\n vec2 st = 1. - uvNorm.xy;\n\n //\n // Tilting the plane\n //\n\n // Front-to-back tilt\n float tilt = resolution.y / 2.0 * uvNorm.y;\n\n // Left-to-right angle\n float incline = resolution.x * uvNorm.x / 2.0 * u_vertDeform.incline;\n\n // Up-down shift to offset incline\n float offset = resolution.x / 2.0 * u_vertDeform.incline * mix(u_vertDeform.offsetBottom, u_vertDeform.offsetTop, uv.y);\n\n //\n // Vertex noise\n //\n\n float noise = snoise(vec3(\n noiseCoord.x * u_vertDeform.noiseFreq.x + time * u_vertDeform.noiseFlow,\n noiseCoord.y * u_vertDeform.noiseFreq.y,\n time * u_vertDeform.noiseSpeed + u_vertDeform.noiseSeed\n )) * u_vertDeform.noiseAmp;\n\n // Fade noise to zero at edges\n noise *= 1.0 - pow(abs(uvNorm.y), 2.0);\n\n // Clamp to 0\n noise = max(0.0, noise);\n\n vec3 pos = vec3(\n position.x,\n position.y + tilt + incline + noise - offset,\n position.z\n );\n\n //\n // Vertex color, to be passed to fragment shader\n //\n\n if (u_active_colors[0] == 1.) {\n v_color = u_baseColor;\n }\n\n for (int i = 0; i < u_waveLayers_length; i++) {\n if (u_active_colors[i + 1] == 1.) {\n WaveLayers layer = u_waveLayers[i];\n\n float noise = smoothstep(\n layer.noiseFloor,\n layer.noiseCeil,\n snoise(vec3(\n noiseCoord.x * layer.noiseFreq.x + time * layer.noiseFlow,\n noiseCoord.y * layer.noiseFreq.y,\n time * layer.noiseSpeed + layer.noiseSeed\n )) / 2.0 + 0.5\n );\n\n v_color = blendNormal(v_color, layer.color, pow(noise, 4.));\n }\n }\n\n //\n // Finish\n //\n\n gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);\n}", - noise: "//\n// Description : Array and textureless GLSL 2D/3D/4D simplex\n// noise functions.\n// Author : Ian McEwan, Ashima Arts.\n// Maintainer : stegu\n// Lastmod : 20110822 (ijm)\n// License : Copyright (C) 2011 Ashima Arts. All rights reserved.\n// Distributed under the MIT License. See LICENSE file.\n// https://github.com/ashima/webgl-noise\n// https://github.com/stegu/webgl-noise\n//\n\nvec3 mod289(vec3 x) {\n return x - floor(x * (1.0 / 289.0)) * 289.0;\n}\n\nvec4 mod289(vec4 x) {\n return x - floor(x * (1.0 / 289.0)) * 289.0;\n}\n\nvec4 permute(vec4 x) {\n return mod289(((x*34.0)+1.0)*x);\n}\n\nvec4 taylorInvSqrt(vec4 r)\n{\n return 1.79284291400159 - 0.85373472095314 * r;\n}\n\nfloat snoise(vec3 v)\n{\n const vec2 C = vec2(1.0/6.0, 1.0/3.0) ;\n const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);\n\n// First corner\n vec3 i = floor(v + dot(v, C.yyy) );\n vec3 x0 = v - i + dot(i, C.xxx) ;\n\n// Other corners\n vec3 g = step(x0.yzx, x0.xyz);\n vec3 l = 1.0 - g;\n vec3 i1 = min( g.xyz, l.zxy );\n vec3 i2 = max( g.xyz, l.zxy );\n\n // x0 = x0 - 0.0 + 0.0 * C.xxx;\n // x1 = x0 - i1 + 1.0 * C.xxx;\n // x2 = x0 - i2 + 2.0 * C.xxx;\n // x3 = x0 - 1.0 + 3.0 * C.xxx;\n vec3 x1 = x0 - i1 + C.xxx;\n vec3 x2 = x0 - i2 + C.yyy; // 2.0*C.x = 1/3 = C.y\n vec3 x3 = x0 - D.yyy; // -1.0+3.0*C.x = -0.5 = -D.y\n\n// Permutations\n i = mod289(i);\n vec4 p = permute( permute( permute(\n i.z + vec4(0.0, i1.z, i2.z, 1.0 ))\n + i.y + vec4(0.0, i1.y, i2.y, 1.0 ))\n + i.x + vec4(0.0, i1.x, i2.x, 1.0 ));\n\n// Gradients: 7x7 points over a square, mapped onto an octahedron.\n// The ring size 17*17 = 289 is close to a multiple of 49 (49*6 = 294)\n float n_ = 0.142857142857; // 1.0/7.0\n vec3 ns = n_ * D.wyz - D.xzx;\n\n vec4 j = p - 49.0 * floor(p * ns.z * ns.z); // mod(p,7*7)\n\n vec4 x_ = floor(j * ns.z);\n vec4 y_ = floor(j - 7.0 * x_ ); // mod(j,N)\n\n vec4 x = x_ *ns.x + ns.yyyy;\n vec4 y = y_ *ns.x + ns.yyyy;\n vec4 h = 1.0 - abs(x) - abs(y);\n\n vec4 b0 = vec4( x.xy, y.xy );\n vec4 b1 = vec4( x.zw, y.zw );\n\n //vec4 s0 = vec4(lessThan(b0,0.0))*2.0 - 1.0;\n //vec4 s1 = vec4(lessThan(b1,0.0))*2.0 - 1.0;\n vec4 s0 = floor(b0)*2.0 + 1.0;\n vec4 s1 = floor(b1)*2.0 + 1.0;\n vec4 sh = -step(h, vec4(0.0));\n\n vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy ;\n vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww ;\n\n vec3 p0 = vec3(a0.xy,h.x);\n vec3 p1 = vec3(a0.zw,h.y);\n vec3 p2 = vec3(a1.xy,h.z);\n vec3 p3 = vec3(a1.zw,h.w);\n\n//Normalise gradients\n vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2, p2), dot(p3,p3)));\n p0 *= norm.x;\n p1 *= norm.y;\n p2 *= norm.z;\n p3 *= norm.w;\n\n// Mix final noise value\n vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);\n m = m * m;\n return 42.0 * dot( m*m, vec4( dot(p0,x0), dot(p1,x1),\n dot(p2,x2), dot(p3,x3) ) );\n}", - blend: "//\n// https://github.com/jamieowen/glsl-blend\n//\n\n// Normal\n\nvec3 blendNormal(vec3 base, vec3 blend) {\n\treturn blend;\n}\n\nvec3 blendNormal(vec3 base, vec3 blend, float opacity) {\n\treturn (blendNormal(base, blend) * opacity + base * (1.0 - opacity));\n}\n\n// Screen\n\nfloat blendScreen(float base, float blend) {\n\treturn 1.0-((1.0-base)*(1.0-blend));\n}\n\nvec3 blendScreen(vec3 base, vec3 blend) {\n\treturn vec3(blendScreen(base.r,blend.r),blendScreen(base.g,blend.g),blendScreen(base.b,blend.b));\n}\n\nvec3 blendScreen(vec3 base, vec3 blend, float opacity) {\n\treturn (blendScreen(base, blend) * opacity + base * (1.0 - opacity));\n}\n\n// Multiply\n\nvec3 blendMultiply(vec3 base, vec3 blend) {\n\treturn base*blend;\n}\n\nvec3 blendMultiply(vec3 base, vec3 blend, float opacity) {\n\treturn (blendMultiply(base, blend) * opacity + base * (1.0 - opacity));\n}\n\n// Overlay\n\nfloat blendOverlay(float base, float blend) {\n\treturn base<0.5?(2.0*base*blend):(1.0-2.0*(1.0-base)*(1.0-blend));\n}\n\nvec3 blendOverlay(vec3 base, vec3 blend) {\n\treturn vec3(blendOverlay(base.r,blend.r),blendOverlay(base.g,blend.g),blendOverlay(base.b,blend.b));\n}\n\nvec3 blendOverlay(vec3 base, vec3 blend, float opacity) {\n\treturn (blendOverlay(base, blend) * opacity + base * (1.0 - opacity));\n}\n\n// Hard light\n\nvec3 blendHardLight(vec3 base, vec3 blend) {\n\treturn blendOverlay(blend,base);\n}\n\nvec3 blendHardLight(vec3 base, vec3 blend, float opacity) {\n\treturn (blendHardLight(base, blend) * opacity + base * (1.0 - opacity));\n}\n\n// Soft light\n\nfloat blendSoftLight(float base, float blend) {\n\treturn (blend<0.5)?(2.0*base*blend+base*base*(1.0-2.0*blend)):(sqrt(base)*(2.0*blend-1.0)+2.0*base*(1.0-blend));\n}\n\nvec3 blendSoftLight(vec3 base, vec3 blend) {\n\treturn vec3(blendSoftLight(base.r,blend.r),blendSoftLight(base.g,blend.g),blendSoftLight(base.b,blend.b));\n}\n\nvec3 blendSoftLight(vec3 base, vec3 blend, float opacity) {\n\treturn (blendSoftLight(base, blend) * opacity + base * (1.0 - opacity));\n}\n\n// Color dodge\n\nfloat blendColorDodge(float base, float blend) {\n\treturn (blend==1.0)?blend:min(base/(1.0-blend),1.0);\n}\n\nvec3 blendColorDodge(vec3 base, vec3 blend) {\n\treturn vec3(blendColorDodge(base.r,blend.r),blendColorDodge(base.g,blend.g),blendColorDodge(base.b,blend.b));\n}\n\nvec3 blendColorDodge(vec3 base, vec3 blend, float opacity) {\n\treturn (blendColorDodge(base, blend) * opacity + base * (1.0 - opacity));\n}\n\n// Color burn\n\nfloat blendColorBurn(float base, float blend) {\n\treturn (blend==0.0)?blend:max((1.0-((1.0-base)/blend)),0.0);\n}\n\nvec3 blendColorBurn(vec3 base, vec3 blend) {\n\treturn vec3(blendColorBurn(base.r,blend.r),blendColorBurn(base.g,blend.g),blendColorBurn(base.b,blend.b));\n}\n\nvec3 blendColorBurn(vec3 base, vec3 blend, float opacity) {\n\treturn (blendColorBurn(base, blend) * opacity + base * (1.0 - opacity));\n}\n\n// Vivid Light\n\nfloat blendVividLight(float base, float blend) {\n\treturn (blend<0.5)?blendColorBurn(base,(2.0*blend)):blendColorDodge(base,(2.0*(blend-0.5)));\n}\n\nvec3 blendVividLight(vec3 base, vec3 blend) {\n\treturn vec3(blendVividLight(base.r,blend.r),blendVividLight(base.g,blend.g),blendVividLight(base.b,blend.b));\n}\n\nvec3 blendVividLight(vec3 base, vec3 blend, float opacity) {\n\treturn (blendVividLight(base, blend) * opacity + base * (1.0 - opacity));\n}\n\n// Lighten\n\nfloat blendLighten(float base, float blend) {\n\treturn max(blend,base);\n}\n\nvec3 blendLighten(vec3 base, vec3 blend) {\n\treturn vec3(blendLighten(base.r,blend.r),blendLighten(base.g,blend.g),blendLighten(base.b,blend.b));\n}\n\nvec3 blendLighten(vec3 base, vec3 blend, float opacity) {\n\treturn (blendLighten(base, blend) * opacity + base * (1.0 - opacity));\n}\n\n// Linear burn\n\nfloat blendLinearBurn(float base, float blend) {\n\t// Note : Same implementation as BlendSubtractf\n\treturn max(base+blend-1.0,0.0);\n}\n\nvec3 blendLinearBurn(vec3 base, vec3 blend) {\n\t// Note : Same implementation as BlendSubtract\n\treturn max(base+blend-vec3(1.0),vec3(0.0));\n}\n\nvec3 blendLinearBurn(vec3 base, vec3 blend, float opacity) {\n\treturn (blendLinearBurn(base, blend) * opacity + base * (1.0 - opacity));\n}\n\n// Linear dodge\n\nfloat blendLinearDodge(float base, float blend) {\n\t// Note : Same implementation as BlendAddf\n\treturn min(base+blend,1.0);\n}\n\nvec3 blendLinearDodge(vec3 base, vec3 blend) {\n\t// Note : Same implementation as BlendAdd\n\treturn min(base+blend,vec3(1.0));\n}\n\nvec3 blendLinearDodge(vec3 base, vec3 blend, float opacity) {\n\treturn (blendLinearDodge(base, blend) * opacity + base * (1.0 - opacity));\n}\n\n// Linear light\n\nfloat blendLinearLight(float base, float blend) {\n\treturn blend<0.5?blendLinearBurn(base,(2.0*blend)):blendLinearDodge(base,(2.0*(blend-0.5)));\n}\n\nvec3 blendLinearLight(vec3 base, vec3 blend) {\n\treturn vec3(blendLinearLight(base.r,blend.r),blendLinearLight(base.g,blend.g),blendLinearLight(base.b,blend.b));\n}\n\nvec3 blendLinearLight(vec3 base, vec3 blend, float opacity) {\n\treturn (blendLinearLight(base, blend) * opacity + base * (1.0 - opacity));\n}", - fragment: - "varying vec3 v_color;\n\nvoid main() {\n vec3 color = v_color;\n if (u_darken_top == 1.0) {\n vec2 st = gl_FragCoord.xy/resolution.xy;\n color.g -= pow(st.y + sin(-12.0) * st.x, u_shadow_power) * 0.4;\n }\n gl_FragColor = vec4(color, 1.0);\n}", - }), - (this.conf = { - presetName: "", - wireframe: false, - density: [0.06, 0.16], - zoom: 1, - rotation: 0, - playing: true, - }), - document.querySelectorAll("canvas").length < 1 - ? console.log("DID NOT LOAD HERO STRIPE CANVAS") - : ((this.minigl = new MiniGl(this.el, null, null, !0)), - requestAnimationFrame(() => { - this.el && ((this.computedCanvasStyle = getComputedStyle(this.el)), this.waitForCssVars()); - })); - /* - this.scrollObserver = await s.create(.1, !1), - this.scrollObserver.observe(this.el), - this.scrollObserver.onSeparate(() => { - window.removeEventListener("scroll", this.handleScroll), window.removeEventListener("mousedown", this.handleMouseDown), window.removeEventListener("mouseup", this.handleMouseUp), window.removeEventListener("keydown", this.handleKeyDown), this.isIntersecting = !1, this.conf.playing && this.pause() - }), - this.scrollObserver.onIntersect(() => { - window.addEventListener("scroll", this.handleScroll), window.addEventListener("mousedown", this.handleMouseDown), window.addEventListener("mouseup", this.handleMouseUp), window.addEventListener("keydown", this.handleKeyDown), this.isIntersecting = !0, this.addIsLoadedClass(), this.play() - })*/ - } - disconnect() { - this.scrollObserver && - (window.removeEventListener("scroll", this.handleScroll), - window.removeEventListener("mousedown", this.handleMouseDown), - window.removeEventListener("mouseup", this.handleMouseUp), - window.removeEventListener("keydown", this.handleKeyDown), - this.scrollObserver.disconnect()), - window.removeEventListener("resize", this.resize); - } - initMaterial() { - this.uniforms = { - u_time: new this.minigl.Uniform({ - value: 0, - }), - u_shadow_power: new this.minigl.Uniform({ - value: 5, - }), - u_darken_top: new this.minigl.Uniform({ - value: "" === this.el.dataset.jsDarkenTop ? 1 : 0, - }), - u_active_colors: new this.minigl.Uniform({ - value: this.activeColors, - type: "vec4", - }), - u_global: new this.minigl.Uniform({ - value: { - noiseFreq: new this.minigl.Uniform({ - value: [this.freqX, this.freqY], - type: "vec2", - }), - noiseSpeed: new this.minigl.Uniform({ - value: 5e-6, - }), - }, - type: "struct", - }), - u_vertDeform: new this.minigl.Uniform({ - value: { - incline: new this.minigl.Uniform({ - value: Math.sin(this.angle) / Math.cos(this.angle), - }), - offsetTop: new this.minigl.Uniform({ - value: -0.5, - }), - offsetBottom: new this.minigl.Uniform({ - value: -0.5, - }), - noiseFreq: new this.minigl.Uniform({ - value: [3, 4], - type: "vec2", - }), - noiseAmp: new this.minigl.Uniform({ - value: this.amp, - }), - noiseSpeed: new this.minigl.Uniform({ - value: 10, - }), - noiseFlow: new this.minigl.Uniform({ - value: 3, - }), - noiseSeed: new this.minigl.Uniform({ - value: this.seed, - }), - }, - type: "struct", - excludeFrom: "fragment", - }), - u_baseColor: new this.minigl.Uniform({ - value: this.sectionColors[0], - type: "vec3", - excludeFrom: "fragment", - }), - u_waveLayers: new this.minigl.Uniform({ - value: [], - excludeFrom: "fragment", - type: "array", - }), - }; - for (let e = 1; e < this.sectionColors.length; e += 1) - this.uniforms.u_waveLayers.value.push( - new this.minigl.Uniform({ - value: { - color: new this.minigl.Uniform({ - value: this.sectionColors[e], - type: "vec3", - }), - noiseFreq: new this.minigl.Uniform({ - value: [2 + e / this.sectionColors.length, 3 + e / this.sectionColors.length], - type: "vec2", - }), - noiseSpeed: new this.minigl.Uniform({ - value: 11 + 0.3 * e, - }), - noiseFlow: new this.minigl.Uniform({ - value: 6.5 + 0.3 * e, - }), - noiseSeed: new this.minigl.Uniform({ - value: this.seed + 10 * e, - }), - noiseFloor: new this.minigl.Uniform({ - value: 0.1, - }), - noiseCeil: new this.minigl.Uniform({ - value: 0.63 + 0.07 * e, - }), - }, - type: "struct", - }), - ); - return ( - (this.vertexShader = [this.shaderFiles.noise, this.shaderFiles.blend, this.shaderFiles.vertex].join( - "\n\n", - )), - new this.minigl.Material(this.vertexShader, this.shaderFiles.fragment, this.uniforms) - ); - } - initMesh() { - (this.material = this.initMaterial()), - (this.geometry = new this.minigl.PlaneGeometry()), - (this.mesh = new this.minigl.Mesh(this.geometry, this.material)); - } - shouldSkipFrame(e) { - return !!window.document.hidden || !this.conf.playing || parseInt(e, 10) % 2 == 0 || void 0; - } - updateFrequency(e) { - (this.freqX += e), (this.freqY += e); - } - toggleColor(index) { - this.activeColors[index] = 0 === this.activeColors[index] ? 1 : 0; - } - showGradientLegend() { - this.width > this.minWidth && - ((this.isGradientLegendVisible = !0), document.body.classList.add("isGradientLegendVisible")); - } - hideGradientLegend() { - (this.isGradientLegendVisible = !1), document.body.classList.remove("isGradientLegendVisible"); - } - init() { - this.initGradientColors(), - this.initMesh(), - this.resize(), - requestAnimationFrame(this.animate), - window.addEventListener("resize", this.resize); - } - /* - * Waiting for the css variables to become available, usually on page load before we can continue. - * Using default colors assigned below if no variables have been found after maxCssVarRetries - */ - waitForCssVars() { - if ( - this.computedCanvasStyle && - -1 !== this.computedCanvasStyle.getPropertyValue("--gradient-color-1").indexOf("#") - ) - this.init(), this.addIsLoadedClass(); - else { - if (((this.cssVarRetries += 1), this.cssVarRetries > this.maxCssVarRetries)) { - return (this.sectionColors = [16711680, 16711680, 16711935, 65280, 255]), void this.init(); - } - requestAnimationFrame(() => this.waitForCssVars()); - } - } - /* - * Initializes the four section colors by retrieving them from css variables. - */ - initGradientColors() { - this.sectionColors = ["--gradient-color-1", "--gradient-color-2", "--gradient-color-3", "--gradient-color-4"] - .map((cssPropertyName) => { - let hex = this.computedCanvasStyle.getPropertyValue(cssPropertyName).trim(); - //Check if shorthand hex value was used and double the length so the conversion in normalizeColor will work. - if (4 === hex.length) { - const hexTemp = hex - .substr(1) - .split("") - .map((hexTemp) => hexTemp + hexTemp) - .join(""); - hex = `#${hexTemp}`; - } - return hex && `0x${hex.substr(1)}`; - }) - .filter(Boolean) - .map(normalizeColor); - } -} - -/* - *Finally initializing the Gradient class, assigning a canvas to it and calling Gradient.connect() which initializes everything, - * Use Gradient.pause() and Gradient.play() for controls. - * - * Here are some default property values you can change anytime: - * Amplitude: Gradient.amp = 0 - * Colors: Gradient.sectionColors (if you change colors, use normalizeColor(#hexValue)) before you assign it. - * - * - * Useful functions - * Gradient.toggleColor(index) - * Gradient.updateFrequency(freq) - */ - -export { Gradient }; diff --git a/app/core/layouts/layout/loader.module.css b/app/core/layouts/layout/loader.module.css deleted file mode 100644 index 2ab5eba..0000000 --- a/app/core/layouts/layout/loader.module.css +++ /dev/null @@ -1,8 +0,0 @@ -#gradientCanvas { - width: 100%; - height: 100%; - --gradient-color-1: #c3e4ff; - --gradient-color-2: #6ec3f4; - --gradient-color-3: #eae2ff; - --gradient-color-4: #b9beff; -} diff --git a/app/core/layouts/layout/loader.tsx b/app/core/layouts/layout/loader.tsx deleted file mode 100644 index f589f9b..0000000 --- a/app/core/layouts/layout/loader.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect } from "react"; - -import Logo from "../../components/logo"; -import { Gradient } from "./loader-gradient.js"; - -import styles from "./loader.module.css"; - -export default function Loader() { - useEffect(() => { - const gradient = new Gradient(); - // @ts-ignore - gradient.initGradient(`#${styles.gradientCanvas}`); - }, []); - - return ( -
-
-
- - Loading up Shellphone... -
-
- -
- ); -} diff --git a/app/core/mutations/set-notification-subscription.ts b/app/core/mutations/set-notification-subscription.ts deleted file mode 100644 index 52a4765..0000000 --- a/app/core/mutations/set-notification-subscription.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { resolver } from "blitz"; -import { z } from "zod"; - -import db from "../../../db"; -import appLogger from "../../../integrations/logger"; -import { enforceSuperAdminIfNotCurrentOrganization, setDefaultOrganizationId } from "../utils"; - -const logger = appLogger.child({ mutation: "set-notification-subscription" }); - -const Body = z.object({ - organizationId: z.string().optional(), - phoneNumberId: z.string(), - subscription: z.object({ - endpoint: z.string(), - expirationTime: z.number().nullable(), - keys: z.object({ - p256dh: z.string(), - auth: z.string(), - }), - }), -}); - -export default resolver.pipe( - resolver.zod(Body), - resolver.authorize(), - setDefaultOrganizationId, - enforceSuperAdminIfNotCurrentOrganization, - async ({ organizationId, phoneNumberId, subscription }) => { - const phoneNumber = await db.phoneNumber.findFirst({ - where: { id: phoneNumberId, organizationId }, - include: { organization: true }, - }); - if (!phoneNumber) { - return; - } - - try { - await db.notificationSubscription.create({ - data: { - organizationId, - phoneNumberId, - endpoint: subscription.endpoint, - expirationTime: subscription.expirationTime, - keys_p256dh: subscription.keys.p256dh, - keys_auth: subscription.keys.auth, - }, - }); - } catch (error: any) { - if (error.code !== "P2002") { - logger.error(error); - // we might want to `throw error`; - } - } - }, -); diff --git a/app/core/styles/index.css b/app/core/styles/index.css deleted file mode 100644 index 0e013e7..0000000 --- a/app/core/styles/index.css +++ /dev/null @@ -1,153 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@font-face { - font-family: "Inter var"; - font-weight: 100 900; - font-display: optional; - font-style: normal; - font-named-instance: "Regular"; - src: url("/fonts/inter-roman.var.woff2") format("woff2"); -} - -@font-face { - font-family: "Inter var"; - font-weight: 100 900; - font-display: optional; - font-style: italic; - font-named-instance: "Italic"; - src: url("/fonts/inter-italic.var.woff2") format("woff2"); -} - -@font-face { - font-family: "P22 Mackinac Pro"; - src: url("/fonts/P22MackinacPro-Book.woff2") format("woff2"); - font-weight: 400; - font-style: normal; - font-display: optional; -} - -@font-face { - font-family: "P22 Mackinac Pro"; - src: url("/fonts/P22MackinacPro-Bold.woff2") format("woff2"); - font-weight: 700; - font-style: normal; - font-display: optional; -} - -@font-face { - font-family: "P22 Mackinac Pro"; - src: url("/fonts/P22MackinacPro-ExtraBold.woff2") format("woff2"); - font-weight: 800; - font-style: normal; - font-display: optional; -} - -@font-face { - font-family: "P22 Mackinac Pro"; - src: url("/fonts/P22MackinacPro-Medium.woff2") format("woff2"); - font-weight: 500; - font-style: normal; - font-display: optional; -} - -.font-heading { - @apply font-mackinac tracking-tight font-bold; - word-spacing: 0.025em; -} - -.divide-y > :first-child { - @apply border-t; -} - -.divide-y > :last-child:not([hidden]) { - @apply border-b; -} - -.h1 { - @apply text-4xl font-extrabold tracking-tighter; -} - -.h2 { - @apply text-3xl font-extrabold tracking-tighter; -} - -.h3 { - @apply text-3xl font-extrabold; -} - -.h4 { - @apply text-2xl font-extrabold tracking-tight; -} - -@screen md { - .h1 { - @apply text-5xl; - } - - .h2 { - @apply text-4xl; - } -} - -.btn, -.btn-sm { - @apply font-medium inline-flex items-center justify-center border border-transparent rounded leading-snug transition duration-150 ease-in-out; -} - -.btn { - @apply px-8 py-3; -} - -.btn-sm { - @apply px-4 py-2; -} - -.form-input, -.form-textarea, -.form-multiselect, -.form-select, -.form-checkbox, -.form-radio { - @apply bg-white border border-gray-300 focus:border-gray-400; -} - -.form-input, -.form-textarea, -.form-multiselect, -.form-select, -.form-checkbox { - @apply rounded; -} - -.form-input, -.form-textarea, -.form-multiselect, -.form-select { - @apply leading-snug py-3 px-4; -} - -.form-input, -.form-textarea { - @apply placeholder-gray-500; -} - -.form-select { - @apply pr-10; -} - -.form-checkbox, -.form-radio { - @apply text-primary-600; -} - -/* Chrome, Safari and Opera */ -.no-scrollbar::-webkit-scrollbar { - display: none; -} - -.no-scrollbar { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ -} diff --git a/app/core/types.ts b/app/core/types.ts deleted file mode 100644 index b528718..0000000 --- a/app/core/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type ApiError = { - statusCode: number; - errorMessage: string; -}; diff --git a/app/core/utils.ts b/app/core/utils.ts deleted file mode 100644 index a704753..0000000 --- a/app/core/utils.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Ctx } from "blitz"; - -import type { Prisma } from "../../db"; -import { GlobalRole } from "../../db"; - -function assert(condition: any, message: string): asserts condition { - if (!condition) throw new Error(message); -} - -export function setDefaultOrganizationId>( - input: T, - { session }: Ctx, -): T & { organizationId: Prisma.StringNullableFilter | string } { - assert(session.orgId, "Missing session.orgId in setDefaultOrganizationId"); - if (input.organizationId) { - // Pass through the input - return input as T & { organizationId: string }; - } else if (session.roles?.includes(GlobalRole.SUPERADMIN)) { - // Allow viewing any organization - return { ...input, organizationId: { not: "" } }; - } else { - // Set organizationId to session.orgId - return { ...input, organizationId: session.orgId }; - } -} - -export function enforceSuperAdminIfNotCurrentOrganization>(input: T, ctx: Ctx): T { - assert(ctx.session.orgId, "missing session.orgId"); - assert(input.organizationId, "missing input.organizationId"); - - if (input.organizationId !== ctx.session.orgId) { - ctx.session.$authorize(GlobalRole.SUPERADMIN); - } - return input; -} diff --git a/app/cron-jobs/index.ts b/app/cron-jobs/index.ts new file mode 100644 index 0000000..50707bc --- /dev/null +++ b/app/cron-jobs/index.ts @@ -0,0 +1,3 @@ +import registerPurgeExpiredSession from "./purge-expired-sessions"; + +export default [registerPurgeExpiredSession]; diff --git a/app/cron-jobs/purge-expired-sessions.ts b/app/cron-jobs/purge-expired-sessions.ts new file mode 100644 index 0000000..1be6cdc --- /dev/null +++ b/app/cron-jobs/purge-expired-sessions.ts @@ -0,0 +1,14 @@ +import db from "~/utils/db.server"; +import { CronJob } from "~/utils/queue.server"; + +export default CronJob( + "purge expired sessions", + async () => { + await db.session.deleteMany({ + where: { + expiresAt: { lt: new Date() }, + }, + }); + }, + "0 0 * * *", +); diff --git a/app/entry.client.tsx b/app/entry.client.tsx new file mode 100644 index 0000000..57dba48 --- /dev/null +++ b/app/entry.client.tsx @@ -0,0 +1,4 @@ +import { hydrate } from "react-dom"; +import { RemixBrowser } from "@remix-run/react"; + +hydrate(, document); diff --git a/app/entry.server.tsx b/app/entry.server.tsx new file mode 100644 index 0000000..8580a99 --- /dev/null +++ b/app/entry.server.tsx @@ -0,0 +1,19 @@ +import { renderToString } from "react-dom/server"; +import type { EntryContext } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + const markup = renderToString(); + + responseHeaders.set("Content-Type", "text/html"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/app/features/auth/actions/forgot-password.ts b/app/features/auth/actions/forgot-password.ts new file mode 100644 index 0000000..e3546bf --- /dev/null +++ b/app/features/auth/actions/forgot-password.ts @@ -0,0 +1,63 @@ +import { type ActionFunction, json } from "@remix-run/node"; +import { type User, TokenType } from "@prisma/client"; + +import db from "~/utils/db.server"; +import { type FormError, validate } from "~/utils/validation.server"; +import { sendForgotPasswordEmail } from "~/mailers/forgot-password-mailer.server"; +import { generateToken, hashToken } from "~/utils/token.server"; +import { ForgotPassword } from "../validations"; + +const RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS = 24; + +type ForgotPasswordFailureActionData = { errors: FormError; submitted?: never }; +type ForgotPasswordSuccessfulActionData = { errors?: never; submitted: true }; +export type ForgotPasswordActionData = ForgotPasswordFailureActionData | ForgotPasswordSuccessfulActionData; + +const action: ActionFunction = async ({ request }) => { + const formData = Object.fromEntries(await request.formData()); + const validation = validate(ForgotPassword, formData); + if (validation.errors) { + return json({ errors: validation.errors }); + } + + const { email } = validation.data; + const user = await db.user.findUnique({ where: { email: email.toLowerCase() } }); + + // always wait the same amount of time so attackers can't tell the difference whether a user is found + await Promise.all([updatePassword(user), new Promise((resolve) => setTimeout(resolve, 750))]); + + // return the same result whether a password reset email was sent or not + return json({ submitted: true }); +}; + +export default action; + +async function updatePassword(user: User | null) { + const membership = await db.membership.findFirst({ where: { userId: user?.id } }); + if (!user || !membership) { + return; + } + + const token = generateToken(); + const hashedToken = hashToken(token); + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS); + + await db.token.deleteMany({ where: { type: TokenType.RESET_PASSWORD, userId: user.id } }); + await db.token.create({ + data: { + user: { connect: { id: user.id } }, + membership: { connect: { id: membership.id } }, + type: TokenType.RESET_PASSWORD, + expiresAt, + hashedToken, + sentTo: user.email, + }, + }); + + await sendForgotPasswordEmail({ + to: user.email, + token, + userName: user.fullName, + }); +} diff --git a/app/features/auth/actions/register.ts b/app/features/auth/actions/register.ts new file mode 100644 index 0000000..564dc06 --- /dev/null +++ b/app/features/auth/actions/register.ts @@ -0,0 +1,56 @@ +import { type ActionFunction, json } from "@remix-run/node"; +import { GlobalRole, MembershipRole } from "@prisma/client"; + +import db from "~/utils/db.server"; +import { authenticate, hashPassword } from "~/utils/auth.server"; +import { type FormError, validate } from "~/utils/validation.server"; +import { Register } from "../validations"; + +export type RegisterActionData = { + errors: FormError; +}; + +const action: ActionFunction = async ({ request }) => { + const formData = Object.fromEntries(await request.formData()); + const validation = validate(Register, formData); + if (validation.errors) { + return json({ errors: validation.errors }); + } + + const { orgName, fullName, email, password } = validation.data; + const hashedPassword = await hashPassword(password.trim()); + try { + await db.user.create({ + data: { + fullName: fullName.trim(), + email: email.toLowerCase().trim(), + hashedPassword, + role: GlobalRole.CUSTOMER, + memberships: { + create: { + role: MembershipRole.OWNER, + organization: { + create: { name: orgName }, + }, + }, + }, + }, + }); + } catch (error: any) { + if (error.code === "P2002") { + if (error.meta.target[0] === "email") { + return json({ + errors: { general: "An account with this email address already exists" }, + }); + } + } + + return json({ + errors: { general: `An unexpected error happened${error.code ? `\nCode: ${error.code}` : ""}` }, + }); + } + + return authenticate({ email, password, request, failureRedirect: "/register" }); +}; + +export default action; diff --git a/app/features/auth/actions/reset-password.ts b/app/features/auth/actions/reset-password.ts new file mode 100644 index 0000000..1ddb2b2 --- /dev/null +++ b/app/features/auth/actions/reset-password.ts @@ -0,0 +1,56 @@ +import { type ActionFunction, json, redirect } from "@remix-run/node"; +import { TokenType } from "@prisma/client"; + +import db from "~/utils/db.server"; +import logger from "~/utils/logger.server"; +import { type FormError, validate } from "~/utils/validation.server"; +import { authenticate, hashPassword } from "~/utils/auth.server"; +import { ResetPasswordError } from "~/utils/errors"; +import { hashToken } from "~/utils/token.server"; +import { ResetPassword } from "../validations"; + +export type ResetPasswordActionData = { errors: FormError }; + +const action: ActionFunction = async ({ request }) => { + const searchParams = new URL(request.url).searchParams; + const token = searchParams.get("token"); + if (!token) { + return redirect("/forgot-password"); + } + + const formData = Object.fromEntries(await request.formData()); + const validation = validate(ResetPassword, { ...formData, token }); + if (validation.errors) { + return json({ errors: validation.errors }); + } + + const hashedToken = hashToken(token); + const savedToken = await db.token.findFirst({ + where: { hashedToken, type: TokenType.RESET_PASSWORD }, + include: { user: true }, + }); + if (!savedToken) { + logger.warn(`No token found with hashedToken=${hashedToken}`); + throw new ResetPasswordError(); + } + + await db.token.delete({ where: { id: savedToken.id } }); + + if (savedToken.expiresAt < new Date()) { + logger.warn(`Token with hashedToken=${hashedToken} is expired since ${savedToken.expiresAt.toUTCString()}`); + throw new ResetPasswordError(); + } + + const password = validation.data.password.trim(); + const hashedPassword = await hashPassword(password); + const { email } = await db.user.update({ + where: { id: savedToken.userId }, + data: { hashedPassword }, + }); + + await db.session.deleteMany({ where: { userId: savedToken.userId } }); + + return authenticate({ email, password, request }); +}; + +export default action; diff --git a/app/features/auth/actions/sign-in.ts b/app/features/auth/actions/sign-in.ts new file mode 100644 index 0000000..ba73a06 --- /dev/null +++ b/app/features/auth/actions/sign-in.ts @@ -0,0 +1,22 @@ +import { type ActionFunction, json } from "@remix-run/node"; + +import { SignIn } from "../validations"; +import { type FormError, validate } from "~/utils/validation.server"; +import { authenticate } from "~/utils/auth.server"; + +export type SignInActionData = { errors: FormError }; + +const action: ActionFunction = async ({ request }) => { + const formData = Object.fromEntries(await request.clone().formData()); + const validation = validate(SignIn, formData); + if (validation.errors) { + return json({ errors: validation.errors }); + } + + const searchParams = new URL(request.url).searchParams; + const redirectTo = searchParams.get("redirectTo"); + const { email, password } = validation.data; + return authenticate({ email, password, request, successRedirect: redirectTo }); +}; + +export default action; diff --git a/app/features/auth/loaders/forgot-password.ts b/app/features/auth/loaders/forgot-password.ts new file mode 100644 index 0000000..0c7edb4 --- /dev/null +++ b/app/features/auth/loaders/forgot-password.ts @@ -0,0 +1,11 @@ +import type { LoaderFunction } from "@remix-run/node"; + +import { requireLoggedOut } from "~/utils/auth.server"; + +const loader: LoaderFunction = async ({ request }) => { + await requireLoggedOut(request); + + return null; +}; + +export default loader; diff --git a/app/features/auth/loaders/register.ts b/app/features/auth/loaders/register.ts new file mode 100644 index 0000000..ce6a626 --- /dev/null +++ b/app/features/auth/loaders/register.ts @@ -0,0 +1,25 @@ +import { type LoaderFunction, json } from "@remix-run/node"; + +import { getErrorMessage, requireLoggedOut } from "~/utils/auth.server"; +import { commitSession, getSession } from "~/utils/session.server"; + +export type RegisterLoaderData = { errors: { general: string } } | null; + +const loader: LoaderFunction = async ({ request }) => { + const session = await getSession(request); + const errorMessage = getErrorMessage(session); + if (errorMessage) { + return json( + { errors: { general: errorMessage } }, + { + headers: { "Set-Cookie": await commitSession(session) }, + }, + ); + } + + await requireLoggedOut(request); + + return null; +}; + +export default loader; diff --git a/app/features/auth/loaders/reset-password.ts b/app/features/auth/loaders/reset-password.ts new file mode 100644 index 0000000..81975cb --- /dev/null +++ b/app/features/auth/loaders/reset-password.ts @@ -0,0 +1,23 @@ +import { type LoaderFunction, redirect } from "@remix-run/node"; + +import { requireLoggedOut } from "~/utils/auth.server"; +import { commitSession, getSession } from "~/utils/session.server"; + +const loader: LoaderFunction = async ({ request }) => { + const session = await getSession(request); + const searchParams = new URL(request.url).searchParams; + const token = searchParams.get("token"); + if (!token) { + return redirect("/forgot-password"); + } + + await requireLoggedOut(request); + + return new Response(null, { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); +}; + +export default loader; diff --git a/app/features/auth/loaders/sign-in.ts b/app/features/auth/loaders/sign-in.ts new file mode 100644 index 0000000..4c04c91 --- /dev/null +++ b/app/features/auth/loaders/sign-in.ts @@ -0,0 +1,25 @@ +import { type LoaderFunction, json } from "@remix-run/node"; + +import { getErrorMessage, requireLoggedOut } from "~/utils/auth.server"; +import { commitSession, getSession } from "~/utils/session.server"; + +export type SignInLoaderData = { errors: { general: string } } | null; + +const loader: LoaderFunction = async ({ request }) => { + const session = await getSession(request); + const errorMessage = getErrorMessage(session); + if (errorMessage) { + return json( + { errors: { general: errorMessage } }, + { + headers: { "Set-Cookie": await commitSession(session) }, + }, + ); + } + + await requireLoggedOut(request); + + return null; +}; + +export default loader; diff --git a/app/features/auth/pages/forgot-password.tsx b/app/features/auth/pages/forgot-password.tsx new file mode 100644 index 0000000..a1107ad --- /dev/null +++ b/app/features/auth/pages/forgot-password.tsx @@ -0,0 +1,49 @@ +import { Form, useActionData, useTransition } from "@remix-run/react"; + +import type { ForgotPasswordActionData } from "../actions/forgot-password"; +import LabeledTextField from "~/features/core/components/labeled-text-field"; +import Button from "~/features/core/components/button"; + +export default function ForgotPasswordPage() { + const actionData = useActionData(); + const transition = useTransition(); + const isSubmitting = transition.state === "submitting"; + + return ( +
+
+

+ Forgot your password? +

+
+ +
+ {actionData?.submitted ? ( +

+ If your email is in our system, you will receive instructions to reset your password shortly. +

+ ) : ( + <> + + + + + )} + +
+ ); +} diff --git a/app/features/auth/pages/register.tsx b/app/features/auth/pages/register.tsx new file mode 100644 index 0000000..89f5697 --- /dev/null +++ b/app/features/auth/pages/register.tsx @@ -0,0 +1,83 @@ +import { Form, Link, useActionData, useLoaderData, useTransition } from "@remix-run/react"; + +import type { RegisterActionData } from "../actions/register"; +import type { RegisterLoaderData } from "../loaders/register"; +import LabeledTextField from "~/features/core/components/labeled-text-field"; +import Alert from "~/features/core/components/alert"; +import Button from "~/features/core/components/button"; + +export default function RegisterPage() { + const loaderData = useLoaderData(); + const actionData = useActionData(); + const transition = useTransition(); + const isSubmitting = transition.state === "submitting"; + const topErrorMessage = loaderData?.errors?.general || actionData?.errors?.general; + + return ( +
+
+

+ Create your account +

+

+ + Already have an account? + +

+
+ +
+ {topErrorMessage ? ( +
+ +
+ ) : null} + + + + + + + + +
+ ); +} diff --git a/app/features/auth/pages/reset-password.tsx b/app/features/auth/pages/reset-password.tsx new file mode 100644 index 0000000..258245a --- /dev/null +++ b/app/features/auth/pages/reset-password.tsx @@ -0,0 +1,55 @@ +import { Form, useActionData, useSearchParams, useTransition } from "@remix-run/react"; +import clsx from "clsx"; + +import type { ResetPasswordActionData } from "../actions/reset-password"; +import LabeledTextField from "~/features/core/components/labeled-text-field"; + +export default function ForgotPasswordPage() { + const [searchParams] = useSearchParams(); + const actionData = useActionData(); + const transition = useTransition(); + const isSubmitting = transition.state === "submitting"; + + return ( +
+
+

Set a new password

+
+ +
+ + + + + + +
+ ); +} diff --git a/app/features/auth/pages/sign-in.tsx b/app/features/auth/pages/sign-in.tsx new file mode 100644 index 0000000..0026617 --- /dev/null +++ b/app/features/auth/pages/sign-in.tsx @@ -0,0 +1,75 @@ +import { Form, Link, useActionData, useLoaderData, useSearchParams, useTransition } from "@remix-run/react"; + +import type { SignInActionData } from "../actions/sign-in"; +import type { SignInLoaderData } from "../loaders/sign-in"; +import LabeledTextField from "~/features/core/components/labeled-text-field"; +import Alert from "~/features/core/components/alert"; +import Button from "~/features/core/components/button"; + +export default function SignInPage() { + const [searchParams] = useSearchParams(); + const loaderData = useLoaderData(); + const actionData = useActionData(); + const transition = useTransition(); + const isSubmitting = transition.state === "submitting"; + return ( +
+
+

Welcome back!

+

+ Need an account?  + + Create yours for free + +

+
+ +
+ {loaderData?.errors ? ( +
+ +
+ ) : null} + + + + + Forgot your password? + + } + /> + + + +
+ ); +} diff --git a/app/auth/validations.ts b/app/features/auth/validations.ts similarity index 62% rename from app/auth/validations.ts rename to app/features/auth/validations.ts index 5b12cbb..ef0b9b9 100644 --- a/app/auth/validations.ts +++ b/app/features/auth/validations.ts @@ -2,15 +2,16 @@ import { z } from "zod"; export const password = z.string().min(10).max(100); -export const Signup = z.object({ - fullName: z.string(), +export const Register = z.object({ + orgName: z.string().nonempty(), + fullName: z.string().nonempty(), email: z.string().email(), password, }); -export const Login = z.object({ +export const SignIn = z.object({ email: z.string().email(), - password: z.string(), + password, }); export const ForgotPassword = z.object({ @@ -27,3 +28,14 @@ export const ResetPassword = z message: "Passwords don't match", path: ["passwordConfirmation"], // set the path of the error }); + +export const AcceptInvitation = z.object({ + fullName: z.string(), + email: z.string().email(), + password, + token: z.string(), +}); + +export const AcceptAuthedInvitation = z.object({ + token: z.string(), +}); diff --git a/app/features/core/components/alert.tsx b/app/features/core/components/alert.tsx new file mode 100644 index 0000000..03eab6e --- /dev/null +++ b/app/features/core/components/alert.tsx @@ -0,0 +1,51 @@ +import type { FunctionComponent, ReactChild } from "react"; + +type AlertVariant = "error" | "success" | "info" | "warning"; + +type AlertVariantProps = { + backgroundColor: string; + titleTextColor: string; + messageTextColor: string; +}; + +type Props = { + title: ReactChild; + message: ReactChild; + variant: AlertVariant; +}; + +const ALERT_VARIANTS: Record = { + error: { + backgroundColor: "bg-red-50", + titleTextColor: "text-red-800", + messageTextColor: "text-red-700", + }, + success: { + backgroundColor: "bg-green-50", + titleTextColor: "text-green-800", + messageTextColor: "text-green-700", + }, + info: { + backgroundColor: "bg-primary-50", + titleTextColor: "text-primary-800", + messageTextColor: "text-primary-700", + }, + warning: { + backgroundColor: "bg-yellow-50", + titleTextColor: "text-yellow-800", + messageTextColor: "text-yellow-700", + }, +}; + +const Alert: FunctionComponent = ({ title, message, variant }) => { + const variantProperties = ALERT_VARIANTS[variant]; + + return ( +
+

{title}

+
{message}
+
+ ); +}; + +export default Alert; diff --git a/app/features/core/components/button.tsx b/app/features/core/components/button.tsx new file mode 100644 index 0000000..ab33a1f --- /dev/null +++ b/app/features/core/components/button.tsx @@ -0,0 +1,26 @@ +import type { ButtonHTMLAttributes, FunctionComponent } from "react"; +import { useTransition } from "@remix-run/react"; +import clsx from "clsx"; + +type Props = ButtonHTMLAttributes; + +const Button: FunctionComponent = ({ children, ...props }) => { + const transition = useTransition(); + + return ( + + ); +} + +export default Button; diff --git a/app/features/core/components/footer.tsx b/app/features/core/components/footer.tsx new file mode 100644 index 0000000..d96692b --- /dev/null +++ b/app/features/core/components/footer.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; +import { NavLink } from "@remix-run/react"; +import { IoCall, IoKeypad, IoChatbubbles, IoSettings } from "react-icons/io5"; +import clsx from "clsx"; + +export default function Footer() { + return ( +
+ } /> + } /> + } /> + } /> +
+ ); +} + +type FooterLinkProps = { + path: string; + label: string; + icon: ReactNode; +}; + +function FooterLink({ path, label, icon }: FooterLinkProps) { + return ( +
+ + clsx("flex flex-col items-center", { + "text-primary-500": isActive, + "text-[#959595]": !isActive, + }) + } + > + {icon} + {label} + +
+ ); +} diff --git a/app/core/components/inactive-subscription.tsx b/app/features/core/components/inactive-subscription.tsx similarity index 91% rename from app/core/components/inactive-subscription.tsx rename to app/features/core/components/inactive-subscription.tsx index 965e109..66c5e10 100644 --- a/app/core/components/inactive-subscription.tsx +++ b/app/features/core/components/inactive-subscription.tsx @@ -1,8 +1,8 @@ -import { Routes, useRouter } from "blitz"; +import { useNavigate } from "@remix-run/react"; import { IoSettings, IoAlertCircleOutline } from "react-icons/io5"; export default function InactiveSubscription() { - const router = useRouter(); + const navigate = useNavigate(); return (
@@ -22,7 +22,7 @@ export default function InactiveSubscription() { +
); diff --git a/app/core/components/modal.tsx b/app/features/core/components/modal.tsx similarity index 86% rename from app/core/components/modal.tsx rename to app/features/core/components/modal.tsx index 1ee48af..5ab0b75 100644 --- a/app/core/components/modal.tsx +++ b/app/features/core/components/modal.tsx @@ -1,4 +1,4 @@ -import type { FunctionComponent, MutableRefObject } from "react"; +import type { FunctionComponent, MutableRefObject, PropsWithChildren } from "react"; import { Fragment } from "react"; import { Transition, Dialog } from "@headlessui/react"; @@ -8,7 +8,7 @@ type Props = { onClose: () => void; }; -const Modal: FunctionComponent = ({ children, initialFocus, isOpen, onClose }) => { +const Modal: FunctionComponent> = ({ children, initialFocus, isOpen, onClose }) => { return ( = ({ children, initialFocus, isOpen, onClo ); }; -export const ModalTitle: FunctionComponent = ({ children }) => ( +export const ModalTitle: FunctionComponent> = ({ children }) => ( {children} diff --git a/app/core/components/page-title.tsx b/app/features/core/components/page-title.tsx similarity index 100% rename from app/core/components/page-title.tsx rename to app/features/core/components/page-title.tsx diff --git a/app/core/components/phone-init-loader.tsx b/app/features/core/components/phone-init-loader.tsx similarity index 100% rename from app/core/components/phone-init-loader.tsx rename to app/features/core/components/phone-init-loader.tsx diff --git a/app/features/core/components/select.tsx b/app/features/core/components/select.tsx new file mode 100644 index 0000000..b4de76c --- /dev/null +++ b/app/features/core/components/select.tsx @@ -0,0 +1,68 @@ +import { Fragment } from "react"; +import { Listbox, Transition } from "@headlessui/react"; +import { HiCheck as CheckIcon, HiSelector as SelectorIcon } from "react-icons/hi"; +import clsx from "clsx"; + +type Option = { name: string; value: string }; + +type Props = { + options: Option[]; + onChange: (selectedValue: Option) => void; + value: Option; +}; + +export default function Select({ options, onChange, value }: Props) { + return ( + +
+ + {value.name} + + + + + + {options.map((option, index) => ( + + clsx( + "cursor-default select-none relative py-2 pl-10 pr-4", + active ? "text-amber-900 bg-amber-100" : "text-gray-900", + ) + } + value={option} + > + {({ selected, active }) => ( + <> + + {option.name} + + {selected ? ( + + + ) : null} + + )} + + ))} + + +
+
+ ); +} diff --git a/app/core/components/spinner.module.css b/app/features/core/components/spinner.css similarity index 100% rename from app/core/components/spinner.module.css rename to app/features/core/components/spinner.css diff --git a/app/features/core/components/spinner.tsx b/app/features/core/components/spinner.tsx new file mode 100644 index 0000000..509c93d --- /dev/null +++ b/app/features/core/components/spinner.tsx @@ -0,0 +1,15 @@ +import type { LinksFunction } from "@remix-run/node"; + +import styles from "./spinner.css"; + +export const links: LinksFunction = () => [ + { rel: "stylesheet", href: styles }, +]; + +export default function Spinner() { + return ( +
+
+
+ ); +} diff --git a/app/core/helpers/date-formatter.ts b/app/features/core/helpers/date-formatter.ts similarity index 100% rename from app/core/helpers/date-formatter.ts rename to app/features/core/helpers/date-formatter.ts diff --git a/app/features/core/hooks/use-session.ts b/app/features/core/hooks/use-session.ts new file mode 100644 index 0000000..ac3e3c1 --- /dev/null +++ b/app/features/core/hooks/use-session.ts @@ -0,0 +1,13 @@ +import { useMatches } from "@remix-run/react"; + +import type { SessionOrganization, SessionUser } from "~/utils/auth.server"; + +export default function useSession() { + const matches = useMatches(); + const __appRoute = matches.find((match) => match.id === "routes/__app"); + if (!__appRoute) { + throw new Error("useSession hook called outside _app route"); + } + + return __appRoute.data as SessionUser & { currentOrganization: SessionOrganization }; +} diff --git a/app/phone-calls/components/keypad-error-modal.tsx b/app/features/keypad/components/keypad-error-modal.tsx similarity index 83% rename from app/phone-calls/components/keypad-error-modal.tsx rename to app/features/keypad/components/keypad-error-modal.tsx index cb7ae32..00df9ae 100644 --- a/app/phone-calls/components/keypad-error-modal.tsx +++ b/app/features/keypad/components/keypad-error-modal.tsx @@ -1,8 +1,9 @@ import type { FunctionComponent } from "react"; import { useRef } from "react"; -import { Link, Routes, useRouter } from "blitz"; +import { useNavigate } from "@remix-run/react"; +import { Link } from "react-router-dom"; -import Modal, { ModalTitle } from "app/core/components/modal"; +import Modal, { ModalTitle } from "~/features/core/components/modal"; type Props = { isOpen: boolean; @@ -11,7 +12,7 @@ type Props = { const KeypadErrorModal: FunctionComponent = ({ isOpen, closeModal }) => { const openSettingsButtonRef = useRef(null); - const router = useRouter(); + const navigate = useNavigate(); return ( @@ -21,8 +22,8 @@ const KeypadErrorModal: FunctionComponent = ({ isOpen, closeModal }) => {

First things first. Head over to your{" "} - - phone settings + + phone settings {" "} to set up your phone number.

@@ -34,7 +35,7 @@ const KeypadErrorModal: FunctionComponent = ({ isOpen, closeModal }) => { ref={openSettingsButtonRef} type="button" className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-primary-500 font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 md:mt-0 md:w-auto" - onClick={() => router.push(Routes.PhoneSettings())} + onClick={() => navigate("/settings/phone")} > Take me there diff --git a/app/phone-calls/components/keypad.tsx b/app/features/keypad/components/keypad.tsx similarity index 77% rename from app/phone-calls/components/keypad.tsx rename to app/features/keypad/components/keypad.tsx index cba7afe..6d97dc9 100644 --- a/app/phone-calls/components/keypad.tsx +++ b/app/features/keypad/components/keypad.tsx @@ -1,4 +1,4 @@ -import type { FunctionComponent } from "react"; +import type { FunctionComponent, PropsWithChildren } from "react"; import type { PressHookProps } from "@react-aria/interactions"; import { usePress } from "@react-aria/interactions"; @@ -7,7 +7,7 @@ type Props = { onZeroPressProps: PressHookProps; }; -const Keypad: FunctionComponent = ({ children, onDigitPressProps, onZeroPressProps }) => { +const Keypad: FunctionComponent> = ({ children, onDigitPressProps, onZeroPressProps }) => { return (
@@ -53,18 +53,18 @@ const Keypad: FunctionComponent = ({ children, onDigitPressProps, onZeroP export default Keypad; -const Row: FunctionComponent = ({ children }) => ( +const Row: FunctionComponent> = ({ children }) => (
{children}
); -const DigitLetters: FunctionComponent = ({ children }) =>
{children}
; +const DigitLetters: FunctionComponent> = ({ children }) =>
{children}
; type DigitProps = { digit: string; onPressProps: Props["onDigitPressProps"]; }; -const Digit: FunctionComponent = ({ children, digit, onPressProps }) => { +const Digit: FunctionComponent> = ({ children, digit, onPressProps }) => { const { pressProps } = usePress(onPressProps(digit)); return ( @@ -79,7 +79,7 @@ type ZeroDigitProps = { onPressProps: Props["onZeroPressProps"]; }; -const ZeroDigit: FunctionComponent = ({ onPressProps }) => { +const ZeroDigit: FunctionComponent> = ({ onPressProps }) => { const { pressProps } = usePress(onPressProps); return ( diff --git a/app/phone-calls/hooks/use-key-press.ts b/app/features/keypad/hooks/use-key-press.ts similarity index 100% rename from app/phone-calls/hooks/use-key-press.ts rename to app/features/keypad/hooks/use-key-press.ts diff --git a/app/messages/components/conversation.tsx b/app/features/messages/components/conversation.tsx similarity index 85% rename from app/messages/components/conversation.tsx rename to app/features/messages/components/conversation.tsx index 5c2a034..3a7298f 100644 --- a/app/messages/components/conversation.tsx +++ b/app/features/messages/components/conversation.tsx @@ -1,15 +1,16 @@ import { Suspense, useEffect, useMemo, useRef } from "react"; -import { useParam } from "blitz"; +import { useLoaderData, useParams } from "@remix-run/react"; import clsx from "clsx"; +import { Direction } from "@prisma/client"; -import { Direction } from "db"; -import useConversation from "../hooks/use-conversation"; import NewMessageArea from "./new-message-area"; -import { formatDate, formatTime } from "app/core/helpers/date-formatter"; +import { formatDate, formatTime } from "~/features/core/helpers/date-formatter"; +import { type ConversationLoaderData } from "~/routes/__app/messages.$recipient"; export default function Conversation() { - const recipient = decodeURIComponent(useParam("recipient", "string") ?? ""); - const conversation = useConversation(recipient)[0]; + const params = useParams<{ recipient: string }>(); + const recipient = decodeURIComponent(params.recipient ?? ""); + const { conversation } = useLoaderData(); const messages = useMemo(() => conversation?.messages ?? [], [conversation?.messages]); const messagesListRef = useRef(null); diff --git a/app/features/messages/components/conversations-list.tsx b/app/features/messages/components/conversations-list.tsx new file mode 100644 index 0000000..9a6d05b --- /dev/null +++ b/app/features/messages/components/conversations-list.tsx @@ -0,0 +1,41 @@ +import { Link, useLoaderData } from "@remix-run/react"; +import { IoChevronForward } from "react-icons/io5"; + +import { formatRelativeDate } from "~/features/core/helpers/date-formatter"; +import PhoneInitLoader from "~/features/core/components/phone-init-loader"; +import EmptyMessages from "./empty-messages"; +import type { MessagesLoaderData } from "~/routes/__app/messages"; + +export default function ConversationsList() { + const { conversations } = useLoaderData(); + + if (!conversations) { + // we're still importing messages from twilio + return ; + } + + if (Object.keys(conversations).length === 0) { + return ; + } + + return ( +
    + {Object.values(conversations).map(({ recipient, formattedPhoneNumber, lastMessage }) => { + return ( +
  • + +
    + {formattedPhoneNumber ?? recipient} +
    + {formatRelativeDate(lastMessage.sentAt)} + +
    +
    +
    {lastMessage.content}
    + +
  • + ); + })} +
+ ); +} diff --git a/app/messages/components/empty-messages.tsx b/app/features/messages/components/empty-messages.tsx similarity index 77% rename from app/messages/components/empty-messages.tsx rename to app/features/messages/components/empty-messages.tsx index 9bcb89d..41be36e 100644 --- a/app/messages/components/empty-messages.tsx +++ b/app/features/messages/components/empty-messages.tsx @@ -1,11 +1,12 @@ import { IoCreateOutline, IoMailOutline } from "react-icons/io5"; -import { useAtom } from "jotai"; +// import { useAtom } from "jotai"; -import { bottomSheetOpenAtom } from "../pages/messages"; +// import { bottomSheetOpenAtom } from "../pages/messages"; export default function EmptyMessages() { - const setIsBottomSheetOpen = useAtom(bottomSheetOpenAtom)[1]; - const openNewMessageArea = () => setIsBottomSheetOpen(true); + // const setIsBottomSheetOpen = useAtom(bottomSheetOpenAtom)[1]; + // const openNewMessageArea = () => setIsBottomSheetOpen(true); + const openNewMessageArea = () => void 0; return (
diff --git a/app/messages/components/new-message-area.tsx b/app/features/messages/components/new-message-area.tsx similarity index 60% rename from app/messages/components/new-message-area.tsx rename to app/features/messages/components/new-message-area.tsx index e3cd779..0cc3f88 100644 --- a/app/messages/components/new-message-area.tsx +++ b/app/features/messages/components/new-message-area.tsx @@ -1,17 +1,7 @@ import type { FunctionComponent } from "react"; -import { useMutation, useQuery } from "blitz"; import { IoSend } from "react-icons/io5"; -import { useForm } from "react-hook-form"; - -import sendMessage from "../mutations/send-message"; -import { Direction, Message, MessageStatus } from "../../../db"; -import getConversationsQuery from "../queries/get-conversations"; -import useCurrentUser from "../../core/hooks/use-current-user"; -import useCurrentPhoneNumber from "../../core/hooks/use-current-phone-number"; - -type Form = { - content: string; -}; +import { type Message, Direction, MessageStatus } from "@prisma/client"; +import useSession from "~/features/core/hooks/use-session"; type Props = { recipient: string; @@ -19,29 +9,11 @@ type Props = { }; const NewMessageArea: FunctionComponent = ({ recipient, onSend }) => { - const { organization, hasOngoingSubscription } = useCurrentUser(); - const phoneNumber = useCurrentPhoneNumber(); - const sendMessageMutation = useMutation(sendMessage)[0]; - const { setQueryData: setConversationsQueryData, refetch: refetchConversations } = useQuery( - getConversationsQuery, - {}, - )[1]; - const { - register, - handleSubmit, - setValue, - formState: { isSubmitting }, - } = useForm
(); - const onSubmit = handleSubmit(async ({ content }) => { - if (!recipient) { - return; - } - - if (isSubmitting) { - return; - } - - const id = uuidv4(); + const { currentOrganization, /*hasOngoingSubscription*/ } = useSession(); + // const phoneNumber = useCurrentPhoneNumber(); + // const sendMessageMutation = useMutation(sendMessage)[0]; + const onSubmit = async () => { + /*const id = uuidv4(); const message: Message = { id, organizationId: organization!.id, @@ -54,9 +26,9 @@ const NewMessageArea: FunctionComponent = ({ recipient, onSend }) => { direction: Direction.Outbound, status: MessageStatus.Queued, sentAt: new Date(), - }; + };*/ - await setConversationsQueryData( + /*await setConversationsQueryData( (conversations) => { const nextConversations = { ...conversations }; if (!nextConversations[recipient]) { @@ -78,12 +50,10 @@ const NewMessageArea: FunctionComponent = ({ recipient, onSend }) => { ); }, { refetch: false }, - ); - setValue("content", ""); - onSend?.(); - await sendMessageMutation({ to: recipient, content }); - await refetchConversations({ cancelRefetch: true }); - }); + );*/ + // setValue("content", ""); + // onSend?.(); + }; return ( = ({ recipient, onSend }) => { className="absolute bottom-0 w-screen backdrop-filter backdrop-blur-xl bg-white bg-opacity-75 border-t flex flex-row h-16 p-2 pr-0" >