remixed v0
This commit is contained in:
parent
9275d4499b
commit
98b89ae0f7
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.env
|
||||
/.idea
|
||||
/cypress/videos
|
||||
/cypress/screenshots
|
||||
/coverage
|
||||
|
||||
# build artifacts
|
||||
/.cache
|
||||
/public/build
|
||||
/build
|
||||
server.js
|
@ -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
.env.e2e
Normal file
1
.env.e2e
Normal file
@ -0,0 +1 @@
|
||||
DATABASE_URL=postgresql://pgremixtape:pgpassword@localhost:5432/remixtape_e2e
|
25
.env.example
Normal file
25
.env.example
Normal 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
|
@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["blitz"],
|
||||
};
|
109
.github/workflows/main.yml
vendored
109
.github/workflows/main.yml
vendored
@ -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"
|
||||
|
61
.gitignore
vendored
61
.gitignore
vendored
@ -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
|
@ -2,4 +2,3 @@
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
npx pretty-quick --staged
|
||||
|
@ -1,6 +0,0 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx tsc
|
||||
npm run lint
|
||||
#npm run test
|
@ -1,8 +1,6 @@
|
||||
.gitkeep
|
||||
.env*
|
||||
*.ico
|
||||
*.lock
|
||||
db/migrations
|
||||
.next
|
||||
.blitz
|
||||
mailers/**/*.html
|
||||
.env
|
||||
.cache
|
||||
build
|
||||
package-lock.json
|
||||
app/tailwind.css
|
12
.vscode/extensions.json
vendored
12
.vscode/extensions.json
vendored
@ -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
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@ -1,7 +0,0 @@
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
}
|
57
Dockerfile
Normal file
57
Dockerfile
Normal 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"]
|
@ -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);
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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();
|
||||
}
|
@ -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;
|
||||
});
|
@ -1,5 +0,0 @@
|
||||
import type { Ctx } from "blitz";
|
||||
|
||||
export default async function logout(_ = null, ctx: Ctx) {
|
||||
return await ctx.session.$revoke();
|
||||
}
|
@ -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;
|
||||
});
|
@ -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;
|
||||
});
|
@ -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;
|
@ -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;
|
@ -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?
|
||||
<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;
|
@ -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;
|
@ -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;
|
@ -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();
|
||||
}
|
@ -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();
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import Avatar from "./avatar";
|
||||
import Date from "./date";
|
||||
import CoverImage from "./cover-image";
|
||||