remixed v0

This commit is contained in:
m5r committed 2022-05-14 12:22:06 +02:00
1 parent 9275d4499b
commit 98b89ae0f7
338 files changed
+22549 -44628

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
node_modules
.env
/.idea
/cypress/videos
/cypress/screenshots
/coverage
# build artifacts
/.cache
/public/build
/build
server.js
-11
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
DATABASE_URL=postgresql://pgremixtape:pgpassword@localhost:5432/remixtape_e2e
+25
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
module.exports = {
extends: ["blitz"],
};
+51 -58
View File
@@ -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"
+11 -50
View File
@@ -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
-1
View File
@@ -2,4 +2,3 @@
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
npx pretty-quick --staged
-6
View File
@@ -1,6 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx tsc
npm run lint
#npm run test
-1
View File
@@ -1,2 +1 @@
save-exact=true
legacy-peer-deps=true
+5 -7
View File
@@ -1,8 +1,6 @@
.gitkeep
.env*
*.ico
*.lock
db/migrations
.next
.blitz
mailers/**/*.html
.env
.cache
build
package-lock.json
app/tailwind.css
-12
View File
@@ -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": []
}
-7
View File
@@ -1,7 +0,0 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}
+57
View File
@@ -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"]
-71
View File
@@ -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);
}
-18
View File
@@ -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;
-29
View File
@@ -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;
-18
View File
@@ -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;
-104
View File
@@ -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<S extends z.ZodType<any, any>>
extends Omit<PropsWithoutRef<JSX.IntrinsicElements["form"]>, "onSubmit"> {
/** All your form fields */
children?: ReactNode;
texts: {
title: string;
subtitle: ReactNode;
submit: string;
};
schema?: S;
onSubmit: (values: z.infer<S>) => Promise<void | OnSubmitResult>;
initialValues?: UseFormProps<z.infer<S>>["defaultValues"];
}
interface OnSubmitResult {
FORM_ERROR?: string;
[prop: string]: any;
}
export const FORM_ERROR = "FORM_ERROR";
export function AuthForm<S extends z.ZodType<any, any>>({
children,
texts,
schema,
initialValues,
onSubmit,
...props
}: FormProps<S>) {
const ctx = useForm<z.infer<S>>({
mode: "onBlur",
resolver: schema ? zodResolver(schema) : undefined,
defaultValues: initialValues,
});
const [formError, setFormError] = useState<string | null>(null);
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="flex flex-col sm:mx-auto sm:w-full sm:max-w-sm">
<Logo className="mx-auto h-12 w-12" />
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">{texts.title}</h2>
<p className="mt-2 text-center text-sm leading-5 text-gray-600">{texts.subtitle}</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-sm">
<FormProvider {...ctx}>
<form
onSubmit={ctx.handleSubmit(async (values) => {
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 ? (
<div role="alert" className="mb-8 sm:mx-auto sm:w-full sm:max-w-sm">
<Alert title="Oops, there was an issue" message={formError} variant="error" />
</div>
) : null}
{children}
{texts.submit ? (
<button
type="submit"
disabled={ctx.formState.isSubmitting}
className={clsx(
"w-full flex justify-center py-2 px-4 border border-transparent text-base font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
{
"bg-primary-400 cursor-not-allowed": ctx.formState.isSubmitting,
"bg-primary-600 hover:bg-primary-700": !ctx.formState.isSubmitting,
},
)}
>
{texts.submit}
</button>
) : null}
</form>
</FormProvider>
</div>
</div>
);
}
export default AuthForm;
@@ -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<JSX.IntrinsicElements["input"]> {
/** 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<HTMLInputElement, LabeledTextFieldProps>(
({ 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 (
<div className="mb-6">
<label
htmlFor="name"
className={clsx("text-sm font-medium leading-5 text-gray-700", {
block: !showForgotPasswordLabel,
"flex justify-between": showForgotPasswordLabel,
})}
>
{label}
{showForgotPasswordLabel ? (
<div>
<Link href={Routes.ForgotPasswordPage()}>
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
Forgot your password?
</a>
</Link>
</div>
) : null}
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="name"
type="text"
tabIndex={1}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
disabled={isSubmitting}
{...register(name)}
{...props}
/>
</div>
{error ? (
<div role="alert" className="text-red-600">
{error}
</div>
) : null}
</div>
);
},
);
export default LabeledTextField;
-46
View File
@@ -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();
}
-46
View File
@@ -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;
});
-5
View File
@@ -1,5 +0,0 @@
import type { Ctx } from "blitz";
export default async function logout(_ = null, ctx: Ctx) {
return await ctx.session.$revoke();
}
-48
View File
@@ -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;
});
-46
View File
@@ -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;
});
-48
View File
@@ -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 (
<Form
texts={{
title: isSuccess ? "Request submitted" : "Forgot your password?",
subtitle: "",
submit: isSuccess ? "" : "Send reset password link",
}}
schema={ForgotPassword}
initialValues={{ email: "" }}
onSubmit={async (values) => {
try {
reset();
await forgotPasswordMutation(values);
} catch (error: any) {
return {
[FORM_ERROR]: "Sorry, we had an unexpected error. Please try again.",
};
}
}}
>
{isSuccess ? (
<p className="text-center">
If your email is in our system, you will receive instructions to reset your password shortly.
</p>
) : (
<LabeledTextField name="email" label="Email" />
)}
</Form>
);
};
ForgotPasswordPage.redirectAuthenticatedTo = Routes.Messages();
ForgotPasswordPage.getLayout = (page) => <BaseLayout title="Reset password">{page}</BaseLayout>;
export default ForgotPasswordPage;
-74
View File
@@ -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 (
<Form
texts={{
title: isSuccess ? "Password reset successfully" : "Set a new password",
subtitle: "",
submit: "Reset password",
}}
schema={ResetPassword}
initialValues={{
password: "",
passwordConfirmation: "",
token: query.token as string,
}}
onSubmit={async (values) => {
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 ? (
<p>
Go to the <Link href={Routes.LandingPage()}>homepage</Link>
</p>
) : (
<>
<LabeledTextField name="password" label="New Password" type="password" />
<LabeledTextField name="passwordConfirmation" label="Confirm New Password" type="password" />
</>
)}
</Form>
);
};
ResetPasswordPage.redirectAuthenticatedTo = Routes.Messages();
ResetPasswordPage.getLayout = (page) => <BaseLayout title="Reset password">{page}</BaseLayout>;
export const getServerSideProps: GetServerSideProps = async (context) => {
if (!context.query.token) {
return {
redirect: {
destination: Routes.ForgotPasswordPage().pathname,
permanent: false,
},
};
}
return { props: {} };
};
export default ResetPasswordPage;
-60
View File
@@ -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 (
<Form
texts={{
title: "Welcome back!",
subtitle: (
<>
Need an account?&nbsp;
<Link href={Routes.SignUp()}>
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
Create yours for free
</a>
</Link>
</>
),
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(),
};
}
}
}}
>
<LabeledTextField name="email" label="Email" type="email" />
<LabeledTextField name="password" label="Password" type="password" showForgotPasswordLabel />
</Form>
);
};
SignIn.redirectAuthenticatedTo = Routes.Messages();
SignIn.getLayout = (page) => <BaseLayout title="Sign in">{page}</BaseLayout>;
export default SignIn;
-60
View File
@@ -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 (
<Form
texts={{
title: "Create your account",
subtitle: (
<Link href={Routes.SignIn()}>
<a className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline transition ease-in-out duration-150">
Already have an account?
</a>
</Link>
),
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() };
}
}
}}
>
<LabeledTextField name="fullName" label="Full name" type="text" />
<LabeledTextField name="email" label="Email" type="email" />
<LabeledTextField name="password" label="Password" type="password" />
</Form>
);
};
SignUp.redirectAuthenticatedTo = ({ session }) => {
if (session.shouldShowWelcomeMessage) {
return Routes.Welcome();
}
return Routes.Messages();
};
SignUp.getLayout = (page) => <BaseLayout title="Sign up">{page}</BaseLayout>;
export default SignUp;
-28
View File
@@ -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 (
<div>
<p>Thanks for joining Shellphone</p>
<p>Let us know if you need our help</p>
<p>Make sure to set up your phone number</p>
<button onClick={() => router.push(Routes.Messages())}>Open my phone</button>
</div>
);
};
export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
const session = await getSession(req, res);
await session.$setPublicData({ shouldShowWelcomeMessage: undefined });
return {
props: {},
};
};
export default Welcome;
-10
View File
@@ -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();
}
-34
View File
@@ -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();
}
-12
View File
@@ -1,12 +0,0 @@
import Image from "next/image";
export default function Avatar({ name, picture }: any) {
return (
<div className="flex items-center">
<div className="w-12 h-12 relative mr-4">
<Image src={picture.url} layout="fill" className="rounded-full" alt={name} />
</div>
<div className="text-xl font-bold">{name}</div>
</div>
);
}
-29
View File
@@ -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
<Image
data={{
...responsiveImage,
alt: `Cover Image for ${title}`,
}}
className={clsx("shadow-small", {
"hover:shadow-medium transition-shadow duration-200": slug,
})}
/>
);
return (
<div className="sm:mx-0">
{slug ? (
<Link href={`/posts/${slug}`}>
<a aria-label={title}>{image}</a>
</Link>
) : (
image
)}
</div>
);
}
-6
View File
@@ -1,6 +0,0 @@
import { formatDate } from "../../core/helpers/date-formatter";
export default function DateComponent({ dateString }: any) {
const date = new Date(dateString);
return <time dateTime={dateString}>{formatDate(date)}</time>;
}
-77
View File
@@ -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 (
<aside>
<div className="relative max-w-6xl mx-auto px-4 sm:px-6">
<div className="pb-12 md:pb-20">
<div className="max-w-3xl mx-auto">
<h4 className="h4 font-mackinac mb-8">Related articles</h4>
{/* Articles container */}
<div className="grid gap-4 sm:gap-6 sm:grid-cols-2">
{posts.map((post) => (
<article key={post.slug} className="relative group p-6 text-white">
<figure>
<img
className="absolute inset-0 w-full h-full object-cover opacity-50 group-hover:opacity-75 transition duration-700 ease-out"
src={post.coverImage.responsiveImage.src}
width="372"
height="182"
alt="Related post"
/>
<div
className="absolute inset-0 bg-primary-500 opacity-75 group-hover:opacity-50 transition duration-700 ease-out"
aria-hidden="true"
/>
</figure>
<div className="relative flex flex-col h-full">
<header className="flex-grow">
<Link href={Routes.PostPage({ slug: post.slug })}>
<a className="hover:underline">
<h3 className="text-lg font-mackinac font-bold tracking-tight mb-2">
{post.title}
</h3>
</a>
</Link>
<div className="text-sm opacity-80">{formatDate(new Date(post.date))}</div>
</header>
<footer>
{/* Author meta */}
<div className="flex items-center text-sm mt-5">
<a href="#0">
<img
className="rounded-full flex-shrink-0 mr-3"
src={post.author.picture.url}
width="32"
height="32"
alt={post.author.name}
/>
</a>
<div>
<span className="opacity-75">By </span>
<span className="font-medium hover:underline">
{post.author.name}
</span>
</div>
</div>
</footer>
</div>
</article>
))}
</div>
</div>
</div>
</div>
</aside>
);
}
-16
View File
@@ -1,16 +0,0 @@
import styles from "../styles/post-body.module.css";
type Props = {
content: string;
};
export default function PostBody({ content }: Props) {
return (
<div className="max-w-2xl mx-auto">
<div
className={`prose prose-lg prose-blue ${styles.markdown}`}
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
);
}
-25
View File
@@ -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 (
<div>
<div className="mb-5">
<CoverImage slug={slug} title={title} responsiveImage={coverImage.responsiveImage} />
</div>
<h3 className="text-3xl mb-3 leading-snug">
<Link href={`/posts/${slug}`}>
<a className="hover:underline">{title}</a>
</Link>
</h3>
<div className="text-lg mb-4">
<Date dateString={date} />
</div>
<p className="text-lg leading-relaxed mb-4">{excerpt}</p>
<Avatar name={author.name} picture={author.picture} />
</div>
);
}
@@ -1,3 +0,0 @@
export default function SectionSeparator() {
return <hr className="border-accent-2 mt-28 mb-24" />;
}
-166
View File
@@ -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<Props> = ({ post, morePosts, preview }) => {
const router = useRouter();
if (!router.isFallback && !post?.slug) {
return <ErrorPage statusCode={404} />;
}
console.log("post", post);
return (
<div className="flex flex-col min-h-screen overflow-hidden">
<Header />
<main className="flex-grow">
<section className="relative">
{/* Background image */}
<div className="absolute inset-0 h-128 pt-16 box-content">
<img
className="absolute inset-0 w-full h-full object-cover opacity-25"
src={post.coverImage.responsiveImage.src}
width={post.coverImage.responsiveImage.width}
height={post.coverImage.responsiveImage.height}
alt={post.coverImage.responsiveImage.alt ?? `${post.title} cover image`}
/>
<div
className="absolute inset-0 bg-gradient-to-t from-white dark:from-gray-900"
aria-hidden="true"
/>
</div>
<div className="relative max-w-6xl mx-auto px-4 sm:px-6">
<div className="pt-32 pb-12 md:pt-40 md:pb-20">
<div className="max-w-3xl mx-auto">
<article>
{/* Article header */}
<header className="mb-8">
{/* Title and excerpt */}
<div className="text-center md:text-left">
<h1 className="h1 font-mackinac mb-4">{post.title}</h1>
<p className="text-xl text-gray-600 dark:text-gray-400">{post.excerpt}</p>
</div>
{/* Article meta */}
<div className="md:flex md:items-center md:justify-between mt-5">
{/* Author meta */}
<div className="flex items-center justify-center">
<img
className="rounded-full flex-shrink-0 mr-3"
src={post.author.picture.url}
width="32"
height="32"
alt="Author 04"
/>
<div>
<span className="text-gray-600 dark:text-gray-400">By </span>
<a
className="font-medium text-gray-800 dark:text-gray-300 hover:underline"
href="#0"
>
{post.author.name}
</a>
<span className="text-gray-600 dark:text-gray-400">
{" "}
· {formatDate(new Date(post.date))}
</span>
</div>
</div>
</div>
</header>
<hr className="w-5 h-px pt-px bg-gray-400 dark:bg-gray-500 border-0 mb-8" />
{/* Article content */}
<div className="text-lg text-gray-600 dark:text-gray-400">
<PostBody content={post.content} />
</div>
</article>
<SectionSeparator />
{morePosts.length > 0 && <MoreStories posts={morePosts} />}
</div>
</div>
</div>
</section>
</main>
</div>
);
/*return (
<Layout preview={preview}>
<Container>
<Header />
{router.isFallback ? (
<PostTitle>Loading…</PostTitle>
) : (
<>
<article>
<Head>
<title>
{post.title} | Next.js Blog Example with {CMS_NAME}
</title>
<meta property="og:image" content={post.ogImage.url} />
</Head>
<PostHeader
title={post.title}
coverImage={post.coverImage}
date={post.date}
author={post.author}
/>
<PostBody content={post.content} />
</article>
<SectionSeparator />
{morePosts.length > 0 && <MoreStories posts={morePosts} />}
</>
)}
</Container>
</Layout>
);*/
};
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,
};
};
-12
View File
@@ -1,12 +0,0 @@
import type { BlitzPage } from "blitz";
import Layout from "../../public-area/components/layout";
const Blog: BlitzPage = () => {
return <article className="m-auto">Coming soon.</article>;
};
Blog.getLayout = (page) => <Layout title="Blog">{page}</Layout>;
Blog.suppressFirstRenderFlicker = true;
export default Blog;
-18
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
export default {};
+40
View File
@@ -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,
},
};
-5
View File
@@ -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"));
-5
View File
@@ -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"));
-5
View File
@@ -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"));
-91
View File
@@ -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<AlertVariant, AlertVariantProps> = {
error: {
backgroundColor: "bg-red-50",
icon: (
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
),
titleTextColor: "text-red-800",
messageTextColor: "text-red-700",
},
success: {
backgroundColor: "bg-green-50",
icon: (
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
),
titleTextColor: "text-green-800",
messageTextColor: "text-green-700",
},
info: {
backgroundColor: "bg-primary-50",
icon: (
<svg className="h-5 w-5 text-primary-400" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
),
titleTextColor: "text-primary-800",
messageTextColor: "text-primary-700",
},
warning: {
backgroundColor: "bg-yellow-50",
icon: (
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
),
titleTextColor: "text-yellow-800",
messageTextColor: "text-yellow-700",
},
};
export default function Alert({ title, message, variant }: Props) {
const variantProperties = ALERT_VARIANTS[variant];
return (
<div className={`rounded-md p-4 ${variantProperties.backgroundColor}`}>
<div className="flex">
<div className="flex-shrink-0">{variantProperties.icon}</div>
<div className="ml-3">
<h3 className={`text-sm leading-5 font-medium ${variantProperties.titleTextColor}`}>{title}</h3>
<div className={`mt-2 text-sm leading-5 ${variantProperties.messageTextColor}`}>{message}</div>
</div>
</div>
</div>
);
}
-63
View File
@@ -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<ExtraProps> {
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 <DefaultErrorComponent statusCode={statusCode} />;
}
}
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;
@@ -1,56 +0,0 @@
import { forwardRef, PropsWithoutRef } from "react";
import { useFormContext } from "react-hook-form";
export interface LabeledTextFieldProps extends PropsWithoutRef<JSX.IntrinsicElements["input"]> {
/** Field name. */
name: string;
/** Field label. */
label: string;
/** Field type. Doesn't include radio buttons and checkboxes */
type?: "text" | "password" | "email" | "number";
outerProps?: PropsWithoutRef<JSX.IntrinsicElements["div"]>;
}
export const LabeledTextField = forwardRef<HTMLInputElement, LabeledTextFieldProps>(
({ 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 (
<div {...outerProps}>
<label>
{label}
<input disabled={isSubmitting} {...register(name)} {...props} />
</label>
{error && (
<div role="alert" style={{ color: "red" }}>
{error}
</div>
)}
<style jsx>{`
label {
display: flex;
flex-direction: column;
align-items: start;
font-size: 1rem;
}
input {
font-size: 1rem;
padding: 0.25rem 0.5rem;
border-radius: 3px;
border: 1px solid purple;
appearance: none;
margin-top: 0.5rem;
}
`}</style>
</div>
);
},
);
export default LabeledTextField;
-11
View File
@@ -1,11 +0,0 @@
import clsx from "clsx";
import styles from "./spinner.module.css";
export default function Spinner() {
return (
<div className="h-full flex">
<div className={clsx(styles.ring, "m-auto text-primary-400")} />
</div>
);
}
@@ -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;
}
-21
View File
@@ -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,
};
}
-71
View File
@@ -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<PushSubscription | null>(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;
}
-22
View File
@@ -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]);
};
-114
View File
@@ -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<Promise<void>>();
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,
};
}
-22
View File
@@ -1,22 +0,0 @@
import { ReactNode } from "react";
import { Head } from "blitz";
type LayoutProps = {
title?: string;
children: ReactNode;
};
const BaseLayout = ({ title, children }: LayoutProps) => {
return (
<>
<Head>
<title>{title ? `${title} | Shellphone` : "Shellphone"}</title>
<link rel="icon" href="/favicon.ico" />
</Head>
{children}
</>
);
};
export default BaseLayout;
-45
View File
@@ -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 (
<footer
className="grid grid-cols-4 bg-[#F7F7F7] border-t border-gray-400 border-opacity-25 py-3 z-10"
style={{ flex: "0 0 50px" }}
>
<NavLink label="Calls" path="/calls" icon={<IoCall className="w-6 h-6" />} />
<NavLink label="Keypad" path="/keypad" icon={<IoKeypad className="w-6 h-6" />} />
<NavLink label="Messages" path="/messages" icon={<IoChatbubbles className="w-6 h-6" />} />
<NavLink label="Settings" path="/settings" icon={<IoSettings className="w-6 h-6" />} />
</footer>
);
}
type NavLinkProps = {
path: string;
label: string;
icon: ReactNode;
};
function NavLink({ path, label, icon }: NavLinkProps) {
const router = useRouter();
const isActiveRoute = router.pathname.startsWith(path);
return (
<div className="flex flex-col items-center justify-around h-full">
<Link href={path} prefetch={false}>
<a
className={clsx("flex flex-col items-center", {
"text-primary-500": isActiveRoute,
"text-[#959595]": !isActiveRoute,
})}
>
{icon}
<span className="text-xs">{label}</span>
</a>
</Link>
</div>
);
}
-119
View File
@@ -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<Props> = ({ children, title, pageTitle = title, hideFooter = false }) => {
return (
<>
{pageTitle ? (
<Head>
<title>{pageTitle} | Shellphone</title>
</Head>
) : null}
<Suspense fallback={<Loader />}>
<div className="h-full w-full overflow-hidden fixed bg-gray-100">
<div className="flex flex-col w-full h-full">
<div className="flex flex-col flex-1 w-full overflow-y-auto">
<main className="flex flex-col flex-1 my-0 h-full">
<ErrorBoundary>{children}</ErrorBoundary>
</main>
</div>
{!hideFooter ? <Footer /> : null}
</div>
</div>
</Suspense>
</>
);
};
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<WithRouterProps, ErrorBoundaryState> {
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 (
<>
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
Oops, something went wrong.
</h2>
<p className="mt-2 text-center text-lg leading-5 text-gray-600">
Would you like to{" "}
<button
className="inline-flex space-x-2 items-center text-left"
onClick={this.props.router.reload}
>
<span className="transition-colors duration-150 border-b border-primary-200 hover:border-primary-500">
reload the page
</span>
</button>{" "}
?
</p>
</>
);
}
return this.props.children;
}
},
);
export default AppLayout;
-749
View File
@@ -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(ba Line truncated
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 };
@@ -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;
}
-26
View File
@@ -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 (
<div className="min-h-screen min-w-screen relative">
<div className="relative z-10 min-h-screen flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="flex flex-col sm:mx-auto sm:w-full sm:max-w-sm">
<Logo className="mx-auto h-12 w-12" />
<span className="mt-2 text-center text-lg leading-9 text-gray-900">Loading up Shellphone...</span>
</div>
</div>
<canvas id={styles.gradientCanvas} className="absolute top-0 z-0" />
</div>
);
}
@@ -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`;
}
}
},
);
-153
View File
@@ -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 */
}
-4
View File
@@ -1,4 +0,0 @@
export type ApiError = {
statusCode: number;
errorMessage: string;
};
-35
View File
@@ -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<T extends Record<any, any>>(
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<T extends Record<any, any>>(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;
}
+3
View File
@@ -0,0 +1,3 @@
import registerPurgeExpiredSession from "./purge-expired-sessions";
export default [registerPurgeExpiredSession];
+14
View File
@@ -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 * * *",
);
+4
View File
@@ -0,0 +1,4 @@
import { hydrate } from "react-dom";
import { RemixBrowser } from "@remix-run/react";
hydrate(<RemixBrowser />, document);
+19
View File
@@ -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(<RemixServer context={remixContext} url={request.url} />);
responseHeaders.set("Content-Type", "text/html");
return new Response("<!DOCTYPE html>" + markup, {
status: responseStatusCode,
headers: responseHeaders,
});
}
@@ -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<typeof ForgotPassword>; 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<ForgotPasswordFailureActionData>({ 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<ForgotPasswordSuccessfulActionData>({ 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,
});
}
+56
View File
@@ -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<typeof Register>;
};
const action: ActionFunction = async ({ request }) => {
const formData = Object.fromEntries(await request.formData());
const validation = validate(Register, formData);
if (validation.errors) {
return json<RegisterActionData>({ 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<RegisterActionData>({
errors: { general: "An account with this email address already exists" },
});
}
}
return json<RegisterActionData>({
errors: { general: `An unexpected error happened${error.code ? `\nCode: ${error.code}` : ""}` },
});
}
return authenticate({ email, password, request, failureRedirect: "/register" });
};
export default action;
@@ -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<typeof ResetPassword> };
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<ResetPasswordActionData>({ 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;
+22
View File
@@ -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<typeof SignIn> };
const action: ActionFunction = async ({ request }) => {
const formData = Object.fromEntries(await request.clone().formData());
const validation = validate(SignIn, formData);
if (validation.errors) {
return json<SignInActionData>({ 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;
@@ -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;
+25
View File
@@ -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<RegisterLoaderData>(
{ errors: { general: errorMessage } },
{
headers: { "Set-Cookie": await commitSession(session) },
},
);
}
await requireLoggedOut(request);
return null;
};
export default loader;
@@ -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;
+25
View File
@@ -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<SignInLoaderData>(
{ errors: { general: errorMessage } },
{
headers: { "Set-Cookie": await commitSession(session) },
},
);
}
await requireLoggedOut(request);
return null;
};
export default loader;
@@ -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<ForgotPasswordActionData>();
const transition = useTransition();
const isSubmitting = transition.state === "submitting";
return (
<section>
<header>
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
Forgot your password?
</h2>
</header>
<Form method="post" className="mt-8 mx-auto w-full max-w-sm">
{actionData?.submitted ? (
<p className="text-center">
If your email is in our system, you will receive instructions to reset your password shortly.
</p>
) : (
<>
<LabeledTextField
name="email"
type="email"
label="Email"
disabled={isSubmitting}
error={actionData?.errors?.email}
tabIndex={1}
/>
<Button
type="submit"
disabled={transition.state === "submitting"}
tabIndex={2}
className="w-full flex justify-center py-2 px-4 text-base font-medium"
>
Send reset password link
</Button>
</>
)}
</Form>
</section>
);
}
+83
View File
@@ -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<RegisterLoaderData>();
const actionData = useActionData<RegisterActionData>();
const transition = useTransition();
const isSubmitting = transition.state === "submitting";
const topErrorMessage = loaderData?.errors?.general || actionData?.errors?.general;
return (
<section>
<header>
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">
Create your account
</h2>
<p className="mt-2 text-center text-sm leading-5 text-gray-600">
<Link
to="/sign-in"
prefetch="intent"
className="font-medium text-primary-600 hover:text-primary-500 focus:underline transition ease-in-out duration-150"
>
Already have an account?
</Link>
</p>
</header>
<Form method="post" className="mt-8 mx-auto w-full max-w-sm">
{topErrorMessage ? (
<div role="alert" className="mb-8 sm:mx-auto sm:w-full sm:max-w-sm whitespace-pre">
<Alert title="Oops, there was an issue" message={topErrorMessage!} variant="error" />
</div>
) : null}
<LabeledTextField
name="orgName"
type="text"
label="Organization name"
disabled={isSubmitting}
error={actionData?.errors?.orgName}
tabIndex={1}
/>
<LabeledTextField
name="fullName"
type="text"
label="Full name"
disabled={isSubmitting}
error={actionData?.errors?.fullName}
tabIndex={2}
/>
<LabeledTextField
name="email"
type="email"
label="Email"
disabled={isSubmitting}
error={actionData?.errors?.email}
tabIndex={3}
/>
<LabeledTextField
name="password"
type="password"
label="Password"
disabled={isSubmitting}
error={actionData?.errors?.password}
tabIndex={4}
/>
<Button
type="submit"
disabled={transition.state === "submitting"}
tabIndex={5}
>
Register
</Button>
</Form>
</section>
);
}
@@ -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<ResetPasswordActionData>();
const transition = useTransition();
const isSubmitting = transition.state === "submitting";
return (
<section>
<header>
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">Set a new password</h2>
</header>
<Form method="post" action={`./?${searchParams}`} className="mt-8 mx-auto w-full max-w-sm">
<LabeledTextField
name="password"
label="New Password"
type="password"
disabled={isSubmitting}
error={actionData?.errors?.password}
tabIndex={1}
/>
<LabeledTextField
name="passwordConfirmation"
label="Confirm New Password"
type="password"
disabled={isSubmitting}
error={actionData?.errors?.passwordConfirmation}
tabIndex={2}
/>
<button
type="submit"
disabled={transition.state === "submitting"}
className={clsx(
"w-full flex justify-center py-2 px-4 border border-transparent text-base font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
{
"bg-primary-400 cursor-not-allowed": isSubmitting,
"bg-primary-600 hover:bg-primary-700": !isSubmitting,
},
)}
tabIndex={3}
>
Reset password
</button>
</Form>
</section>
);
}
+75
View File
@@ -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<SignInLoaderData>();
const actionData = useActionData<SignInActionData>();
const transition = useTransition();
const isSubmitting = transition.state === "submitting";
return (
<section>
<header>
<h2 className="mt-6 text-center text-3xl leading-9 font-extrabold text-gray-900">Welcome back!</h2>
<p className="mt-2 text-center text-sm leading-5 text-gray-600">
Need an account?&nbsp;
<Link
to="/register"
prefetch="intent"
className="font-medium text-primary-600 hover:text-primary-500 focus:underline transition ease-in-out duration-150"
>
Create yours for free
</Link>
</p>
</header>
<Form method="post" action={`./?${searchParams}`} className="mt-8 mx-auto w-full max-w-sm">
{loaderData?.errors ? (
<div role="alert" className="mb-8 sm:mx-auto sm:w-full sm:max-w-sm whitespace-pre">
<Alert title="Oops, there was an issue" message={loaderData.errors.general} variant="error" />
</div>
) : null}
<LabeledTextField
name="email"
type="email"
label="Email"
disabled={isSubmitting}
error={actionData?.errors?.email}
tabIndex={1}
/>
<LabeledTextField
name="password"
type="password"
label="Password"
disabled={isSubmitting}
error={actionData?.errors?.password}
tabIndex={2}
sideLabel={
<Link
to="/forgot-password"
prefetch="intent"
className="font-medium text-primary-600 hover:text-primary-500 transition ease-in-out duration-150"
>
Forgot your password?
</Link>
}
/>
<Button
type="submit"
disabled={transition.state === "submitting"}
tabIndex={3}
>
Sign in
</Button>
</Form>
</section>
);
}
@@ -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(),
});
+51
View File
@@ -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<AlertVariant, AlertVariantProps> = {
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<Props> = ({ title, message, variant }) => {
const variantProperties = ALERT_VARIANTS[variant];
return (
<div className={`rounded-md p-4 ${variantProperties.backgroundColor}`}>
<h3 className={`text-sm leading-5 font-medium ${variantProperties.titleTextColor}`}>{title}</h3>
<div className={`mt-2 text-sm leading-5 ${variantProperties.messageTextColor}`}>{message}</div>
</div>
);
};
export default Alert;
+26
View File
@@ -0,0 +1,26 @@
import type { ButtonHTMLAttributes, FunctionComponent } from "react";
import { useTransition } from "@remix-run/react";
import clsx from "clsx";
type Props = ButtonHTMLAttributes<HTMLButtonElement>;
const Button: FunctionComponent<Props> = ({ children, ...props }) => {
const transition = useTransition();
return (
<button
className={clsx(
"w-full flex justify-center py-2 px-4 border border-transparent text-base font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
{
"bg-primary-400 cursor-not-allowed": transition.state === "submitting",
"bg-primary-600 hover:bg-primary-700": transition.state !== "submitting",
},
)}
{...props}
>
{children}
</button>
);
}
export default Button;
+44
View File
@@ -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 (
<footer
className="grid grid-cols-4 bg-[#F7F7F7] border-t border-gray-400 border-opacity-25 py-3 z-10"
style={{ flex: "0 0 50px" }}
>
<FooterLink label="Calls" path="/calls" icon={<IoCall className="w-6 h-6" />} />
<FooterLink label="Keypad" path="/keypad" icon={<IoKeypad className="w-6 h-6" />} />
<FooterLink label="Messages" path="/messages" icon={<IoChatbubbles className="w-6 h-6" />} />
<FooterLink label="Settings" path="/settings" icon={<IoSettings className="w-6 h-6" />} />
</footer>
);
}
type FooterLinkProps = {
path: string;
label: string;
icon: ReactNode;
};
function FooterLink({ path, label, icon }: FooterLinkProps) {
return (
<div className="flex flex-col items-center justify-around h-full">
<NavLink
to={path}
prefetch="none"
className={({ isActive }) =>
clsx("flex flex-col items-center", {
"text-primary-500": isActive,
"text-[#959595]": !isActive,
})
}
>
{icon}
<span className="text-xs">{label}</span>
</NavLink>
</div>
);
}
@@ -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 (
<div className="flex items-end justify-center min-h-full overflow-y-hidden pt-4 px-4 pb-4 text-center md:block md:p-0 z-10">
@@ -22,7 +22,7 @@ export default function InactiveSubscription() {
<button
type="button"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
onClick={() => router.push(Routes.Billing())}
onClick={() => navigate("/settings/billing")}
>
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
Choose a plan
@@ -0,0 +1,46 @@
import type { FunctionComponent, InputHTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
type Props = {
name: string;
label: ReactNode;
sideLabel?: ReactNode;
type?: "text" | "password" | "email";
error?: string;
} & InputHTMLAttributes<HTMLInputElement>;
const LabeledTextField: FunctionComponent<Props> = ({ name, label, sideLabel, type = "text", error, ...props }) => {
const hasSideLabel = !!sideLabel;
return (
<div className="mb-6">
<label
htmlFor={name}
className={clsx("text-sm font-medium leading-5 text-gray-700", {
block: !hasSideLabel,
"flex justify-between": hasSideLabel,
})}
>
{label}
{sideLabel ?? null}
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id={name}
name={name}
type={type}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
required
{...props}
/>
</div>
{error ? (
<div role="alert" className="text-red-600">
{error}
</div>
) : null}
</div>
);
};
export default LabeledTextField;
@@ -1,14 +1,12 @@
import type { FunctionComponent } from "react";
import Image from "next/image";
import clsx from "clsx";
type Props = {
className?: string;
};
const Logo: FunctionComponent<Props> = ({ className }) => (
<div className={clsx("relative", className)}>
<Image src="/shellphone.png" layout="fill" alt="app logo" />
<div className={className}>
<img src="/shellphone.png" alt="app logo" />
</div>
);
@@ -1,9 +1,7 @@
import { Routes, useRouter } from "blitz";
import { Link } from "@remix-run/react";
import { IoSettings, IoAlertCircleOutline } from "react-icons/io5";
export default function MissingTwilioCredentials() {
const router = useRouter();
return (
<div className="text-center my-auto">
<IoAlertCircleOutline className="mx-auto h-12 w-12 text-gray-400" aria-hidden="true" />
@@ -14,14 +12,13 @@ export default function MissingTwilioCredentials() {
to set up your phone number.
</p>
<div className="mt-6">
<button
type="button"
<Link
to="/settings/account"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
onClick={() => router.push(Routes.PhoneSettings())}
>
<IoSettings className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
Set up my phone number
</button>
</Link>
</div>
</div>
);
@@ -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<Props> = ({ children, initialFocus, isOpen, onClose }) => {
const Modal: FunctionComponent<PropsWithChildren<Props>> = ({ children, initialFocus, isOpen, onClose }) => {
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog
@@ -52,7 +52,7 @@ const Modal: FunctionComponent<Props> = ({ children, initialFocus, isOpen, onClo
);
};
export const ModalTitle: FunctionComponent = ({ children }) => (
export const ModalTitle: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => (
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
{children}
</Dialog.Title>
+68
View File
@@ -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 (
<Listbox value={value} onChange={onChange}>
<div className="relative mt-1">
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left bg-white rounded-lg shadow-md cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-white focus-visible:ring-offset-orange-300 focus-visible:ring-offset-2 focus-visible:border-indigo-500 sm:text-sm">
<span className="block truncate">{value.name}</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<SelectorIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute w-full py-1 mt-1 overflow-auto text-base bg-white rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{options.map((option, index) => (
<Listbox.Option
key={`option-${option}-${index}`}
className={({ active }) =>
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 }) => (
<>
<span
className={clsx("block truncate", selected ? "font-medium" : "font-normal")}
>
{option.name}
</span>
{selected ? (
<span
className={clsx(
"absolute inset-y-0 left-0 flex items-center pl-3",
active ? "text-amber-600" : "text-amber-600",
)}
>
<CheckIcon className="w-5 h-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
);
}
+15
View File
@@ -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 (
<div className="h-full flex">
<div className="ring m-auto text-primary-400" />
</div>
);
}
+13
View File
@@ -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 };
}
@@ -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<Props> = ({ isOpen, closeModal }) => {
const openSettingsButtonRef = useRef<HTMLButtonElement>(null);
const router = useRouter();
const navigate = useNavigate();
return (
<Modal initialFocus={openSettingsButtonRef} isOpen={isOpen} onClose={closeModal}>
@@ -21,8 +22,8 @@ const KeypadErrorModal: FunctionComponent<Props> = ({ isOpen, closeModal }) => {
<div className="mt-2 text-gray-500">
<p>
First things first. Head over to your{" "}
<Link href={Routes.PhoneSettings()}>
<a className="underline">phone settings</a>
<Link to="/settings/phone" className="underline">
phone settings
</Link>{" "}
to set up your phone number.
</p>
@@ -34,7 +35,7 @@ const KeypadErrorModal: FunctionComponent<Props> = ({ 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
</button>
@@ -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<Props> = ({ children, onDigitPressProps, onZeroPressProps }) => {
const Keypad: FunctionComponent<PropsWithChildren<Props>> = ({ children, onDigitPressProps, onZeroPressProps }) => {
return (
<section>
<Row>
@@ -53,18 +53,18 @@ const Keypad: FunctionComponent<Props> = ({ children, onDigitPressProps, onZeroP
export default Keypad;
const Row: FunctionComponent = ({ children }) => (
const Row: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => (
<div className="grid grid-cols-3 p-4 my-0 mx-auto text-black">{children}</div>
);
const DigitLetters: FunctionComponent = ({ children }) => <div className="text-xs text-gray-600">{children}</div>;
const DigitLetters: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => <div className="text-xs text-gray-600">{children}</div>;
type DigitProps = {
digit: string;
onPressProps: Props["onDigitPressProps"];
};
const Digit: FunctionComponent<DigitProps> = ({ children, digit, onPressProps }) => {
const Digit: FunctionComponent<PropsWithChildren<DigitProps>> = ({ children, digit, onPressProps }) => {
const { pressProps } = usePress(onPressProps(digit));
return (
@@ -79,7 +79,7 @@ type ZeroDigitProps = {
onPressProps: Props["onZeroPressProps"];
};
const ZeroDigit: FunctionComponent<ZeroDigitProps> = ({ onPressProps }) => {
const ZeroDigit: FunctionComponent<PropsWithChildren<ZeroDigitProps>> = ({ onPressProps }) => {
const { pressProps } = usePress(onPressProps);
return (
Loaded 100 of 338 files, more files were not shown because too many files have changed in this diff. Show more