Merge branch 'master' into outgoing-calls
80
.github/workflows/main.yml
vendored
Normal file
@ -0,0 +1,80 @@
|
||||
name: Deployment pipeline
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
timeout-minutes: 4
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
|
||||
test:
|
||||
name: Test
|
||||
timeout-minutes: 4
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
|
||||
build:
|
||||
name: Compile
|
||||
timeout-minutes: 6
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
env:
|
||||
DATOCMS_API_TOKEN: ${{ secrets.DATOCMS_API_TOKEN }}
|
||||
QUIRREL_BASE_URL: doesntmatter.shellphone.app
|
||||
|
||||
deploy_dev:
|
||||
if: github.ref == 'refs/heads/master'
|
||||
needs: [lint, test, build]
|
||||
name: Deploy dev.shellphone.app
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
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
|
@ -1,4 +0,0 @@
|
||||
export type ApiError = {
|
||||
statusCode: number;
|
||||
errorMessage: string;
|
||||
};
|
@ -1,56 +0,0 @@
|
||||
import type { BlitzApiRequest, BlitzApiResponse } from "blitz";
|
||||
import zod from "zod";
|
||||
|
||||
import type { ApiError } from "../../_types";
|
||||
import appLogger from "integrations/logger";
|
||||
import { addSubscriber } from "integrations/mailchimp";
|
||||
|
||||
type Response = {} | ApiError;
|
||||
|
||||
const logger = appLogger.child({ route: "/api/newsletter/subscribe" });
|
||||
|
||||
const bodySchema = zod.object({
|
||||
email: zod.string().email(),
|
||||
});
|
||||
|
||||
export default async function subscribeToNewsletter(req: BlitzApiRequest, res: BlitzApiResponse<Response>) {
|
||||
if (req.method !== "POST") {
|
||||
const statusCode = 405;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: `Method ${req.method} Not Allowed`,
|
||||
};
|
||||
logger.error(apiError);
|
||||
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = bodySchema.parse(req.body);
|
||||
} catch (error) {
|
||||
const statusCode = 400;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: "Body is malformed",
|
||||
};
|
||||
logger.error(error);
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await addSubscriber(body.email);
|
||||
} catch (error) {
|
||||
console.log("error", error.response?.data);
|
||||
|
||||
if (error.response?.data.title !== "Member Exists") {
|
||||
return res.status(error.response?.status ?? 400).end();
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).end();
|
||||
}
|
@ -24,7 +24,7 @@ export const LoginForm = (props: LoginFormProps) => {
|
||||
try {
|
||||
await loginMutation(values);
|
||||
props.onSuccess?.();
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
return { [FORM_ERROR]: "Sorry, those credentials are invalid" };
|
||||
} else {
|
||||
|
@ -24,7 +24,7 @@ export const SignupForm = (props: SignupFormProps) => {
|
||||
try {
|
||||
await signupMutation(values);
|
||||
props.onSuccess?.();
|
||||
} catch (error) {
|
||||
} 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" };
|
||||
|
@ -1,59 +0,0 @@
|
||||
import { hash256, Ctx } from "blitz";
|
||||
import previewEmail from "preview-email";
|
||||
|
||||
import forgotPassword from "./forgot-password";
|
||||
import db from "../../../db";
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.$reset();
|
||||
});
|
||||
|
||||
const generatedToken = "plain-token";
|
||||
jest.mock("blitz", () => ({
|
||||
...jest.requireActual<object>("blitz")!,
|
||||
generateToken: () => generatedToken,
|
||||
}));
|
||||
jest.mock("preview-email", () => jest.fn());
|
||||
|
||||
describe.skip("forgotPassword mutation", () => {
|
||||
it("does not throw error if user doesn't exist", async () => {
|
||||
await expect(forgotPassword({ email: "no-user@email.com" }, {} as Ctx)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("works correctly", async () => {
|
||||
// Create test user
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
email: "user@example.com",
|
||||
tokens: {
|
||||
// Create old token to ensure it's deleted
|
||||
create: {
|
||||
type: "RESET_PASSWORD",
|
||||
hashedToken: "token",
|
||||
expiresAt: new Date(),
|
||||
sentTo: "user@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { tokens: true },
|
||||
});
|
||||
|
||||
// Invoke the mutation
|
||||
await forgotPassword({ email: user.email }, {} as Ctx);
|
||||
|
||||
const tokens = await db.token.findMany({ where: { userId: user.id } });
|
||||
const token = tokens[0];
|
||||
if (!user.tokens[0]) throw new Error("Missing user token");
|
||||
if (!token) throw new Error("Missing token");
|
||||
|
||||
// delete's existing tokens
|
||||
expect(tokens.length).toBe(1);
|
||||
|
||||
expect(token.id).not.toBe(user.tokens[0].id);
|
||||
expect(token.type).toBe("RESET_PASSWORD");
|
||||
expect(token.sentTo).toBe(user.email);
|
||||
expect(token.hashedToken).toBe(hash256(generatedToken));
|
||||
expect(token.expiresAt > new Date()).toBe(true);
|
||||
expect(previewEmail).toBeCalled();
|
||||
});
|
||||
});
|
@ -1,6 +1,6 @@
|
||||
import { resolver, SecurePassword, AuthenticationError } from "blitz";
|
||||
|
||||
import db, { GlobalRole } from "../../../db";
|
||||
import db from "../../../db";
|
||||
import { Login } from "../validations";
|
||||
|
||||
export const authenticateUser = async (rawEmail: string, rawPassword: string) => {
|
||||
|
@ -1,75 +0,0 @@
|
||||
import { hash256, SecurePassword } from "blitz";
|
||||
|
||||
import db from "../../../db";
|
||||
import resetPassword from "./reset-password";
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.$reset();
|
||||
});
|
||||
|
||||
const mockCtx: any = {
|
||||
session: {
|
||||
$create: jest.fn,
|
||||
},
|
||||
};
|
||||
|
||||
describe.skip("resetPassword mutation", () => {
|
||||
it("works correctly", async () => {
|
||||
expect(true).toBe(true);
|
||||
|
||||
// Create test user
|
||||
const goodToken = "randomPasswordResetToken";
|
||||
const expiredToken = "expiredRandomPasswordResetToken";
|
||||
const future = new Date();
|
||||
future.setHours(future.getHours() + 4);
|
||||
const past = new Date();
|
||||
past.setHours(past.getHours() - 4);
|
||||
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
email: "user@example.com",
|
||||
tokens: {
|
||||
// Create old token to ensure it's deleted
|
||||
create: [
|
||||
{
|
||||
type: "RESET_PASSWORD",
|
||||
hashedToken: hash256(expiredToken),
|
||||
expiresAt: past,
|
||||
sentTo: "user@example.com",
|
||||
},
|
||||
{
|
||||
type: "RESET_PASSWORD",
|
||||
hashedToken: hash256(goodToken),
|
||||
expiresAt: future,
|
||||
sentTo: "user@example.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: { tokens: true },
|
||||
});
|
||||
|
||||
const newPassword = "newPassword";
|
||||
|
||||
// Non-existent token
|
||||
await expect(
|
||||
resetPassword({ token: "no-token", password: "", passwordConfirmation: "" }, mockCtx),
|
||||
).rejects.toThrowError();
|
||||
|
||||
// Expired token
|
||||
await expect(
|
||||
resetPassword({ token: expiredToken, password: newPassword, passwordConfirmation: newPassword }, mockCtx),
|
||||
).rejects.toThrowError();
|
||||
|
||||
// Good token
|
||||
await resetPassword({ token: goodToken, password: newPassword, passwordConfirmation: newPassword }, mockCtx);
|
||||
|
||||
// Delete's the token
|
||||
const numberOfTokens = await db.token.count({ where: { userId: user.id } });
|
||||
expect(numberOfTokens).toBe(0);
|
||||
|
||||
// Updates user's password
|
||||
const updatedUser = await db.user.findFirst({ where: { id: user.id } });
|
||||
expect(await SecurePassword.verify(updatedUser!.hashedPassword, newPassword)).toBe(SecurePassword.VALID);
|
||||
});
|
||||
});
|
@ -27,7 +27,7 @@ const ForgotPasswordPage: BlitzPage = () => {
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await forgotPasswordMutation(values);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
return {
|
||||
[FORM_ERROR]: "Sorry, we had an unexpected error. Please try again.",
|
||||
};
|
||||
|
@ -19,7 +19,7 @@ const ResetPasswordPage: BlitzPage = () => {
|
||||
<div>
|
||||
<h2>Password Reset Successfully</h2>
|
||||
<p>
|
||||
Go to the <Link href={Routes.Home()}>homepage</Link>
|
||||
Go to the <Link href={Routes.LandingPage()}>homepage</Link>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -34,7 +34,7 @@ const ResetPasswordPage: BlitzPage = () => {
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await resetPasswordMutation(values);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.name === "ResetPasswordError") {
|
||||
return {
|
||||
[FORM_ERROR]: error.message,
|
||||
|
12
app/blog/components/avatar.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
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>
|
||||
);
|
||||
}
|
28
app/blog/components/cover-image.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { Image } from "react-datocms";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function CoverImage({ title, responsiveImage, slug }: any) {
|
||||
const image = (
|
||||
<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>
|
||||
);
|
||||
}
|
10
app/blog/components/date.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
const formatter = Intl.DateTimeFormat("en-US", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export default function DateComponent({ dateString }: any) {
|
||||
const date = new Date(dateString);
|
||||
return <time dateTime={dateString}>{formatter.format(date)}</time>;
|
||||
}
|
82
app/blog/components/more-stories.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { Link, Routes } from "blitz";
|
||||
import PostPreview from "./post-preview";
|
||||
import type { Post } from "../../../integrations/datocms";
|
||||
|
||||
type Props = {
|
||||
posts: Post[];
|
||||
};
|
||||
|
||||
const formatter = Intl.DateTimeFormat("en-US", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
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">
|
||||
{formatter.format(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
app/blog/components/post-body.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
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
app/blog/components/post-preview.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
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>
|
||||
);
|
||||
}
|
3
app/blog/components/section-separator.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function SectionSeparator() {
|
||||
return <hr className="border-accent-2 mt-28 mb-24" />;
|
||||
}
|
@ -1,8 +1,13 @@
|
||||
import { BlitzPage, GetStaticPaths, GetStaticProps, Head, useRouter } from "blitz";
|
||||
import type { BlitzPage, GetStaticPaths, GetStaticProps } from "blitz";
|
||||
import { Head, useRouter } from "blitz";
|
||||
import ErrorPage from "next/error";
|
||||
|
||||
import type { Post } from "integrations/datocms";
|
||||
import { getAllPostsWithSlug, getPostAndMorePosts } from "integrations/datocms";
|
||||
import { getAllPostsWithSlug, getPostAndMorePosts, markdownToHtml } from "integrations/datocms";
|
||||
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;
|
||||
@ -10,15 +15,96 @@ type Props = {
|
||||
preview: boolean;
|
||||
};
|
||||
|
||||
const formatter = Intl.DateTimeFormat("en-US", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const PostPage: BlitzPage<Props> = ({ post, morePosts, preview }) => {
|
||||
const router = useRouter();
|
||||
if (!router.isFallback && !post?.slug) {
|
||||
return <ErrorPage statusCode={404} />;
|
||||
}
|
||||
|
||||
console.log("post", post);
|
||||
|
||||
// TODO
|
||||
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">
|
||||
{" "}
|
||||
· {formatter.format(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>
|
||||
@ -49,8 +135,6 @@ const PostPage: BlitzPage<Props> = ({ post, morePosts, preview }) => {
|
||||
</Container>
|
||||
</Layout>
|
||||
);*/
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default PostPage;
|
||||
@ -63,7 +147,7 @@ export const getStaticProps: GetStaticProps = async ({ params, preview = false }
|
||||
}
|
||||
|
||||
const data = await getPostAndMorePosts(params.slug, preview);
|
||||
const content = /*await markdownToHtml(data.post.content || "");*/ "";
|
||||
const content = await markdownToHtml(data.post.content || "");
|
||||
|
||||
return {
|
||||
props: {
|
||||
|
12
app/blog/pages/blog.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
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
app/blog/styles/post-body.module.css
Normal file
@ -0,0 +1,18 @@
|
||||
.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;
|
||||
}
|
21
app/core/hooks/use-panelbear.ts
Normal file
@ -0,0 +1,21 @@
|
||||
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);
|
||||
}, [siteId]);
|
||||
};
|
@ -45,7 +45,7 @@ export default resolver.pipe(
|
||||
keys_auth: subscription.keys.auth,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.code !== "P2002") {
|
||||
logger.error(error);
|
||||
// we might want to `throw error`;
|
||||
|
@ -1,3 +1,140 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
.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 */
|
||||
}
|
||||
|
@ -23,8 +23,8 @@ const insertMessagesQueue = Queue<Payload>(
|
||||
|
||||
const sms = messages
|
||||
.map<Message>((message) => ({
|
||||
organizationId,
|
||||
id: message.sid,
|
||||
organizationId,
|
||||
phoneNumberId: phoneNumber.id,
|
||||
content: encrypt(message.body, phoneNumber.organization.encryptionKey),
|
||||
from: message.from,
|
||||
|
@ -46,7 +46,7 @@ const notifyIncomingMessageQueue = Queue<Payload>(
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(webPushSubscription, JSON.stringify(notification));
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(error);
|
||||
if (error instanceof WebPushError) {
|
||||
// subscription most likely expired or has been revoked
|
||||
|
@ -34,7 +34,7 @@ const sendMessageQueue = Queue<Payload>(
|
||||
where: { organizationId_phoneNumberId_id: { id, organizationId, phoneNumberId } },
|
||||
data: { id: message.sid },
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
// TODO: handle twilio error
|
||||
console.log(error.code); // 21211
|
||||
console.log(error.moreInfo); // https://www.twilio.com/docs/errors/21211
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { testApiHandler } from "next-test-api-route-handler";
|
||||
import twilio from "twilio";
|
||||
|
||||
import { testApiHandler } from "../../../../test/test-api-handler";
|
||||
import db from "db";
|
||||
import handler from "./incoming-message";
|
||||
import insertIncomingMessageQueue from "../queue/insert-incoming-message";
|
||||
|
@ -2,11 +2,15 @@ import type { BlitzApiRequest, BlitzApiResponse } from "blitz";
|
||||
import { getConfig } from "blitz";
|
||||
import twilio from "twilio";
|
||||
|
||||
import type { ApiError } from "../../../_types";
|
||||
import appLogger from "../../../../integrations/logger";
|
||||
import db from "../../../../db";
|
||||
import insertIncomingMessageQueue from "../queue/insert-incoming-message";
|
||||
|
||||
type ApiError = {
|
||||
statusCode: number;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
const logger = appLogger.child({ route: "/api/webhook/incoming-message" });
|
||||
const { serverRuntimeConfig } = getConfig();
|
||||
|
||||
@ -83,7 +87,7 @@ export default async function incomingMessageHandler(req: BlitzApiRequest, res:
|
||||
|
||||
res.setHeader("content-type", "text/html");
|
||||
res.status(200).send("<Response></Response>");
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const statusCode = error.statusCode ?? 500;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
|
@ -10,21 +10,22 @@ export default function Conversation() {
|
||||
const router = useRouter();
|
||||
const recipient = decodeURIComponent(router.params.recipient);
|
||||
const conversation = useConversation(recipient)[0];
|
||||
const messages = conversation?.messages ?? [];
|
||||
const messagesListRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesListRef.current?.querySelector("li:last-child")?.scrollIntoView();
|
||||
}, [conversation, messagesListRef]);
|
||||
}, [messages, messagesListRef]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col space-y-6 p-6 pt-12 pb-16">
|
||||
<ul ref={messagesListRef}>
|
||||
{conversation.length === 0 ? "empty state" : null}
|
||||
{conversation.map((message, index) => {
|
||||
{messages.length === 0 ? "empty state" : null}
|
||||
{messages.map((message, index) => {
|
||||
const isOutbound = message.direction === Direction.Outbound;
|
||||
const nextMessage = conversation![index + 1];
|
||||
const previousMessage = conversation![index - 1];
|
||||
const nextMessage = messages![index + 1];
|
||||
const previousMessage = messages![index - 1];
|
||||
const isNextMessageFromSameSender = message.from === nextMessage?.from;
|
||||
const isPreviousMessageFromSameSender = message.from === previousMessage?.from;
|
||||
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { Link, useQuery, Routes } from "blitz";
|
||||
import { DateTime } from "luxon";
|
||||
import { faChevronRight } from "@fortawesome/pro-regular-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import getConversationsQuery from "../queries/get-conversations";
|
||||
|
||||
@ -11,21 +14,20 @@ export default function ConversationsList() {
|
||||
|
||||
return (
|
||||
<ul className="divide-y">
|
||||
{Object.entries(conversations).map(([recipient, messages]) => {
|
||||
{Object.values(conversations).map(({ recipient, formattedPhoneNumber, messages }) => {
|
||||
const lastMessage = messages[messages.length - 1]!;
|
||||
return (
|
||||
<li key={recipient} className="py-2">
|
||||
<Link
|
||||
href={Routes.ConversationPage({
|
||||
recipient: encodeURI(recipient),
|
||||
})}
|
||||
>
|
||||
<li key={recipient} className="py-2 p-4">
|
||||
<Link href={Routes.ConversationPage({ recipient })}>
|
||||
<a className="flex flex-col">
|
||||
<div className="flex flex-row justify-between">
|
||||
<strong>{recipient}</strong>
|
||||
<div>{new Date(lastMessage.sentAt).toLocaleString("fr-FR")}</div>
|
||||
<strong>{formattedPhoneNumber}</strong>
|
||||
<div className="text-gray-700 flex flex-row gap-x-1">
|
||||
{formatMessageDate(lastMessage.sentAt)}
|
||||
<FontAwesomeIcon className="w-4 h-4 my-auto" icon={faChevronRight} />
|
||||
</div>
|
||||
</div>
|
||||
<div>{lastMessage.content}</div>
|
||||
<div className="line-clamp-2 text-gray-700">{lastMessage.content}</div>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
@ -34,3 +36,20 @@ export default function ConversationsList() {
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function formatMessageDate(date: Date): string {
|
||||
const messageDate = DateTime.fromJSDate(date);
|
||||
const diff = messageDate.diffNow("days");
|
||||
|
||||
const isToday = diff.days > -1;
|
||||
if (isToday) {
|
||||
return messageDate.toFormat("HH:mm", { locale: "fr-FR" });
|
||||
}
|
||||
|
||||
const isDuringLastWeek = diff.days > -8;
|
||||
if (isDuringLastWeek) {
|
||||
return messageDate.weekdayLong;
|
||||
}
|
||||
|
||||
return messageDate.toFormat("dd/MM/yyyy", { locale: "fr-FR" });
|
||||
}
|
||||
|
@ -59,14 +59,20 @@ const NewMessageArea: FunctionComponent<Props> = ({ recipient, onSend }) => {
|
||||
(conversations) => {
|
||||
const nextConversations = { ...conversations };
|
||||
if (!nextConversations[recipient]) {
|
||||
nextConversations[recipient] = [];
|
||||
nextConversations[recipient] = {
|
||||
recipient,
|
||||
formattedPhoneNumber: recipient,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
nextConversations[recipient] = [...nextConversations[recipient]!, message];
|
||||
nextConversations[recipient]!.messages = [...nextConversations[recipient]!.messages, message];
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(nextConversations).sort(
|
||||
([, a], [, b]) => b[b.length - 1]!.sentAt.getTime() - a[a.length - 1]!.sentAt.getTime(),
|
||||
([, a], [, b]) =>
|
||||
b.messages[b.messages.length - 1]!.sentAt.getTime() -
|
||||
a.messages[a.messages.length - 1]!.sentAt.getTime(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -9,7 +9,7 @@ export default function useConversation(recipient: string) {
|
||||
{
|
||||
select(conversations) {
|
||||
if (!conversations[recipient]) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
return conversations[recipient]!;
|
||||
|
@ -27,7 +27,7 @@ export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({
|
||||
const twilioClient = getTwilioClient(organization);
|
||||
try {
|
||||
await twilioClient.lookups.v1.phoneNumbers(to).fetch();
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(error);
|
||||
return;
|
||||
}
|
||||
|
@ -7,12 +7,14 @@ import { faLongArrowLeft, faInfoCircle, faPhoneAlt as faPhone } from "@fortaweso
|
||||
import Layout from "../../../core/layouts/layout";
|
||||
import Conversation from "../../components/conversation";
|
||||
import useRequireOnboarding from "../../../core/hooks/use-require-onboarding";
|
||||
import useConversation from "../../hooks/use-conversation";
|
||||
|
||||
const ConversationPage: BlitzPage = () => {
|
||||
useRequireOnboarding();
|
||||
|
||||
const router = useRouter();
|
||||
const recipient = decodeURIComponent(router.params.recipient);
|
||||
const conversation = useConversation(recipient)[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -20,7 +22,7 @@ const ConversationPage: BlitzPage = () => {
|
||||
<span className="col-start-1 col-span-1 pl-2 cursor-pointer" onClick={router.back}>
|
||||
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faLongArrowLeft} />
|
||||
</span>
|
||||
<strong className="col-span-1 text-center">{recipient}</strong>
|
||||
<strong className="col-span-1">{conversation?.formattedPhoneNumber ?? recipient}</strong>
|
||||
<span className="col-span-1 flex justify-end space-x-4 pr-2">
|
||||
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faPhone} />
|
||||
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faInfoCircle} />
|
||||
|
@ -1,10 +1,17 @@
|
||||
import { resolver, NotFoundError } from "blitz";
|
||||
import { z } from "zod";
|
||||
import PhoneNumber from "awesome-phonenumber";
|
||||
|
||||
import db, { Direction, Message, Prisma } from "../../../db";
|
||||
import { decrypt } from "../../../db/_encryption";
|
||||
import { enforceSuperAdminIfNotCurrentOrganization, setDefaultOrganizationId } from "../../core/utils";
|
||||
|
||||
type Conversation = {
|
||||
recipient: string;
|
||||
formattedPhoneNumber: string;
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
export default resolver.pipe(
|
||||
resolver.zod(z.object({ organizationId: z.string().optional() })),
|
||||
resolver.authorize(),
|
||||
@ -25,7 +32,7 @@ export default resolver.pipe(
|
||||
orderBy: { sentAt: Prisma.SortOrder.desc },
|
||||
});
|
||||
|
||||
let conversations: Record<string, Message[]> = {};
|
||||
let conversations: Record<string, Conversation> = {};
|
||||
for (const message of messages) {
|
||||
let recipient: string;
|
||||
if (message.direction === Direction.Outbound) {
|
||||
@ -33,21 +40,28 @@ export default resolver.pipe(
|
||||
} else {
|
||||
recipient = message.from;
|
||||
}
|
||||
const formattedPhoneNumber = new PhoneNumber(recipient).getNumber("international");
|
||||
|
||||
if (!conversations[recipient]) {
|
||||
conversations[recipient] = [];
|
||||
conversations[recipient] = {
|
||||
recipient,
|
||||
formattedPhoneNumber,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
conversations[recipient]!.push({
|
||||
conversations[recipient]!.messages.push({
|
||||
...message,
|
||||
content: decrypt(message.content, organization.encryptionKey),
|
||||
});
|
||||
|
||||
conversations[recipient]!.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
|
||||
conversations[recipient]!.messages.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
|
||||
}
|
||||
conversations = Object.fromEntries(
|
||||
Object.entries(conversations).sort(
|
||||
([, a], [, b]) => b[b.length - 1]!.sentAt.getTime() - a[a.length - 1]!.sentAt.getTime(),
|
||||
([, a], [, b]) =>
|
||||
b.messages[b.messages.length - 1]!.sentAt.getTime() -
|
||||
a.messages[a.messages.length - 1]!.sentAt.getTime(),
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -52,13 +52,13 @@ async function getTwimlApplication(
|
||||
}
|
||||
|
||||
const twimlApps = await twilioClient.applications.list();
|
||||
const twimlApp = twimlApps.find((app) => app.friendlyName === "Shellphone");
|
||||
const twimlApp = twimlApps.find((app) => app.friendlyName.startsWith("Shellphone"));
|
||||
if (twimlApp) {
|
||||
return updateTwimlApplication(twilioClient, twimlApp.sid);
|
||||
}
|
||||
|
||||
return twilioClient.applications.create({
|
||||
friendlyName: "Shellphone",
|
||||
friendlyName: getTwiMLName(),
|
||||
smsUrl: `https://${serverRuntimeConfig.app.baseUrl}/api/webhook/incoming-message`,
|
||||
smsMethod: "POST",
|
||||
voiceUrl: `https://${serverRuntimeConfig.app.baseUrl}/api/webhook/call`,
|
||||
@ -77,4 +77,15 @@ async function updateTwimlApplication(twilioClient: twilio.Twilio, twimlAppSid:
|
||||
return twilioClient.applications.get(twimlAppSid).fetch();
|
||||
}
|
||||
|
||||
function getTwiMLName() {
|
||||
switch (serverRuntimeConfig.app.baseUrl) {
|
||||
case "local.shellphone.app":
|
||||
return "Shellphone LOCAL";
|
||||
case "dev.shellphone.app":
|
||||
return "Shellphone DEV";
|
||||
case "www.shellphone.app":
|
||||
return "Shellphone";
|
||||
}
|
||||
}
|
||||
|
||||
export default setTwilioWebhooks;
|
||||
|
@ -30,6 +30,13 @@ const HelpModal: FunctionComponent<Props> = ({ isHelpModalOpen, closeModal }) =>
|
||||
</a>{" "}
|
||||
and we will help you get started!
|
||||
</p>
|
||||
<p>
|
||||
Don't miss out on free $10 Twilio credit by using{" "}
|
||||
<a className="underline" href="https://www.twilio.com/referral/gNvX8p">
|
||||
our referral link
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -32,7 +32,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
|
||||
await session.$revoke();
|
||||
return {
|
||||
redirect: {
|
||||
destination: Routes.Home().pathname,
|
||||
destination: Routes.LandingPage().pathname,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
@ -94,7 +94,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async ({ req, res }
|
||||
await session.$revoke();
|
||||
return {
|
||||
redirect: {
|
||||
destination: Routes.Home().pathname,
|
||||
destination: Routes.LandingPage().pathname,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
@ -133,7 +133,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
|
||||
await session.$revoke();
|
||||
return {
|
||||
redirect: {
|
||||
destination: Routes.Home().pathname,
|
||||
destination: Routes.LandingPage().pathname,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
@ -7,13 +7,19 @@ import {
|
||||
AuthorizationError,
|
||||
ErrorFallbackProps,
|
||||
useQueryErrorResetBoundary,
|
||||
getConfig,
|
||||
} from "blitz";
|
||||
|
||||
import LoginForm from "../auth/components/login-form";
|
||||
import { usePanelbear } from "../core/hooks/use-panelbear";
|
||||
|
||||
import "app/core/styles/index.css";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
usePanelbear(publicRuntimeConfig.panelBear.siteId);
|
||||
|
||||
const getLayout = Component.getLayout || ((page) => page);
|
||||
|
||||
return (
|
||||
|
@ -23,6 +23,23 @@ class MyDocument extends Document {
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="msapplication-starturl" content="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#663399" />
|
||||
<meta name="apple-mobile-web-app-title" content="Shellphone: Your Personal Cloud Phone" />
|
||||
<meta name="application-name" content="Shellphone: Your Personal Cloud Phone" />
|
||||
<meta name="msapplication-TileColor" content="#663399" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/inter-roman.var.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
|
@ -1,39 +0,0 @@
|
||||
import { GlobalRole } from "db";
|
||||
import { render } from "../../test/utils";
|
||||
import Home from "./index";
|
||||
import useCurrentUser from "../core/hooks/use-current-user";
|
||||
|
||||
jest.mock("../core/hooks/use-current-user");
|
||||
const mockUseCurrentUser = useCurrentUser as jest.MockedFunction<typeof useCurrentUser>;
|
||||
|
||||
test.skip("renders blitz documentation link", () => {
|
||||
// This is an example of how to ensure a specific item is in the document
|
||||
// But it's disabled by default (by test.skip) so the test doesn't fail
|
||||
// when you remove the the default content from the page
|
||||
|
||||
// This is an example on how to mock api hooks when testing
|
||||
mockUseCurrentUser.mockReturnValue({
|
||||
organization: undefined,
|
||||
user: {
|
||||
id: uuidv4(),
|
||||
name: "name",
|
||||
email: "email@test.com",
|
||||
role: GlobalRole.CUSTOMER,
|
||||
memberships: [],
|
||||
},
|
||||
hasFilledTwilioCredentials: false,
|
||||
hasCompletedOnboarding: undefined,
|
||||
});
|
||||
|
||||
const { getByText } = render(<Home />);
|
||||
const linkElement = getByText(/Documentation/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
function uuidv4() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0,
|
||||
v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
@ -1,274 +0,0 @@
|
||||
import { Suspense } from "react";
|
||||
import type { BlitzPage } from "blitz";
|
||||
import { Link, useMutation, Routes } from "blitz";
|
||||
|
||||
import BaseLayout from "../core/layouts/base-layout";
|
||||
import logout from "../auth/mutations/logout";
|
||||
import useCurrentUser from "../core/hooks/use-current-user";
|
||||
|
||||
/*
|
||||
* This file is just for a pleasant getting started page for your new app.
|
||||
* You can delete everything in here and start from scratch if you like.
|
||||
*/
|
||||
|
||||
const UserInfo = () => {
|
||||
const { user } = useCurrentUser();
|
||||
const [logoutMutation] = useMutation(logout);
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="button small"
|
||||
onClick={async () => {
|
||||
await logoutMutation();
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
<div>
|
||||
User id: <code>{user.id}</code>
|
||||
<br />
|
||||
User role: <code>{user.role}</code>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<Link href={Routes.SignUp()}>
|
||||
<a className="button small">
|
||||
<strong>Sign Up</strong>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={Routes.SignIn()}>
|
||||
<a className="button small">
|
||||
<strong>Login</strong>
|
||||
</a>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const Home: BlitzPage = () => {
|
||||
return (
|
||||
<div className="container">
|
||||
<main>
|
||||
<div className="logo">
|
||||
<img src="/logo.png" alt="blitz.js" />
|
||||
</div>
|
||||
<p>
|
||||
<strong>Congrats!</strong> Your app is ready, including user sign-up and log-in.
|
||||
</p>
|
||||
<div className="buttons" style={{ marginTop: "1rem", marginBottom: "1rem" }}>
|
||||
<Suspense fallback="Loading...">
|
||||
<UserInfo />
|
||||
</Suspense>
|
||||
</div>
|
||||
<p>
|
||||
<strong>
|
||||
To add a new model to your app, <br />
|
||||
run the following in your terminal:
|
||||
</strong>
|
||||
</p>
|
||||
<pre>
|
||||
<code>blitz generate all project name:string</code>
|
||||
</pre>
|
||||
<div style={{ marginBottom: "1rem" }}>(And select Yes to run prisma migrate)</div>
|
||||
<div>
|
||||
<p>
|
||||
Then <strong>restart the server</strong>
|
||||
</p>
|
||||
<pre>
|
||||
<code>Ctrl + c</code>
|
||||
</pre>
|
||||
<pre>
|
||||
<code>blitz dev</code>
|
||||
</pre>
|
||||
<p>
|
||||
and go to{" "}
|
||||
<Link href="/projects">
|
||||
<a>/projects</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="buttons" style={{ marginTop: "5rem" }}>
|
||||
<a
|
||||
className="button"
|
||||
href="https://blitzjs.com/docs/getting-started?utm_source=blitz-new&utm_medium=app-template&utm_campaign=blitz-new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
<a
|
||||
className="button-outline"
|
||||
href="https://github.com/blitz-js/blitz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Github Repo
|
||||
</a>
|
||||
<a
|
||||
className="button-outline"
|
||||
href="https://discord.blitzjs.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Discord Community
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<a
|
||||
href="https://blitzjs.com?utm_source=blitz-new&utm_medium=app-template&utm_campaign=blitz-new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Powered by Blitz.js
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
<style jsx global>{`
|
||||
@import url("https://fonts.googleapis.com/css2?family=Libre+Franklin:wght@300;700&display=swap");
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: "Libre Franklin", -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 5rem 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
main p {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
p {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
border-top: 1px solid #eaeaea;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #45009d;
|
||||
}
|
||||
|
||||
footer a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #f4f4f4;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-gap: 0.5rem;
|
||||
}
|
||||
.button {
|
||||
font-size: 1rem;
|
||||
background-color: #6700eb;
|
||||
padding: 1rem 2rem;
|
||||
color: #f4f4f4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button.small {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: #45009d;
|
||||
}
|
||||
|
||||
.button-outline {
|
||||
border: 2px solid #6700eb;
|
||||
padding: 1rem 2rem;
|
||||
color: #6700eb;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button-outline:hover {
|
||||
border-color: #45009d;
|
||||
color: #45009d;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #fafafa;
|
||||
border-radius: 5px;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
code {
|
||||
font-size: 0.9rem;
|
||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
max-width: 800px;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.grid {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Home.suppressFirstRenderFlicker = true;
|
||||
|
||||
Home.getLayout = (page) => <BaseLayout title="Home">{page}</BaseLayout>;
|
||||
|
||||
export default Home;
|
@ -3,13 +3,17 @@ import { getConfig } from "blitz";
|
||||
import twilio from "twilio";
|
||||
import type { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
|
||||
|
||||
import type { ApiError } from "../../../_types";
|
||||
import db, { CallStatus, Direction } from "../../../../db";
|
||||
import appLogger from "../../../../integrations/logger";
|
||||
|
||||
const { serverRuntimeConfig } = getConfig();
|
||||
const logger = appLogger.child({ route: "/api/webhook/call" });
|
||||
|
||||
type ApiError = {
|
||||
statusCode: number;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export default async function incomingCallHandler(req: BlitzApiRequest, res: BlitzApiResponse) {
|
||||
console.log("req.body", req.body);
|
||||
|
||||
|
32
app/public-area/components/base-layout.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { Head } from "blitz";
|
||||
|
||||
import Header from "./header";
|
||||
import Footer from "./footer";
|
||||
|
||||
const BaseLayout: FunctionComponent = ({ children }) => (
|
||||
<>
|
||||
<Head>
|
||||
<title>Shellphone: Your Personal Cloud Phone</title>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/P22MackinacPro-ExtraBold.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<section className="font-inter antialiased bg-white text-gray-900 tracking-tight">
|
||||
<section className="flex flex-col min-h-screen overflow-hidden">
|
||||
<Header />
|
||||
|
||||
<main className="flex-grow">{children}</main>
|
||||
|
||||
<Footer />
|
||||
</section>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
export default BaseLayout;
|
11
app/public-area/components/checkmark.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
export default function Checkmark() {
|
||||
return (
|
||||
<svg
|
||||
className="w-3 h-3 fill-current text-primary-400 mr-2 flex-shrink-0"
|
||||
viewBox="0 0 12 12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M10.28 2.28L3.989 8.575 1.695 6.28A1 1 0 00.28 7.695l3 3a1 1 0 001.414 0l7-7A1 1 0 0010.28 2.28z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
48
app/public-area/components/cta-form.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { useMutation } from "blitz";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as Panelbear from "@panelbear/panelbear-js";
|
||||
|
||||
import joinWaitlist from "../mutations/join-waitlist";
|
||||
|
||||
type Form = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export default function CTAForm() {
|
||||
const [joinWaitlistMutation] = useMutation(joinWaitlist);
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { isSubmitted },
|
||||
} = useForm<Form>();
|
||||
const onSubmit = handleSubmit(async ({ email }) => {
|
||||
if (isSubmitted) {
|
||||
return;
|
||||
}
|
||||
|
||||
Panelbear.track("join-waitlist");
|
||||
return joinWaitlistMutation({ email });
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="mt-8">
|
||||
{isSubmitted ? (
|
||||
<p className="text-center md:text-left mt-2 opacity-75 text-green-900 text-md">
|
||||
You're on the list! We will be in touch soon
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row justify-center max-w-sm mx-auto sm:max-w-md md:mx-0">
|
||||
<input
|
||||
{...register("email")}
|
||||
type="email"
|
||||
className="form-input w-full mb-2 sm:mb-0 sm:mr-2 focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Enter your email address"
|
||||
/>
|
||||
<button type="submit" className="btn text-white bg-primary-500 hover:bg-primary-400 flex-shrink-0">
|
||||
Request access
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
86
app/public-area/components/faqs.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export default function FAQs() {
|
||||
return (
|
||||
<section className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="py-12 md:py-20 border-t border-gray-200">
|
||||
<div className="max-w-3xl mx-auto text-center pb-20">
|
||||
<h2 className="h2 font-mackinac">Questions & Answers</h2>
|
||||
</div>
|
||||
|
||||
<ul className="max-w-3xl mx-auto pl-12">
|
||||
<Accordion title="How does it work?">
|
||||
Shellphone is your go-to app to use your phone number over the internet. It integrates
|
||||
seamlessly with Twilio to provide the best experience for your personal cloud phone.
|
||||
</Accordion>
|
||||
<Accordion title="What do I need to use Shellphone?">
|
||||
Shellphone is still in its early stages and we're working hard to make it as easy-to-use as
|
||||
possible. Currently, you must have a Twilio account to set up your personal cloud phone with
|
||||
Shellphone.
|
||||
</Accordion>
|
||||
<Accordion title="Why would I use this over an eSIM?">
|
||||
Chances are you're currently using an eSIM-compatible device. eSIMs are a reasonable way of
|
||||
using a phone number internationally but they are still subject to some irky limitations. For
|
||||
example, you can only use an eSIM on one device at a time and you are still subject to
|
||||
exorbitant rates from your carrier.
|
||||
</Accordion>
|
||||
<span className="block border-t border-gray-200" aria-hidden="true" />
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const Accordion: FunctionComponent<{ title: string }> = ({ title, children }) => {
|
||||
return (
|
||||
<Disclosure>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button className="flex items-center w-full text-lg font-medium text-left py-5 border-t border-gray-200">
|
||||
<svg
|
||||
className="w-4 h-4 fill-current text-blue-500 flex-shrink-0 mr-8 -ml-12"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
y="7"
|
||||
width="16"
|
||||
height="2"
|
||||
rx="1"
|
||||
className={clsx("transform origin-center transition duration-200 ease-out", {
|
||||
"rotate-180": open,
|
||||
})}
|
||||
/>
|
||||
<rect
|
||||
y="7"
|
||||
width="16"
|
||||
height="2"
|
||||
rx="1"
|
||||
className={clsx("transform origin-center transition duration-200 ease-out", {
|
||||
"rotate-90": !open,
|
||||
"rotate-180": open,
|
||||
})}
|
||||
/>
|
||||
</svg>
|
||||
<span>{title}</span>
|
||||
</Disclosure.Button>
|
||||
|
||||
<Transition
|
||||
enter="transition duration-300 ease-in-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Disclosure.Panel className="text-gray-600 overflow-hidden">
|
||||
<p className="pb-5">{children}</p>
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
41
app/public-area/components/footer.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import type { LinkProps } from "blitz";
|
||||
import { Link, Routes } from "blitz";
|
||||
|
||||
export default function Footer() {
|
||||
// TODO
|
||||
const isDisabled = true;
|
||||
if (isDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className="bg-white">
|
||||
<div className="max-w-7xl mx-auto py-12 px-4 overflow-hidden sm:px-6 lg:px-8">
|
||||
<nav className="-mx-5 -my-2 flex flex-wrap justify-center" aria-label="Footer">
|
||||
<NavLink href={Routes.Blog()} name="Blog" />
|
||||
<NavLink href={Routes.PrivacyPolicy()} name="Privacy Policy" />
|
||||
<NavLink href={Routes.TermsOfService()} name="Terms of Service" />
|
||||
<NavLink href="mailto:support@shellphone.app" name="Email Us" />
|
||||
</nav>
|
||||
<p className="mt-8 text-center text-base text-gray-400">
|
||||
© 2021 Capsule Corp. Dev Pte. Ltd. All rights reserved.
|
||||
{/*© 2021 Mokhtar Mial All rights reserved.*/}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
href: LinkProps["href"];
|
||||
name: string;
|
||||
};
|
||||
|
||||
const NavLink: FunctionComponent<Props> = ({ href, name }) => (
|
||||
<div className="px-5 py-2">
|
||||
<Link href={href}>
|
||||
<a className="text-base text-gray-500 hover:text-gray-900">{name}</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
190
app/public-area/components/header.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
import { Fragment, useState, useRef, useEffect } from "react";
|
||||
import type { LinkProps } from "blitz";
|
||||
import { Link, Routes } from "blitz";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { XIcon } from "@heroicons/react/outline";
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<header className="absolute w-full z-30">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="flex items-center justify-between h-20">
|
||||
<div className="flex-shrink-0 mr-5">
|
||||
<Link href="/">
|
||||
<a className="block">
|
||||
<img className="w-10 h-10" src="/shellphone.png" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="hidden md:flex md:flex-grow">
|
||||
<ul className="flex flex-grow flex-wrap items-center font-medium">
|
||||
<li>
|
||||
<DesktopNavLink href={Routes.Roadmap()} label="Roadmap" />
|
||||
</li>
|
||||
<li>
|
||||
<DesktopNavLink href={Routes.OpenMetrics()} label="Open Metrics" />
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<MobileNav />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
type NavLinkProps = {
|
||||
href: LinkProps["href"];
|
||||
label: string;
|
||||
};
|
||||
|
||||
function DesktopNavLink({ href, label }: NavLinkProps) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<a className="text-gray-600 hover:text-gray-900 px-5 py-2 flex items-center transition duration-150 ease-in-out">
|
||||
{label}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNav() {
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const mobileNav = useRef<HTMLDivElement>(null);
|
||||
|
||||
// close the mobile menu on click outside
|
||||
useEffect(() => {
|
||||
const clickHandler = ({ target }: MouseEvent) => {
|
||||
if (!mobileNav.current || !trigger.current) {
|
||||
return;
|
||||
}
|
||||
console.log(mobileNav.current.contains(target as Node));
|
||||
if (
|
||||
!mobileNavOpen ||
|
||||
mobileNav.current.contains(target as Node) ||
|
||||
trigger.current.contains(target as Node)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setMobileNavOpen(false);
|
||||
};
|
||||
document.addEventListener("click", clickHandler);
|
||||
return () => document.removeEventListener("click", clickHandler);
|
||||
});
|
||||
|
||||
// close the mobile menu if the esc key is pressed
|
||||
useEffect(() => {
|
||||
const keyHandler = ({ keyCode }: KeyboardEvent) => {
|
||||
if (!mobileNavOpen || keyCode !== 27) return;
|
||||
setMobileNavOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", keyHandler);
|
||||
return () => document.removeEventListener("keydown", keyHandler);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="inline-flex md:hidden">
|
||||
<button
|
||||
ref={trigger}
|
||||
className={`hamburger ${mobileNavOpen && "active"}`}
|
||||
aria-controls="mobile-nav"
|
||||
aria-expanded={mobileNavOpen}
|
||||
onClick={() => setMobileNavOpen(!mobileNavOpen)}
|
||||
>
|
||||
<span className="sr-only">Menu</span>
|
||||
<svg
|
||||
className="w-6 h-6 fill-current text-gray-900 hover:text-gray-900 transition duration-150 ease-in-out"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect y="4" width="24" height="2" rx="1" />
|
||||
<rect y="11" width="24" height="2" rx="1" />
|
||||
<rect y="18" width="24" height="2" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Transition.Root show={mobileNavOpen} as={Fragment}>
|
||||
<Dialog as="div" className="fixed z-40 inset-0 overflow-hidden" onClose={setMobileNavOpen}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-in-out duration-500"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in-out duration-500"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Dialog.Overlay className="absolute inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-y-0 right-0 pl-10 max-w-full flex">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<div ref={mobileNav} className="w-screen max-w-xs">
|
||||
<div className="h-full flex flex-col py-6 bg-white shadow-xl overflow-y-scroll">
|
||||
<div className="px-4 sm:px-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<Dialog.Title className="text-lg font-medium text-gray-900">
|
||||
Shellphone
|
||||
</Dialog.Title>
|
||||
<div className="ml-3 h-7 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
>
|
||||
<span className="sr-only">Close panel</span>
|
||||
<XIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 relative flex-1 px-4 sm:px-6">
|
||||
<div className="absolute inset-0 px-4 sm:px-6">
|
||||
<ul>
|
||||
<li>
|
||||
<MobileNavLink href={Routes.Roadmap()} label="Roadmap" />
|
||||
</li>
|
||||
<li>
|
||||
<MobileNavLink
|
||||
href={Routes.OpenMetrics()}
|
||||
label="Open Metrics"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
</div>
|
||||
);
|
||||
|
||||
function MobileNavLink({ href, label }: NavLinkProps) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<a onClick={() => setMobileNavOpen(false)} className="flex text-gray-600 hover:text-gray-900 py-2">
|
||||
{label}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Header;
|
44
app/public-area/components/hero.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import CTAForm from "./cta-form";
|
||||
import Checkmark from "./checkmark";
|
||||
import PhoneMockup from "./phone-mockup";
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<section className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="pt-32 pb-10 md:pt-34 md:pb-20">
|
||||
<div className="md:grid md:grid-cols-12 md:gap-12 lg:gap-20 items-center">
|
||||
<div className="md:col-span-7 lg:col-span-7 mb-8 md:mb-0 text-center md:text-left">
|
||||
<h1 className="h1 lg:text-5xl mb-4 font-extrabold font-mackinac">
|
||||
<strong className="bg-gradient-to-br from-primary-500 to-indigo-600 bg-clip-text decoration-clone text-transparent">
|
||||
Take your phone number
|
||||
</strong>
|
||||
<br />
|
||||
<strong className="text-[#24185B]">anywhere you go</strong>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Coming soon! 🐚 Keep your phone number and pay less for your communications, even
|
||||
abroad.
|
||||
</p>
|
||||
<CTAForm />
|
||||
<ul className="max-w-sm sm:max-w-md mx-auto md:max-w-none text-gray-600 mt-8 -mb-2">
|
||||
<li className="flex items-center mb-2">
|
||||
<Checkmark />
|
||||
<span>Send and receive SMS messages.</span>
|
||||
</li>
|
||||
<li className="flex items-center mb-2">
|
||||
<Checkmark />
|
||||
<span>Make and receive phone calls.</span>
|
||||
</li>
|
||||
<li className="flex items-center mb-2">
|
||||
<Checkmark />
|
||||
<span>No download required.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<PhoneMockup />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
23
app/public-area/components/layout.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
|
||||
import BaseLayout from "./base-layout";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
const Layout: FunctionComponent<Props> = ({ children, title }) => (
|
||||
<BaseLayout>
|
||||
<section className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="pt-32 pb-10 md:pt-34 md:pb-16">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h1 className="h1 mb-16 font-extrabold font-mackinac">{title}</h1>
|
||||
</div>
|
||||
|
||||
<div className="max-w-3xl mx-auto text-lg xl:text-xl flow-root">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
);
|
||||
|
||||
export default Layout;
|
26
app/public-area/components/phone-mockup.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import mockupImage from "../images/mockup-image-01.png";
|
||||
import iphoneMockup from "../images/iphone-mockup.png";
|
||||
|
||||
export default function PhoneMockup() {
|
||||
return (
|
||||
<div className="md:col-span-5 lg:col-span-5 text-center md:text-right">
|
||||
<div className="inline-flex relative justify-center items-center">
|
||||
<img
|
||||
className="absolute max-w-[84.33%]"
|
||||
src={mockupImage.src}
|
||||
width={290}
|
||||
height={624}
|
||||
alt="Features illustration"
|
||||
/>
|
||||
<img
|
||||
className="relative max-w-full mx-auto md:mr-0 md:max-w-none h-auto pointer-events-none"
|
||||
src={iphoneMockup.src}
|
||||
width={344}
|
||||
height={674}
|
||||
alt="iPhone mockup"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
36
app/public-area/components/referral-banner.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { XIcon } from "@heroicons/react/outline";
|
||||
|
||||
export default function ReferralBanner() {
|
||||
// TODO
|
||||
const isDisabled = true;
|
||||
if (isDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative bg-primary-600">
|
||||
<div className="max-w-7xl mx-auto py-3 px-3 sm:px-6 lg:px-8">
|
||||
<div className="pr-16 sm:text-center sm:px-16">
|
||||
<p className="font-medium text-white">
|
||||
<span>🎉 New: Get one month free for every friend that joins and subscribe!</span>
|
||||
<span className="block sm:ml-2 sm:inline-block">
|
||||
<a href="#" className="text-white font-bold underline">
|
||||
{" "}
|
||||
Learn more <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute inset-y-0 right-0 pt-1 pr-1 flex items-start sm:pt-1 sm:pr-2 sm:items-start">
|
||||
<button
|
||||
type="button"
|
||||
className="flex p-2 rounded-md hover:bg-primary-500 focus:outline-none focus:ring-2 focus:ring-white"
|
||||
>
|
||||
<span className="sr-only">Dismiss</span>
|
||||
<XIcon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
BIN
app/public-area/images/iphone-mockup.png
Normal file
After Width: | Height: | Size: 54 KiB |
BIN
app/public-area/images/mockup-image-01.jpg
Normal file
After Width: | Height: | Size: 45 KiB |
BIN
app/public-area/images/mockup-image-01.png
Normal file
After Width: | Height: | Size: 195 KiB |
23
app/public-area/mutations/join-waitlist.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { resolver } from "blitz";
|
||||
import { z } from "zod";
|
||||
|
||||
import appLogger from "../../../integrations/logger";
|
||||
import { addSubscriber } from "../../../integrations/mailchimp";
|
||||
|
||||
const logger = appLogger.child({ mutation: "join-waitlist" });
|
||||
|
||||
const bodySchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export default resolver.pipe(resolver.zod(bodySchema), async ({ email }, ctx) => {
|
||||
try {
|
||||
await addSubscriber(email);
|
||||
} catch (error: any) {
|
||||
logger.error(error.response?.data);
|
||||
|
||||
if (error.response?.data.title !== "Member Exists") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
21
app/public-area/pages/index.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import type { BlitzPage } from "blitz";
|
||||
|
||||
import BaseLayout from "../components/base-layout";
|
||||
import ReferralBanner from "../components/referral-banner";
|
||||
import Hero from "../components/hero";
|
||||
import FAQs from "../components/faqs";
|
||||
|
||||
const LandingPage: BlitzPage = () => {
|
||||
return (
|
||||
<>
|
||||
<ReferralBanner />
|
||||
<Hero />
|
||||
<FAQs />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
LandingPage.getLayout = (page) => <BaseLayout>{page}</BaseLayout>;
|
||||
LandingPage.suppressFirstRenderFlicker = true;
|
||||
|
||||
export default LandingPage;
|
39
app/public-area/pages/open-metrics.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import type { BlitzPage } from "blitz";
|
||||
import { useQuery } from "blitz";
|
||||
|
||||
import getMetrics from "../queries/get-metrics";
|
||||
|
||||
import Layout from "../components/layout";
|
||||
|
||||
const initialData = {
|
||||
phoneNumbers: 0,
|
||||
smsExchanged: 0,
|
||||
minutesCalled: 0,
|
||||
};
|
||||
|
||||
const OpenMetrics: BlitzPage = () => {
|
||||
const [metrics] = useQuery(getMetrics, {}, { suspense: false, initialData });
|
||||
const { phoneNumbers, smsExchanged, minutesCalled } = metrics ?? initialData;
|
||||
|
||||
return (
|
||||
<dl className="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-3">
|
||||
<Card title="Phone Numbers Registered" value={phoneNumbers} />
|
||||
<Card title="SMS Exchanged" value={smsExchanged} />
|
||||
<Card title="Minutes on Call" value={minutesCalled} />
|
||||
</dl>
|
||||
);
|
||||
};
|
||||
|
||||
function Card({ title, value }: any) {
|
||||
return (
|
||||
<div className="px-4 py-5 bg-white shadow rounded-lg overflow-hidden sm:p-6">
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">{title}</dt>
|
||||
<dd className="mt-1 text-3xl font-semibold text-gray-900">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
OpenMetrics.getLayout = (page) => <Layout title="Open Metrics">{page}</Layout>;
|
||||
OpenMetrics.suppressFirstRenderFlicker = true;
|
||||
|
||||
export default OpenMetrics;
|
12
app/public-area/pages/privacy.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import type { BlitzPage } from "blitz";
|
||||
|
||||
import Layout from "../components/layout";
|
||||
|
||||
const PrivacyPolicy: BlitzPage = () => {
|
||||
return <article className="m-auto">Coming soon.</article>;
|
||||
};
|
||||
|
||||
PrivacyPolicy.getLayout = (page) => <Layout title="Privacy Policy">{page}</Layout>;
|
||||
PrivacyPolicy.suppressFirstRenderFlicker = true;
|
||||
|
||||
export default PrivacyPolicy;
|
143
app/public-area/pages/roadmap.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import type { BlitzPage } from "blitz";
|
||||
import { Head } from "blitz";
|
||||
import clsx from "clsx";
|
||||
import { CheckIcon, XIcon, TerminalIcon } from "@heroicons/react/solid";
|
||||
|
||||
import Header from "../components/header";
|
||||
import Footer from "../components/footer";
|
||||
import Layout from "../components/layout";
|
||||
|
||||
const Roadmap: BlitzPage = () => {
|
||||
return (
|
||||
<ul role="list" className="-mb-8">
|
||||
{roadmap.map((feature, index) => {
|
||||
const isDone = feature.status === "done";
|
||||
const isInProgress = feature.status === "in-progress";
|
||||
return (
|
||||
<li key={feature.name}>
|
||||
<div className="relative pb-8">
|
||||
{index !== roadmap.length - 1 ? (
|
||||
<span
|
||||
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative flex space-x-3">
|
||||
<div>
|
||||
<span
|
||||
className={clsx(
|
||||
isDone ? "bg-green-500" : isInProgress ? "bg-yellow-500" : "bg-gray-400",
|
||||
"h-8 w-8 rounded-full flex items-center justify-center ring-8 ring-white",
|
||||
)}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckIcon className="h-5 w-5 text-white" aria-hidden="true" />
|
||||
) : isInProgress ? (
|
||||
<TerminalIcon className="h-5 w-5 text-white" aria-hidden="true" />
|
||||
) : (
|
||||
<XIcon className="h-5 w-5 text-white" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 items-center flex justify-between space-x-4">
|
||||
<div>
|
||||
<p className="text-md xl:text-lg text-gray-900">{feature.name}</p>
|
||||
</div>
|
||||
{isDone ? (
|
||||
<div className="text-right self-start text-md xl:text-lg whitespace-nowrap text-gray-500">
|
||||
<time>{formatter.format(feature.doneDate)}</time>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
type RoadmapItem = {
|
||||
name: string;
|
||||
doneDate?: unknown;
|
||||
} & (
|
||||
| {
|
||||
status: "done";
|
||||
doneDate: Date;
|
||||
}
|
||||
| {
|
||||
status: "in-progress";
|
||||
}
|
||||
| {
|
||||
status: "to-do";
|
||||
}
|
||||
);
|
||||
|
||||
const roadmap: RoadmapItem[] = [
|
||||
{
|
||||
name: "Send SMS",
|
||||
status: "done",
|
||||
doneDate: new Date("2021-07-18T15:33:08Z"),
|
||||
},
|
||||
{
|
||||
name: "Receive SMS",
|
||||
status: "done",
|
||||
doneDate: new Date("2021-08-01T10:54:51Z"),
|
||||
},
|
||||
{
|
||||
name: "Make a phone call",
|
||||
status: "in-progress",
|
||||
},
|
||||
{
|
||||
name: "Receive a phone call",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Get notified of incoming messages and calls",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Remove any phone call or message from history",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Allow incoming calls to go to voicemail",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Forward incoming messages and phone calls to your desired phone number",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Import contacts from your mobile phone",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Use Shellphone with multiple phone numbers at once",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Port your phone number to Shellphone - you won't have to deal with Twilio anymore!",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Send delayed messages",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Record phone calls",
|
||||
status: "to-do",
|
||||
},
|
||||
];
|
||||
|
||||
const formatter = Intl.DateTimeFormat("en-US", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
Roadmap.getLayout = (page) => <Layout title="(Rough) Roadmap">{page}</Layout>;
|
||||
Roadmap.suppressFirstRenderFlicker = true;
|
||||
|
||||
export default Roadmap;
|
12
app/public-area/pages/terms.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import type { BlitzPage } from "blitz";
|
||||
|
||||
import Layout from "../components/layout";
|
||||
|
||||
const TermsOfService: BlitzPage = () => {
|
||||
return <article className="m-auto">Coming soon.</article>;
|
||||
};
|
||||
|
||||
TermsOfService.getLayout = (page) => <Layout title="Terms of Service">{page}</Layout>;
|
||||
TermsOfService.suppressFirstRenderFlicker = true;
|
||||
|
||||
export default TermsOfService;
|
25
app/public-area/queries/get-metrics.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { resolver } from "blitz";
|
||||
|
||||
import db from "../../../db";
|
||||
|
||||
export default resolver.pipe(async () => {
|
||||
const [phoneNumbers, smsExchanged, allPhoneCalls] = await Promise.all([
|
||||
db.phoneNumber.count(),
|
||||
db.message.count(),
|
||||
db.phoneCall.findMany(),
|
||||
]);
|
||||
const secondsCalled = allPhoneCalls.reduce<number>((seconds, phoneCall) => {
|
||||
if (!phoneCall.duration) {
|
||||
return seconds;
|
||||
}
|
||||
|
||||
return seconds + Number.parseInt(phoneCall.duration);
|
||||
}, 0);
|
||||
const minutesCalled = Math.round(secondsCalled / 60);
|
||||
|
||||
return {
|
||||
phoneNumbers,
|
||||
smsExchanged,
|
||||
minutesCalled,
|
||||
};
|
||||
});
|
@ -41,7 +41,7 @@ const ProfileInformations: FunctionComponent = () => {
|
||||
try {
|
||||
// TODO
|
||||
// await updateUser({ email, data: { name } });
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(error.response, "error updating user infos");
|
||||
|
||||
if (error.response.status === 401) {
|
||||
|
@ -38,7 +38,7 @@ const UpdatePassword: FunctionComponent = () => {
|
||||
try {
|
||||
// TODO
|
||||
// await customer.updateUser({ password: newPassword });
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(error.response, "error updating user infos");
|
||||
|
||||
if (error.response.status === 401) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { BlitzPage } from "blitz";
|
||||
import { Routes } from "blitz";
|
||||
import { Routes, useMutation } from "blitz";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCreditCard, faUserCircle } from "@fortawesome/pro-regular-svg-icons";
|
||||
|
||||
@ -7,6 +7,7 @@ import Layout from "../../core/layouts/layout";
|
||||
|
||||
import appLogger from "../../../integrations/logger";
|
||||
import useRequireOnboarding from "../../core/hooks/use-require-onboarding";
|
||||
import logout from "../../auth/mutations/logout";
|
||||
|
||||
const logger = appLogger.child({ page: "/settings" });
|
||||
|
||||
@ -27,6 +28,7 @@ const navigation = [
|
||||
|
||||
const Settings: BlitzPage = () => {
|
||||
useRequireOnboarding();
|
||||
const [logoutMutation] = useMutation(logout);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -48,6 +50,8 @@ const Settings: BlitzPage = () => {
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<button onClick={() => logoutMutation()}>Log out</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
6
blitz-env.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
@ -6,12 +6,36 @@ import { sessionMiddleware, simpleRolesIsAuthorized } from "blitz";
|
||||
type Module = Omit<NodeModule, "exports"> & { exports: BlitzConfig };
|
||||
|
||||
(module as Module).exports = {
|
||||
async header() {
|
||||
return [
|
||||
{
|
||||
source: "/fonts/*.woff2",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=31536000, immutable",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/bear.js",
|
||||
destination: "https://cdn.panelbear.com/analytics.js",
|
||||
},
|
||||
];
|
||||
},
|
||||
middleware: [
|
||||
sessionMiddleware({
|
||||
cookiePrefix: "virtual-phone",
|
||||
cookiePrefix: "shellphone",
|
||||
isAuthorized: simpleRolesIsAuthorized,
|
||||
}),
|
||||
],
|
||||
images: {
|
||||
domains: ["www.datocms-assets.com"],
|
||||
},
|
||||
serverRuntimeConfig: {
|
||||
paddle: {
|
||||
apiKey: process.env.PADDLE_API_KEY,
|
||||
@ -43,6 +67,9 @@ type Module = Omit<NodeModule, "exports"> & { exports: BlitzConfig };
|
||||
webPush: {
|
||||
publicKey: process.env.WEB_PUSH_VAPID_PUBLIC_KEY,
|
||||
},
|
||||
panelBear: {
|
||||
siteId: process.env.PANELBEAR_SITE_ID,
|
||||
},
|
||||
},
|
||||
/* Uncomment this to customize the webpack config
|
||||
webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {
|
||||
|
48
fly.dev.toml
Normal file
@ -0,0 +1,48 @@
|
||||
app = "shellphone-dev"
|
||||
|
||||
kill_signal = "SIGINT"
|
||||
kill_timeout = 5
|
||||
processes = []
|
||||
|
||||
[build]
|
||||
builder = "heroku/buildpacks:20"
|
||||
|
||||
[build.args]
|
||||
QUIRREL_BASE_URL = "dev.shellphone.app"
|
||||
|
||||
[env]
|
||||
AWS_SES_REGION = "eu-central-1"
|
||||
AWS_SES_FROM_EMAIL = "mokhtar@fss.dev"
|
||||
QUIRREL_API_URL = "https://queue.mokhtar.dev"
|
||||
QUIRREL_BASE_URL = "dev.shellphone.app"
|
||||
APP_BASE_URL = "dev.shellphone.app"
|
||||
|
||||
[experimental]
|
||||
allowed_public_ports = []
|
||||
auto_rollback = true
|
||||
|
||||
[[services]]
|
||||
http_checks = []
|
||||
internal_port = 3000
|
||||
processes = ["app"]
|
||||
protocol = "tcp"
|
||||
script_checks = []
|
||||
|
||||
[services.concurrency]
|
||||
hard_limit = 25
|
||||
soft_limit = 20
|
||||
type = "connections"
|
||||
|
||||
[[services.ports]]
|
||||
handlers = ["http"]
|
||||
port = 80
|
||||
|
||||
[[services.ports]]
|
||||
handlers = ["tls", "http"]
|
||||
port = 443
|
||||
|
||||
[[services.tcp_checks]]
|
||||
grace_period = "1s"
|
||||
interval = "15s"
|
||||
restart_limit = 6
|
||||
timeout = "2s"
|
48
fly.prod.toml
Normal file
@ -0,0 +1,48 @@
|
||||
app = "shellphone-prod"
|
||||
|
||||
kill_signal = "SIGINT"
|
||||
kill_timeout = 5
|
||||
processes = []
|
||||
|
||||
[build]
|
||||
builder = "heroku/buildpacks:20"
|
||||
|
||||
[build.args]
|
||||
QUIRREL_BASE_URL = "www.shellphone.app"
|
||||
|
||||
[env]
|
||||
AWS_SES_REGION = "eu-central-1"
|
||||
AWS_SES_FROM_EMAIL = "mokhtar@fss.dev"
|
||||
QUIRREL_API_URL = "https://queue.mokhtar.dev"
|
||||
QUIRREL_BASE_URL = "www.shellphone.app"
|
||||
APP_BASE_URL = "www.shellphone.app"
|
||||
|
||||
[experimental]
|
||||
allowed_public_ports = []
|
||||
auto_rollback = true
|
||||
|
||||
[[services]]
|
||||
http_checks = []
|
||||
internal_port = 3000
|
||||
processes = ["app"]
|
||||
protocol = "tcp"
|
||||
script_checks = []
|
||||
|
||||
[services.concurrency]
|
||||
hard_limit = 25
|
||||
soft_limit = 20
|
||||
type = "connections"
|
||||
|
||||
[[services.ports]]
|
||||
handlers = ["http"]
|
||||
port = 80
|
||||
|
||||
[[services.ports]]
|
||||
handlers = ["tls", "http"]
|
||||
port = 443
|
||||
|
||||
[[services.tcp_checks]]
|
||||
grace_period = "1s"
|
||||
interval = "15s"
|
||||
restart_limit = 6
|
||||
timeout = "2s"
|
6908
package-lock.json
generated
73
package.json
@ -2,7 +2,7 @@
|
||||
"name": "shellphone.app",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "concurrently --raw \"blitz dev\" 'quirrel'",
|
||||
"dev": "concurrently --raw \"blitz dev\" 'DISABLE_TELEMETRY=true quirrel'",
|
||||
"build": "blitz build",
|
||||
"start": "blitz start",
|
||||
"studio": "blitz prisma studio",
|
||||
@ -12,7 +12,7 @@
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"engines": {
|
||||
"node": "15"
|
||||
"node": ">=12 <15"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "db/schema.prisma"
|
||||
@ -34,62 +34,71 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-pro": "file:./fontawesome/fortawesome-fontawesome-pro-5.15.3.tgz",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.35",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.3",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.3",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.3",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/pro-duotone-svg-icons": "file:./fontawesome/fortawesome-pro-duotone-svg-icons-5.15.3.tgz",
|
||||
"@fortawesome/pro-light-svg-icons": "file:./fontawesome/fortawesome-pro-light-svg-icons-5.15.3.tgz",
|
||||
"@fortawesome/pro-regular-svg-icons": "file:./fontawesome/fortawesome-pro-regular-svg-icons-5.15.3.tgz",
|
||||
"@fortawesome/pro-solid-svg-icons": "file:./fontawesome/fortawesome-pro-solid-svg-icons-5.15.3.tgz",
|
||||
"@fortawesome/react-fontawesome": "0.1.15",
|
||||
"@headlessui/react": "1.4.0",
|
||||
"@heroicons/react": "1.0.3",
|
||||
"@hookform/resolvers": "2.6.1",
|
||||
"@prisma/client": "2.28.0",
|
||||
"@react-aria/interactions": "3.5.0",
|
||||
"@heroicons/react": "1.0.4",
|
||||
"@hookform/resolvers": "2.8.0",
|
||||
"@panelbear/panelbear-js": "1.2.0",
|
||||
"@prisma/client": "2.30.0",
|
||||
"@react-aria/interactions": "3.5.1",
|
||||
"@tailwindcss/forms": "0.3.3",
|
||||
"@tailwindcss/line-clamp": "0.2.1",
|
||||
"@tailwindcss/typography": "0.4.1",
|
||||
"@twilio/voice-sdk": "2.0.1",
|
||||
"blitz": "0.38.6",
|
||||
"awesome-phonenumber": "2.58.0",
|
||||
"blitz": "0.40.0-canary.5",
|
||||
"clsx": "1.1.1",
|
||||
"got": "11.8.2",
|
||||
"jotai": "1.2.2",
|
||||
"next-pwa": "5.2.24",
|
||||
"pino": "6.13.0",
|
||||
"pino-pretty": "5.1.2",
|
||||
"prisma": "2.28.0",
|
||||
"quirrel": "1.6.3",
|
||||
"react": "18.0.0-alpha-6f3fcbd6f-20210730",
|
||||
"react-dom": "18.0.0-alpha-6f3fcbd6f-20210730",
|
||||
"react-hook-form": "7.12.2",
|
||||
"jotai": "1.3.2",
|
||||
"luxon": "2.0.2",
|
||||
"next-pwa": "5.3.1",
|
||||
"pino": "6.13.1",
|
||||
"pino-pretty": "6.0.0",
|
||||
"quirrel": "1.7.1",
|
||||
"react": "18.0.0-alpha-8723e772b-20210826",
|
||||
"react-datocms": "1.6.3",
|
||||
"react-dom": "18.0.0-alpha-8723e772b-20210826",
|
||||
"react-hook-form": "7.14.0",
|
||||
"react-spring": "9.2.4",
|
||||
"react-spring-bottom-sheet": "3.4.0",
|
||||
"react-use-gesture": "9.1.3",
|
||||
"remark": "14.0.1",
|
||||
"remark-html": "13.0.1",
|
||||
"tailwindcss": "2.2.7",
|
||||
"twilio": "3.66.1",
|
||||
"remark-html": "14.0.0",
|
||||
"tailwindcss": "2.2.8",
|
||||
"twilio": "3.67.1",
|
||||
"web-push": "3.4.5",
|
||||
"zod": "3.2.0"
|
||||
"zod": "3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "2.0.1",
|
||||
"@types/pino": "6.3.11",
|
||||
"@types/preview-email": "2.0.1",
|
||||
"@types/react": "17.0.15",
|
||||
"@types/react": "17.0.19",
|
||||
"@types/test-listen": "1.1.0",
|
||||
"@types/web-push": "3.3.2",
|
||||
"autoprefixer": "10.3.1",
|
||||
"concurrently": "6.2.0",
|
||||
"autoprefixer": "10.3.3",
|
||||
"concurrently": "6.2.1",
|
||||
"eslint": "7.32.0",
|
||||
"husky": "6.0.0",
|
||||
"lint-staged": "10.5.4",
|
||||
"next-test-api-route-handler": "2.0.2",
|
||||
"isomorphic-unfetch": "3.1.0",
|
||||
"lint-staged": "11.1.2",
|
||||
"postcss": "8.3.6",
|
||||
"prettier": "2.3.2",
|
||||
"prettier-plugin-prisma": "2.28.0",
|
||||
"prettier-plugin-prisma": "2.30.0",
|
||||
"pretty-quick": "3.1.1",
|
||||
"preview-email": "3.0.4",
|
||||
"typescript": "4.3.5"
|
||||
"preview-email": "3.0.5",
|
||||
"prisma": "2.30.0",
|
||||
"test-listen": "1.1.0",
|
||||
"type-fest": "2.1.0",
|
||||
"typescript": "4.4.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
BIN
public/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
public/android-chrome-384x384.png
Normal file
After Width: | Height: | Size: 99 KiB |
BIN
public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 19 KiB |
8
public/browserconfig.xml
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<TileColor>#663399</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
BIN
public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
public/favicon.ico
Executable file → Normal file
Before Width: | Height: | Size: 556 B After Width: | Height: | Size: 15 KiB |
BIN
public/fonts/P22MackinacPro-Bold.woff2
Normal file
BIN
public/fonts/P22MackinacPro-Book.woff2
Normal file
BIN
public/fonts/P22MackinacPro-ExtraBold.woff2
Normal file
BIN
public/fonts/P22MackinacPro-Medium.woff2
Normal file
BIN
public/fonts/inter-italic.var.woff2
Normal file
BIN
public/fonts/inter-roman.var.woff2
Normal file
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Shellphone: Your Personal Virtual Phone",
|
||||
"name": "Shellphone: Your Personal Cloud Phone",
|
||||
"short_name": "Shellphone",
|
||||
"lang": "en-US",
|
||||
"start_url": "/",
|
||||
@ -16,6 +16,18 @@
|
||||
"url": "/calls"
|
||||
}
|
||||
],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"theme_color": "#663399",
|
||||
|
55
public/safari-pinned-tab.svg
Normal file
@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="425.000000pt" height="425.000000pt" viewBox="0 0 425.000000 425.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.14, written by Peter Selinger 2001-2017
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,425.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M424 4014 c-16 -8 -42 -25 -58 -39 -16 -14 -33 -25 -38 -25 -5 0 -6
|
||||
-4 -3 -10 3 -5 1 -10 -4 -10 -6 0 -11 -7 -11 -15 0 -8 -5 -15 -11 -15 -6 0 -9
|
||||
-7 -5 -15 3 -8 0 -15 -7 -15 -8 0 -6 -4 3 -10 8 -5 10 -10 5 -10 -6 0 -11 -9
|
||||
-11 -20 0 -11 4 -20 8 -20 4 0 8 -6 8 -14 0 -20 41 -27 81 -15 61 18 70 18 89
|
||||
-1 11 -11 20 -22 20 -25 0 -5 25 -37 48 -61 7 -8 10 -14 7 -14 -4 0 6 -13 21
|
||||
-28 16 -15 37 -40 48 -55 10 -15 23 -24 29 -20 6 3 7 1 3 -5 -4 -7 -2 -12 5
|
||||
-12 7 0 9 -3 6 -7 -4 -4 5 -19 20 -35 15 -15 23 -28 19 -28 -5 0 6 -13 24 -30
|
||||
18 -16 29 -30 26 -30 -6 0 7 -16 64 -80 14 -16 30 -35 35 -42 6 -8 19 -25 30
|
||||
-38 12 -14 14 -20 5 -15 -13 7 -13 6 -2 -7 8 -10 9 -19 3 -22 -11 -7 -9 -25 3
|
||||
-45 6 -10 5 -12 -2 -7 -16 9 -15 -7 0 -22 19 -19 19 -191 0 -219 -8 -13 -10
|
||||
-23 -6 -23 5 0 3 -4 -3 -8 -24 -15 -19 -115 8 -166 8 -14 7 -16 -3 -11 -7 4
|
||||
-1 -5 15 -19 43 -42 55 -68 50 -103 -3 -18 -10 -33 -15 -33 -6 0 -5 -6 2 -15
|
||||
7 -8 8 -15 3 -15 -6 0 -9 -10 -9 -22 1 -13 0 -27 -3 -33 -11 -20 -10 -40 2
|
||||
-47 7 -4 7 -8 2 -8 -13 0 -13 -99 -1 -119 7 -10 5 -12 -5 -6 -11 7 -12 6 -3
|
||||
-6 6 -8 11 -25 12 -39 1 -14 6 -26 12 -28 5 -2 8 -8 5 -12 -6 -10 41 -60 55
|
||||
-60 6 0 10 -4 10 -10 0 -5 7 -10 15 -10 8 0 15 -5 15 -11 0 -5 4 -8 9 -4 19
|
||||
11 25 -22 27 -135 1 -63 3 -115 4 -115 1 0 3 -16 4 -35 1 -20 5 -41 10 -48 4
|
||||
-7 5 -16 1 -21 -3 -5 -1 -12 5 -16 6 -4 8 -10 5 -15 -7 -11 59 -172 107 -259
|
||||
23 -41 60 -97 83 -125 23 -28 57 -76 77 -106 42 -66 212 -227 306 -289 210
|
||||
-140 447 -259 602 -302 19 -6 50 -17 67 -25 38 -17 140 -30 319 -40 l128 -7
|
||||
59 -48 c130 -105 308 -155 520 -146 117 4 133 7 190 35 34 17 81 50 105 73 44
|
||||
43 50 64 44 161 -2 33 1 39 30 54 39 19 194 168 213 204 32 61 48 162 42 274
|
||||
-5 123 -13 160 -63 320 -64 203 -236 503 -374 651 -25 28 -55 64 -65 80 -11
|
||||
17 -61 73 -112 124 -51 52 -93 99 -93 103 0 4 -4 8 -10 8 -12 0 -42 24 -90 73
|
||||
-21 20 -43 37 -49 37 -6 0 -11 5 -11 11 0 5 -3 8 -7 6 -10 -6 -34 19 -26 27 4
|
||||
3 0 6 -8 6 -8 0 -29 14 -47 30 -18 17 -37 30 -43 30 -5 0 -9 5 -9 10 0 6 -7
|
||||
10 -15 10 -8 0 -15 5 -15 10 0 6 -5 10 -10 10 -6 0 -16 5 -23 10 -31 26 -86
|
||||
58 -91 52 -3 -3 -6 0 -6 6 0 6 -16 16 -35 22 -19 6 -35 16 -35 21 0 5 -3 8 -7
|
||||
7 -5 -1 -27 8 -49 20 -23 11 -44 18 -48 15 -3 -4 -6 -1 -6 5 0 7 -3 11 -7 10
|
||||
-5 -1 -39 8 -78 20 -84 26 -155 42 -155 35 0 -3 -6 -1 -12 4 -7 5 -41 9 -75 9
|
||||
-35 -1 -63 3 -63 7 0 5 -5 5 -11 1 -16 -9 -139 -10 -139 -1 0 5 -4 6 -9 2 -5
|
||||
-3 -12 4 -16 15 -3 11 -12 20 -18 20 -7 0 -21 11 -31 25 -10 14 -25 25 -33 25
|
||||
-7 0 -16 6 -19 14 -9 24 -91 53 -174 62 -25 3 -51 7 -57 9 -7 2 -13 1 -13 -3
|
||||
0 -4 -32 -6 -70 -6 -58 1 -77 6 -110 28 -22 14 -44 26 -50 26 -5 0 -10 4 -10
|
||||
8 0 4 -8 8 -17 9 -10 0 -29 4 -43 8 -14 4 -63 9 -110 10 -47 2 -87 4 -90 5 -3
|
||||
1 -8 2 -12 1 -5 0 -8 2 -8 7 0 4 -19 8 -42 10 -29 2 -51 11 -69 28 -25 24
|
||||
-109 42 -109 23 0 -5 -11 -3 -25 3 -14 6 -25 15 -25 19 0 7 -38 54 -77 96 -12
|
||||
13 -20 23 -18 23 2 0 -9 14 -25 32 -16 17 -26 36 -23 41 3 6 2 8 -3 5 -5 -3
|
||||
-15 0 -22 8 -7 8 -10 14 -6 14 4 0 -6 13 -22 29 -55 53 -134 142 -134 150 0 4
|
||||
-11 14 -25 21 -13 7 -22 16 -20 20 3 5 -2 10 -10 14 -8 3 -15 10 -15 16 0 6
|
||||
-7 18 -15 26 -20 21 -19 27 10 58 27 29 32 42 13 30 -10 -6 -10 -4 -2 7 13 16
|
||||
15 49 2 49 -4 0 -8 5 -8 11 0 38 -75 57 -126 33z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/shellphone.png
Normal file
After Width: | Height: | Size: 134 KiB |
@ -6,6 +6,8 @@ module.exports = {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["Inter var", ...defaultTheme.fontFamily.sans],
|
||||
inter: ["Inter var", "sans-serif"],
|
||||
mackinac: ["P22 Mackinac Pro", "sans-serif"],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
@ -20,10 +22,130 @@ module.exports = {
|
||||
800: "#39236b",
|
||||
900: "#1f163f",
|
||||
},
|
||||
gray: {
|
||||
50: "#FAFAFA",
|
||||
100: "#F4F4F5",
|
||||
200: "#E4E4E7",
|
||||
300: "#D4D4D8",
|
||||
400: "#A2A2A8",
|
||||
500: "#6E6E76",
|
||||
600: "#52525A",
|
||||
700: "#3F3F45",
|
||||
800: "#2E2E33",
|
||||
900: "#1D1D20",
|
||||
},
|
||||
teal: {
|
||||
50: "#F4FFFD",
|
||||
100: "#E6FFFA",
|
||||
200: "#B2F5EA",
|
||||
300: "#81E6D9",
|
||||
400: "#4FD1C5",
|
||||
500: "#3ABAB4",
|
||||
600: "#319795",
|
||||
700: "#2C7A7B",
|
||||
800: "#285E61",
|
||||
900: "#234E52",
|
||||
},
|
||||
indigo: {
|
||||
50: "#F8FBFF",
|
||||
100: "#EBF4FF",
|
||||
200: "#C3DAFE",
|
||||
300: "#A3BFFA",
|
||||
400: "#7F9CF5",
|
||||
500: "#667EEA",
|
||||
600: "#5A67D8",
|
||||
700: "#4C51BF",
|
||||
800: "#34399B",
|
||||
900: "#1E2156",
|
||||
},
|
||||
purple: {
|
||||
50: "#FAF5FF",
|
||||
100: "#F3E8FF",
|
||||
200: "#E9D8FD",
|
||||
300: "#D6BCFA",
|
||||
400: "#B794F4",
|
||||
500: "#9F7AEA",
|
||||
600: "#805AD5",
|
||||
700: "#6B46C1",
|
||||
800: "#553C9A",
|
||||
900: "#44337A",
|
||||
},
|
||||
pink: {
|
||||
50: "#FFF5F7",
|
||||
100: "#FFEBEF",
|
||||
200: "#FED7E2",
|
||||
300: "#FBB6CE",
|
||||
400: "#F687B3",
|
||||
500: "#ED64A6",
|
||||
600: "#D53F8C",
|
||||
700: "#B83280",
|
||||
800: "#97266D",
|
||||
900: "#702459",
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
"2xl": "0 25px 50px -12px rgba(0, 0, 0, 0.08)",
|
||||
},
|
||||
outline: {
|
||||
blue: "2px solid rgba(0, 112, 244, 0.5)",
|
||||
},
|
||||
spacing: {
|
||||
128: "32rem",
|
||||
"9/16": "56.25%",
|
||||
"3/4": "75%",
|
||||
"1/1": "100%",
|
||||
},
|
||||
fontSize: {
|
||||
xs: ["0.75rem", { lineHeight: "1.5" }],
|
||||
sm: ["0.875rem", { lineHeight: "1.5" }],
|
||||
base: ["1rem", { lineHeight: "1.5" }],
|
||||
lg: ["1.125rem", { lineHeight: "1.5" }],
|
||||
xl: ["1.25rem", { lineHeight: "1.5" }],
|
||||
"2xl": ["1.63rem", { lineHeight: "1.35" }],
|
||||
"3xl": ["2.63rem", { lineHeight: "1.24" }],
|
||||
"4xl": ["3.5rem", { lineHeight: "1.18" }],
|
||||
"5xl": ["4rem", { lineHeight: "1.16" }],
|
||||
"6xl": ["5.5rem", { lineHeight: "1.11" }],
|
||||
},
|
||||
inset: {
|
||||
"1/2": "50%",
|
||||
full: "100%",
|
||||
},
|
||||
letterSpacing: {
|
||||
tighter: "-0.02em",
|
||||
tight: "-0.01em",
|
||||
normal: "0",
|
||||
wide: "0.01em",
|
||||
wider: "0.02em",
|
||||
widest: "0.4em",
|
||||
},
|
||||
minWidth: {
|
||||
10: "2.5rem",
|
||||
},
|
||||
scale: {
|
||||
98: ".98",
|
||||
},
|
||||
animation: {
|
||||
float: "float 5s ease-in-out infinite",
|
||||
},
|
||||
keyframes: {
|
||||
float: {
|
||||
"0%, 100%": { transform: "translateY(0)" },
|
||||
"50%": { transform: "translateY(-10%)" },
|
||||
},
|
||||
},
|
||||
zIndex: {
|
||||
"-1": "-1",
|
||||
"-10": "-10",
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {},
|
||||
plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")],
|
||||
variants: {
|
||||
extend: {
|
||||
rotate: ["group-hover"],
|
||||
translate: ["group-hover"],
|
||||
},
|
||||
},
|
||||
plugins: [require("@tailwindcss/line-clamp"), require("@tailwindcss/forms"), require("@tailwindcss/typography")],
|
||||
purge: ["{pages,app}/**/*.{js,ts,jsx,tsx}"],
|
||||
};
|
||||
|
120
test/test-api-handler.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import listen from "test-listen";
|
||||
import fetch from "isomorphic-unfetch";
|
||||
import { createServer } from "http";
|
||||
import { parse as parseUrl } from "url";
|
||||
import { apiResolver } from "next/dist/server/api-utils";
|
||||
|
||||
import type { PromiseValue } from "type-fest";
|
||||
import type { NextApiHandler } from "next";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
|
||||
type FetchReturnValue = PromiseValue<ReturnType<typeof fetch>>;
|
||||
type FetchReturnType<NextResponseJsonType> = Promise<
|
||||
Omit<FetchReturnValue, "json"> & {
|
||||
json: (...args: Parameters<FetchReturnValue["json"]>) => Promise<NextResponseJsonType>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* The parameters expected by `testApiHandler`.
|
||||
*/
|
||||
export type TestParameters<NextResponseJsonType = unknown> = {
|
||||
/**
|
||||
* A function that receives an `IncomingMessage` object. Use this function to
|
||||
* edit the request before it's injected into the handler.
|
||||
*/
|
||||
requestPatcher?: (req: IncomingMessage) => void;
|
||||
/**
|
||||
* A function that receives a `ServerResponse` object. Use this functions to
|
||||
* edit the request before it's injected into the handler.
|
||||
*/
|
||||
responsePatcher?: (res: ServerResponse) => void;
|
||||
/**
|
||||
* A function that receives an object representing "processed" dynamic routes;
|
||||
* _modifications_ to this object are passed directly to the handler. This
|
||||
* should not be confused with query string parsing, which is handled
|
||||
* automatically.
|
||||
*/
|
||||
paramsPatcher?: (params: Record<string, unknown>) => void;
|
||||
/**
|
||||
* `params` is passed directly to the handler and represent processed dynamic
|
||||
* routes. This should not be confused with query string parsing, which is
|
||||
* handled automatically.
|
||||
*
|
||||
* `params: { id: 'some-id' }` is shorthand for `paramsPatcher: (params) =>
|
||||
* (params.id = 'some-id')`. This is most useful for quickly setting many
|
||||
* params at once.
|
||||
*/
|
||||
params?: Record<string, string | string[]>;
|
||||
/**
|
||||
* `url: 'your-url'` is shorthand for `requestPatcher: (req) => (req.url =
|
||||
* 'your-url')`
|
||||
*/
|
||||
url?: string;
|
||||
/**
|
||||
* The actual handler under test. It should be an async function that accepts
|
||||
* `NextApiRequest` and `NextApiResult` objects (in that order) as its two
|
||||
* parameters.
|
||||
*/
|
||||
handler: NextApiHandler<NextResponseJsonType>;
|
||||
/**
|
||||
* `test` must be a function that runs your test assertions, returning a
|
||||
* promise (or async). This function receives one parameter: `fetch`, which is
|
||||
* the unfetch package's `fetch(...)` function but with the first parameter
|
||||
* omitted.
|
||||
*/
|
||||
test: (obj: { fetch: (init?: RequestInit) => FetchReturnType<NextResponseJsonType> }) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Uses Next's internal `apiResolver` to execute api route handlers in a
|
||||
* Next-like testing environment.
|
||||
*/
|
||||
export async function testApiHandler<NextResponseJsonType = any>({
|
||||
requestPatcher,
|
||||
responsePatcher,
|
||||
paramsPatcher,
|
||||
params,
|
||||
url,
|
||||
handler,
|
||||
test,
|
||||
}: TestParameters<NextResponseJsonType>) {
|
||||
let server = null;
|
||||
|
||||
try {
|
||||
const localUrl = await listen(
|
||||
(server = createServer((req, res) => {
|
||||
if (!apiResolver) {
|
||||
res.end();
|
||||
throw new Error("missing apiResolver export from next-server/api-utils");
|
||||
}
|
||||
|
||||
url && (req.url = url);
|
||||
requestPatcher && requestPatcher(req);
|
||||
responsePatcher && responsePatcher(res);
|
||||
|
||||
const finalParams = { ...parseUrl(req.url || "", true).query, ...params };
|
||||
paramsPatcher && paramsPatcher(finalParams);
|
||||
|
||||
/**
|
||||
*? From next internals:
|
||||
** apiResolver(
|
||||
** req: IncomingMessage,
|
||||
** res: ServerResponse,
|
||||
** query: any,
|
||||
** resolverModule: any,
|
||||
** apiContext: __ApiPreviewProps,
|
||||
** propagateError: boolean
|
||||
** )
|
||||
*/
|
||||
void apiResolver(req, res, finalParams, handler, undefined as any, true, { route: "", config: {} });
|
||||
})),
|
||||
);
|
||||
|
||||
await test({
|
||||
fetch: (init?: RequestInit) => fetch(localUrl, init) as FetchReturnType<NextResponseJsonType>,
|
||||
});
|
||||
} finally {
|
||||
server?.close();
|
||||
}
|
||||
}
|