Merge branch 'master' into outgoing-calls

This commit is contained in:
m5r 2021-08-30 06:58:00 +08:00
commit e9fb2602ae
92 changed files with 6276 additions and 3211 deletions

80
.github/workflows/main.yml vendored Normal file
View 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

View File

@ -1,4 +0,0 @@
export type ApiError = {
statusCode: number;
errorMessage: string;
};

View File

@ -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();
}

View File

@ -24,7 +24,7 @@ export const LoginForm = (props: LoginFormProps) => {
try { try {
await loginMutation(values); await loginMutation(values);
props.onSuccess?.(); props.onSuccess?.();
} catch (error) { } catch (error: any) {
if (error instanceof AuthenticationError) { if (error instanceof AuthenticationError) {
return { [FORM_ERROR]: "Sorry, those credentials are invalid" }; return { [FORM_ERROR]: "Sorry, those credentials are invalid" };
} else { } else {

View File

@ -24,7 +24,7 @@ export const SignupForm = (props: SignupFormProps) => {
try { try {
await signupMutation(values); await signupMutation(values);
props.onSuccess?.(); props.onSuccess?.();
} catch (error) { } catch (error: any) {
if (error.code === "P2002" && error.meta?.target?.includes("email")) { if (error.code === "P2002" && error.meta?.target?.includes("email")) {
// This error comes from Prisma // This error comes from Prisma
return { email: "This email is already being used" }; return { email: "This email is already being used" };

View File

@ -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();
});
});

View File

@ -1,6 +1,6 @@
import { resolver, SecurePassword, AuthenticationError } from "blitz"; import { resolver, SecurePassword, AuthenticationError } from "blitz";
import db, { GlobalRole } from "../../../db"; import db from "../../../db";
import { Login } from "../validations"; import { Login } from "../validations";
export const authenticateUser = async (rawEmail: string, rawPassword: string) => { export const authenticateUser = async (rawEmail: string, rawPassword: string) => {

View File

@ -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);
});
});

View File

@ -27,7 +27,7 @@ const ForgotPasswordPage: BlitzPage = () => {
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await forgotPasswordMutation(values); await forgotPasswordMutation(values);
} catch (error) { } catch (error: any) {
return { return {
[FORM_ERROR]: "Sorry, we had an unexpected error. Please try again.", [FORM_ERROR]: "Sorry, we had an unexpected error. Please try again.",
}; };

View File

@ -19,7 +19,7 @@ const ResetPasswordPage: BlitzPage = () => {
<div> <div>
<h2>Password Reset Successfully</h2> <h2>Password Reset Successfully</h2>
<p> <p>
Go to the <Link href={Routes.Home()}>homepage</Link> Go to the <Link href={Routes.LandingPage()}>homepage</Link>
</p> </p>
</div> </div>
) : ( ) : (
@ -34,7 +34,7 @@ const ResetPasswordPage: BlitzPage = () => {
onSubmit={async (values) => { onSubmit={async (values) => {
try { try {
await resetPasswordMutation(values); await resetPasswordMutation(values);
} catch (error) { } catch (error: any) {
if (error.name === "ResetPasswordError") { if (error.name === "ResetPasswordError") {
return { return {
[FORM_ERROR]: error.message, [FORM_ERROR]: error.message,

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,3 @@
export default function SectionSeparator() {
return <hr className="border-accent-2 mt-28 mb-24" />;
}

View File

@ -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 ErrorPage from "next/error";
import type { Post } from "integrations/datocms"; 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 = { type Props = {
post: Post; post: Post;
@ -10,15 +15,96 @@ type Props = {
preview: boolean; preview: boolean;
}; };
const formatter = Intl.DateTimeFormat("en-US", {
day: "2-digit",
month: "short",
year: "numeric",
});
const PostPage: BlitzPage<Props> = ({ post, morePosts, preview }) => { const PostPage: BlitzPage<Props> = ({ post, morePosts, preview }) => {
const router = useRouter(); const router = useRouter();
if (!router.isFallback && !post?.slug) { if (!router.isFallback && !post?.slug) {
return <ErrorPage statusCode={404} />; return <ErrorPage statusCode={404} />;
} }
console.log("post", post); 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 ( /*return (
<Layout preview={preview}> <Layout preview={preview}>
<Container> <Container>
@ -49,8 +135,6 @@ const PostPage: BlitzPage<Props> = ({ post, morePosts, preview }) => {
</Container> </Container>
</Layout> </Layout>
);*/ );*/
return null;
}; };
export default PostPage; export default PostPage;
@ -63,7 +147,7 @@ export const getStaticProps: GetStaticProps = async ({ params, preview = false }
} }
const data = await getPostAndMorePosts(params.slug, preview); const data = await getPostAndMorePosts(params.slug, preview);
const content = /*await markdownToHtml(data.post.content || "");*/ ""; const content = await markdownToHtml(data.post.content || "");
return { return {
props: { props: {

12
app/blog/pages/blog.tsx Normal file
View 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;

View 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;
}

View 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]);
};

View File

@ -45,7 +45,7 @@ export default resolver.pipe(
keys_auth: subscription.keys.auth, keys_auth: subscription.keys.auth,
}, },
}); });
} catch (error) { } catch (error: any) {
if (error.code !== "P2002") { if (error.code !== "P2002") {
logger.error(error); logger.error(error);
// we might want to `throw error`; // we might want to `throw error`;

View File

@ -1,3 +1,140 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @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 */
}

View File

@ -23,8 +23,8 @@ const insertMessagesQueue = Queue<Payload>(
const sms = messages const sms = messages
.map<Message>((message) => ({ .map<Message>((message) => ({
organizationId,
id: message.sid, id: message.sid,
organizationId,
phoneNumberId: phoneNumber.id, phoneNumberId: phoneNumber.id,
content: encrypt(message.body, phoneNumber.organization.encryptionKey), content: encrypt(message.body, phoneNumber.organization.encryptionKey),
from: message.from, from: message.from,

View File

@ -46,7 +46,7 @@ const notifyIncomingMessageQueue = Queue<Payload>(
try { try {
await webpush.sendNotification(webPushSubscription, JSON.stringify(notification)); await webpush.sendNotification(webPushSubscription, JSON.stringify(notification));
} catch (error) { } catch (error: any) {
logger.error(error); logger.error(error);
if (error instanceof WebPushError) { if (error instanceof WebPushError) {
// subscription most likely expired or has been revoked // subscription most likely expired or has been revoked

View File

@ -34,7 +34,7 @@ const sendMessageQueue = Queue<Payload>(
where: { organizationId_phoneNumberId_id: { id, organizationId, phoneNumberId } }, where: { organizationId_phoneNumberId_id: { id, organizationId, phoneNumberId } },
data: { id: message.sid }, data: { id: message.sid },
}); });
} catch (error) { } catch (error: any) {
// TODO: handle twilio error // TODO: handle twilio error
console.log(error.code); // 21211 console.log(error.code); // 21211
console.log(error.moreInfo); // https://www.twilio.com/docs/errors/21211 console.log(error.moreInfo); // https://www.twilio.com/docs/errors/21211

View File

@ -1,6 +1,6 @@
import { testApiHandler } from "next-test-api-route-handler";
import twilio from "twilio"; import twilio from "twilio";
import { testApiHandler } from "../../../../test/test-api-handler";
import db from "db"; import db from "db";
import handler from "./incoming-message"; import handler from "./incoming-message";
import insertIncomingMessageQueue from "../queue/insert-incoming-message"; import insertIncomingMessageQueue from "../queue/insert-incoming-message";

View File

@ -2,11 +2,15 @@ import type { BlitzApiRequest, BlitzApiResponse } from "blitz";
import { getConfig } from "blitz"; import { getConfig } from "blitz";
import twilio from "twilio"; import twilio from "twilio";
import type { ApiError } from "../../../_types";
import appLogger from "../../../../integrations/logger"; import appLogger from "../../../../integrations/logger";
import db from "../../../../db"; import db from "../../../../db";
import insertIncomingMessageQueue from "../queue/insert-incoming-message"; import insertIncomingMessageQueue from "../queue/insert-incoming-message";
type ApiError = {
statusCode: number;
errorMessage: string;
};
const logger = appLogger.child({ route: "/api/webhook/incoming-message" }); const logger = appLogger.child({ route: "/api/webhook/incoming-message" });
const { serverRuntimeConfig } = getConfig(); const { serverRuntimeConfig } = getConfig();
@ -83,7 +87,7 @@ export default async function incomingMessageHandler(req: BlitzApiRequest, res:
res.setHeader("content-type", "text/html"); res.setHeader("content-type", "text/html");
res.status(200).send("<Response></Response>"); res.status(200).send("<Response></Response>");
} catch (error) { } catch (error: any) {
const statusCode = error.statusCode ?? 500; const statusCode = error.statusCode ?? 500;
const apiError: ApiError = { const apiError: ApiError = {
statusCode, statusCode,

View File

@ -10,21 +10,22 @@ export default function Conversation() {
const router = useRouter(); const router = useRouter();
const recipient = decodeURIComponent(router.params.recipient); const recipient = decodeURIComponent(router.params.recipient);
const conversation = useConversation(recipient)[0]; const conversation = useConversation(recipient)[0];
const messages = conversation?.messages ?? [];
const messagesListRef = useRef<HTMLUListElement>(null); const messagesListRef = useRef<HTMLUListElement>(null);
useEffect(() => { useEffect(() => {
messagesListRef.current?.querySelector("li:last-child")?.scrollIntoView(); messagesListRef.current?.querySelector("li:last-child")?.scrollIntoView();
}, [conversation, messagesListRef]); }, [messages, messagesListRef]);
return ( return (
<> <>
<div className="flex flex-col space-y-6 p-6 pt-12 pb-16"> <div className="flex flex-col space-y-6 p-6 pt-12 pb-16">
<ul ref={messagesListRef}> <ul ref={messagesListRef}>
{conversation.length === 0 ? "empty state" : null} {messages.length === 0 ? "empty state" : null}
{conversation.map((message, index) => { {messages.map((message, index) => {
const isOutbound = message.direction === Direction.Outbound; const isOutbound = message.direction === Direction.Outbound;
const nextMessage = conversation![index + 1]; const nextMessage = messages![index + 1];
const previousMessage = conversation![index - 1]; const previousMessage = messages![index - 1];
const isNextMessageFromSameSender = message.from === nextMessage?.from; const isNextMessageFromSameSender = message.from === nextMessage?.from;
const isPreviousMessageFromSameSender = message.from === previousMessage?.from; const isPreviousMessageFromSameSender = message.from === previousMessage?.from;

View File

@ -1,4 +1,7 @@
import { Link, useQuery, Routes } from "blitz"; 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"; import getConversationsQuery from "../queries/get-conversations";
@ -11,21 +14,20 @@ export default function ConversationsList() {
return ( return (
<ul className="divide-y"> <ul className="divide-y">
{Object.entries(conversations).map(([recipient, messages]) => { {Object.values(conversations).map(({ recipient, formattedPhoneNumber, messages }) => {
const lastMessage = messages[messages.length - 1]!; const lastMessage = messages[messages.length - 1]!;
return ( return (
<li key={recipient} className="py-2"> <li key={recipient} className="py-2 p-4">
<Link <Link href={Routes.ConversationPage({ recipient })}>
href={Routes.ConversationPage({
recipient: encodeURI(recipient),
})}
>
<a className="flex flex-col"> <a className="flex flex-col">
<div className="flex flex-row justify-between"> <div className="flex flex-row justify-between">
<strong>{recipient}</strong> <strong>{formattedPhoneNumber}</strong>
<div>{new Date(lastMessage.sentAt).toLocaleString("fr-FR")}</div> <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>
<div>{lastMessage.content}</div> <div className="line-clamp-2 text-gray-700">{lastMessage.content}</div>
</a> </a>
</Link> </Link>
</li> </li>
@ -34,3 +36,20 @@ export default function ConversationsList() {
</ul> </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" });
}

View File

@ -59,14 +59,20 @@ const NewMessageArea: FunctionComponent<Props> = ({ recipient, onSend }) => {
(conversations) => { (conversations) => {
const nextConversations = { ...conversations }; const nextConversations = { ...conversations };
if (!nextConversations[recipient]) { 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( return Object.fromEntries(
Object.entries(nextConversations).sort( 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(),
), ),
); );
}, },

View File

@ -9,7 +9,7 @@ export default function useConversation(recipient: string) {
{ {
select(conversations) { select(conversations) {
if (!conversations[recipient]) { if (!conversations[recipient]) {
return []; return null;
} }
return conversations[recipient]!; return conversations[recipient]!;

View File

@ -27,7 +27,7 @@ export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({
const twilioClient = getTwilioClient(organization); const twilioClient = getTwilioClient(organization);
try { try {
await twilioClient.lookups.v1.phoneNumbers(to).fetch(); await twilioClient.lookups.v1.phoneNumbers(to).fetch();
} catch (error) { } catch (error: any) {
logger.error(error); logger.error(error);
return; return;
} }

View File

@ -7,12 +7,14 @@ import { faLongArrowLeft, faInfoCircle, faPhoneAlt as faPhone } from "@fortaweso
import Layout from "../../../core/layouts/layout"; import Layout from "../../../core/layouts/layout";
import Conversation from "../../components/conversation"; import Conversation from "../../components/conversation";
import useRequireOnboarding from "../../../core/hooks/use-require-onboarding"; import useRequireOnboarding from "../../../core/hooks/use-require-onboarding";
import useConversation from "../../hooks/use-conversation";
const ConversationPage: BlitzPage = () => { const ConversationPage: BlitzPage = () => {
useRequireOnboarding(); useRequireOnboarding();
const router = useRouter(); const router = useRouter();
const recipient = decodeURIComponent(router.params.recipient); const recipient = decodeURIComponent(router.params.recipient);
const conversation = useConversation(recipient)[0];
return ( return (
<> <>
@ -20,7 +22,7 @@ const ConversationPage: BlitzPage = () => {
<span className="col-start-1 col-span-1 pl-2 cursor-pointer" onClick={router.back}> <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} /> <FontAwesomeIcon size="lg" className="h-8 w-8" icon={faLongArrowLeft} />
</span> </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"> <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={faPhone} />
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faInfoCircle} /> <FontAwesomeIcon size="lg" className="h-8 w-8" icon={faInfoCircle} />

View File

@ -1,10 +1,17 @@
import { resolver, NotFoundError } from "blitz"; import { resolver, NotFoundError } from "blitz";
import { z } from "zod"; import { z } from "zod";
import PhoneNumber from "awesome-phonenumber";
import db, { Direction, Message, Prisma } from "../../../db"; import db, { Direction, Message, Prisma } from "../../../db";
import { decrypt } from "../../../db/_encryption"; import { decrypt } from "../../../db/_encryption";
import { enforceSuperAdminIfNotCurrentOrganization, setDefaultOrganizationId } from "../../core/utils"; import { enforceSuperAdminIfNotCurrentOrganization, setDefaultOrganizationId } from "../../core/utils";
type Conversation = {
recipient: string;
formattedPhoneNumber: string;
messages: Message[];
};
export default resolver.pipe( export default resolver.pipe(
resolver.zod(z.object({ organizationId: z.string().optional() })), resolver.zod(z.object({ organizationId: z.string().optional() })),
resolver.authorize(), resolver.authorize(),
@ -25,7 +32,7 @@ export default resolver.pipe(
orderBy: { sentAt: Prisma.SortOrder.desc }, orderBy: { sentAt: Prisma.SortOrder.desc },
}); });
let conversations: Record<string, Message[]> = {}; let conversations: Record<string, Conversation> = {};
for (const message of messages) { for (const message of messages) {
let recipient: string; let recipient: string;
if (message.direction === Direction.Outbound) { if (message.direction === Direction.Outbound) {
@ -33,21 +40,28 @@ export default resolver.pipe(
} else { } else {
recipient = message.from; recipient = message.from;
} }
const formattedPhoneNumber = new PhoneNumber(recipient).getNumber("international");
if (!conversations[recipient]) { if (!conversations[recipient]) {
conversations[recipient] = []; conversations[recipient] = {
recipient,
formattedPhoneNumber,
messages: [],
};
} }
conversations[recipient]!.push({ conversations[recipient]!.messages.push({
...message, ...message,
content: decrypt(message.content, organization.encryptionKey), 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( conversations = Object.fromEntries(
Object.entries(conversations).sort( 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(),
), ),
); );

View File

@ -52,13 +52,13 @@ async function getTwimlApplication(
} }
const twimlApps = await twilioClient.applications.list(); 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) { if (twimlApp) {
return updateTwimlApplication(twilioClient, twimlApp.sid); return updateTwimlApplication(twilioClient, twimlApp.sid);
} }
return twilioClient.applications.create({ return twilioClient.applications.create({
friendlyName: "Shellphone", friendlyName: getTwiMLName(),
smsUrl: `https://${serverRuntimeConfig.app.baseUrl}/api/webhook/incoming-message`, smsUrl: `https://${serverRuntimeConfig.app.baseUrl}/api/webhook/incoming-message`,
smsMethod: "POST", smsMethod: "POST",
voiceUrl: `https://${serverRuntimeConfig.app.baseUrl}/api/webhook/call`, 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(); 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; export default setTwilioWebhooks;

View File

@ -30,6 +30,13 @@ const HelpModal: FunctionComponent<Props> = ({ isHelpModalOpen, closeModal }) =>
</a>{" "} </a>{" "}
and we will help you get started! and we will help you get started!
</p> </p>
<p>
Don&#39;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> </div>
</div> </div>

View File

@ -32,7 +32,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
await session.$revoke(); await session.$revoke();
return { return {
redirect: { redirect: {
destination: Routes.Home().pathname, destination: Routes.LandingPage().pathname,
permanent: false, permanent: false,
}, },
}; };

View File

@ -94,7 +94,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async ({ req, res }
await session.$revoke(); await session.$revoke();
return { return {
redirect: { redirect: {
destination: Routes.Home().pathname, destination: Routes.LandingPage().pathname,
permanent: false, permanent: false,
}, },
}; };

View File

@ -133,7 +133,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
await session.$revoke(); await session.$revoke();
return { return {
redirect: { redirect: {
destination: Routes.Home().pathname, destination: Routes.LandingPage().pathname,
permanent: false, permanent: false,
}, },
}; };

View File

@ -7,13 +7,19 @@ import {
AuthorizationError, AuthorizationError,
ErrorFallbackProps, ErrorFallbackProps,
useQueryErrorResetBoundary, useQueryErrorResetBoundary,
getConfig,
} from "blitz"; } from "blitz";
import LoginForm from "../auth/components/login-form"; import LoginForm from "../auth/components/login-form";
import { usePanelbear } from "../core/hooks/use-panelbear";
import "app/core/styles/index.css"; import "app/core/styles/index.css";
const { publicRuntimeConfig } = getConfig();
export default function App({ Component, pageProps }: AppProps) { export default function App({ Component, pageProps }: AppProps) {
usePanelbear(publicRuntimeConfig.panelBear.siteId);
const getLayout = Component.getLayout || ((page) => page); const getLayout = Component.getLayout || ((page) => page);
return ( return (

View File

@ -23,6 +23,23 @@ class MyDocument extends Document {
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="msapplication-starturl" content="/" /> <meta name="msapplication-starturl" content="/" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" /> <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> </Head>
<body> <body>
<Main /> <Main />

View File

@ -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);
});
}

View File

@ -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;

View File

@ -3,13 +3,17 @@ import { getConfig } from "blitz";
import twilio from "twilio"; import twilio from "twilio";
import type { CallInstance } from "twilio/lib/rest/api/v2010/account/call"; import type { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
import type { ApiError } from "../../../_types";
import db, { CallStatus, Direction } from "../../../../db"; import db, { CallStatus, Direction } from "../../../../db";
import appLogger from "../../../../integrations/logger"; import appLogger from "../../../../integrations/logger";
const { serverRuntimeConfig } = getConfig(); const { serverRuntimeConfig } = getConfig();
const logger = appLogger.child({ route: "/api/webhook/call" }); const logger = appLogger.child({ route: "/api/webhook/call" });
type ApiError = {
statusCode: number;
errorMessage: string;
};
export default async function incomingCallHandler(req: BlitzApiRequest, res: BlitzApiResponse) { export default async function incomingCallHandler(req: BlitzApiRequest, res: BlitzApiResponse) {
console.log("req.body", req.body); console.log("req.body", req.body);

View 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;

View 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>
);
}

View 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&#39;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>
);
}

View 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&#39;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&#39;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>
);
};

View 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">
&copy; 2021 Capsule Corp. Dev Pte. Ltd. All rights reserved.
{/*&copy; 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>
);

View 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;

View 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! &#128026; 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>
);
}

View 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;

View 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>
);
}

View 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>&#127881; 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">&rarr;</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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

View 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;
}
}
});

View 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;

View 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;

View 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;

View 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;

View 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;

View 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,
};
});

View File

@ -41,7 +41,7 @@ const ProfileInformations: FunctionComponent = () => {
try { try {
// TODO // TODO
// await updateUser({ email, data: { name } }); // await updateUser({ email, data: { name } });
} catch (error) { } catch (error: any) {
logger.error(error.response, "error updating user infos"); logger.error(error.response, "error updating user infos");
if (error.response.status === 401) { if (error.response.status === 401) {

View File

@ -38,7 +38,7 @@ const UpdatePassword: FunctionComponent = () => {
try { try {
// TODO // TODO
// await customer.updateUser({ password: newPassword }); // await customer.updateUser({ password: newPassword });
} catch (error) { } catch (error: any) {
logger.error(error.response, "error updating user infos"); logger.error(error.response, "error updating user infos");
if (error.response.status === 401) { if (error.response.status === 401) {

View File

@ -1,5 +1,5 @@
import type { BlitzPage } from "blitz"; import type { BlitzPage } from "blitz";
import { Routes } from "blitz"; import { Routes, useMutation } from "blitz";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCreditCard, faUserCircle } from "@fortawesome/pro-regular-svg-icons"; 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 appLogger from "../../../integrations/logger";
import useRequireOnboarding from "../../core/hooks/use-require-onboarding"; import useRequireOnboarding from "../../core/hooks/use-require-onboarding";
import logout from "../../auth/mutations/logout";
const logger = appLogger.child({ page: "/settings" }); const logger = appLogger.child({ page: "/settings" });
@ -27,6 +28,7 @@ const navigation = [
const Settings: BlitzPage = () => { const Settings: BlitzPage = () => {
useRequireOnboarding(); useRequireOnboarding();
const [logoutMutation] = useMutation(logout);
return ( return (
<> <>
@ -48,6 +50,8 @@ const Settings: BlitzPage = () => {
))} ))}
</nav> </nav>
</aside> </aside>
<button onClick={() => logoutMutation()}>Log out</button>
</div> </div>
</> </>
); );

6
blitz-env.d.ts vendored Normal file
View 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.

View File

@ -6,12 +6,36 @@ import { sessionMiddleware, simpleRolesIsAuthorized } from "blitz";
type Module = Omit<NodeModule, "exports"> & { exports: BlitzConfig }; type Module = Omit<NodeModule, "exports"> & { exports: BlitzConfig };
(module as Module).exports = { (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: [ middleware: [
sessionMiddleware({ sessionMiddleware({
cookiePrefix: "virtual-phone", cookiePrefix: "shellphone",
isAuthorized: simpleRolesIsAuthorized, isAuthorized: simpleRolesIsAuthorized,
}), }),
], ],
images: {
domains: ["www.datocms-assets.com"],
},
serverRuntimeConfig: { serverRuntimeConfig: {
paddle: { paddle: {
apiKey: process.env.PADDLE_API_KEY, apiKey: process.env.PADDLE_API_KEY,
@ -43,6 +67,9 @@ type Module = Omit<NodeModule, "exports"> & { exports: BlitzConfig };
webPush: { webPush: {
publicKey: process.env.WEB_PUSH_VAPID_PUBLIC_KEY, publicKey: process.env.WEB_PUSH_VAPID_PUBLIC_KEY,
}, },
panelBear: {
siteId: process.env.PANELBEAR_SITE_ID,
},
}, },
/* Uncomment this to customize the webpack config /* Uncomment this to customize the webpack config
webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => { webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {

48
fly.dev.toml Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
"name": "shellphone.app", "name": "shellphone.app",
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"dev": "concurrently --raw \"blitz dev\" 'quirrel'", "dev": "concurrently --raw \"blitz dev\" 'DISABLE_TELEMETRY=true quirrel'",
"build": "blitz build", "build": "blitz build",
"start": "blitz start", "start": "blitz start",
"studio": "blitz prisma studio", "studio": "blitz prisma studio",
@ -12,7 +12,7 @@
"prepare": "husky install" "prepare": "husky install"
}, },
"engines": { "engines": {
"node": "15" "node": ">=12 <15"
}, },
"prisma": { "prisma": {
"schema": "db/schema.prisma" "schema": "db/schema.prisma"
@ -34,62 +34,71 @@
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-pro": "file:./fontawesome/fortawesome-fontawesome-pro-5.15.3.tgz", "@fortawesome/fontawesome-pro": "file:./fontawesome/fortawesome-fontawesome-pro-5.15.3.tgz",
"@fortawesome/fontawesome-svg-core": "1.2.35", "@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.3", "@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.3", "@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.3", "@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-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-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-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/pro-solid-svg-icons": "file:./fontawesome/fortawesome-pro-solid-svg-icons-5.15.3.tgz",
"@fortawesome/react-fontawesome": "0.1.15", "@fortawesome/react-fontawesome": "0.1.15",
"@headlessui/react": "1.4.0", "@headlessui/react": "1.4.0",
"@heroicons/react": "1.0.3", "@heroicons/react": "1.0.4",
"@hookform/resolvers": "2.6.1", "@hookform/resolvers": "2.8.0",
"@prisma/client": "2.28.0", "@panelbear/panelbear-js": "1.2.0",
"@react-aria/interactions": "3.5.0", "@prisma/client": "2.30.0",
"@react-aria/interactions": "3.5.1",
"@tailwindcss/forms": "0.3.3", "@tailwindcss/forms": "0.3.3",
"@tailwindcss/line-clamp": "0.2.1",
"@tailwindcss/typography": "0.4.1", "@tailwindcss/typography": "0.4.1",
"@twilio/voice-sdk": "2.0.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", "clsx": "1.1.1",
"got": "11.8.2", "got": "11.8.2",
"jotai": "1.2.2", "jotai": "1.3.2",
"next-pwa": "5.2.24", "luxon": "2.0.2",
"pino": "6.13.0", "next-pwa": "5.3.1",
"pino-pretty": "5.1.2", "pino": "6.13.1",
"prisma": "2.28.0", "pino-pretty": "6.0.0",
"quirrel": "1.6.3", "quirrel": "1.7.1",
"react": "18.0.0-alpha-6f3fcbd6f-20210730", "react": "18.0.0-alpha-8723e772b-20210826",
"react-dom": "18.0.0-alpha-6f3fcbd6f-20210730", "react-datocms": "1.6.3",
"react-hook-form": "7.12.2", "react-dom": "18.0.0-alpha-8723e772b-20210826",
"react-hook-form": "7.14.0",
"react-spring": "9.2.4", "react-spring": "9.2.4",
"react-spring-bottom-sheet": "3.4.0", "react-spring-bottom-sheet": "3.4.0",
"react-use-gesture": "9.1.3", "react-use-gesture": "9.1.3",
"remark": "14.0.1", "remark": "14.0.1",
"remark-html": "13.0.1", "remark-html": "14.0.0",
"tailwindcss": "2.2.7", "tailwindcss": "2.2.8",
"twilio": "3.66.1", "twilio": "3.67.1",
"web-push": "3.4.5", "web-push": "3.4.5",
"zod": "3.2.0" "zod": "3.8.1"
}, },
"devDependencies": { "devDependencies": {
"@types/luxon": "2.0.1",
"@types/pino": "6.3.11", "@types/pino": "6.3.11",
"@types/preview-email": "2.0.1", "@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", "@types/web-push": "3.3.2",
"autoprefixer": "10.3.1", "autoprefixer": "10.3.3",
"concurrently": "6.2.0", "concurrently": "6.2.1",
"eslint": "7.32.0", "eslint": "7.32.0",
"husky": "6.0.0", "husky": "6.0.0",
"lint-staged": "10.5.4", "isomorphic-unfetch": "3.1.0",
"next-test-api-route-handler": "2.0.2", "lint-staged": "11.1.2",
"postcss": "8.3.6", "postcss": "8.3.6",
"prettier": "2.3.2", "prettier": "2.3.2",
"prettier-plugin-prisma": "2.28.0", "prettier-plugin-prisma": "2.30.0",
"pretty-quick": "3.1.1", "pretty-quick": "3.1.1",
"preview-email": "3.0.4", "preview-email": "3.0.5",
"typescript": "4.3.5" "prisma": "2.30.0",
"test-listen": "1.1.0",
"type-fest": "2.1.0",
"typescript": "4.4.2"
}, },
"private": true "private": true
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

8
public/browserconfig.xml Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
public/favicon.ico Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 556 B

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,5 +1,5 @@
{ {
"name": "Shellphone: Your Personal Virtual Phone", "name": "Shellphone: Your Personal Cloud Phone",
"short_name": "Shellphone", "short_name": "Shellphone",
"lang": "en-US", "lang": "en-US",
"start_url": "/", "start_url": "/",
@ -16,6 +16,18 @@
"url": "/calls" "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", "display": "standalone",
"orientation": "portrait", "orientation": "portrait",
"theme_color": "#663399", "theme_color": "#663399",

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@ -6,6 +6,8 @@ module.exports = {
extend: { extend: {
fontFamily: { fontFamily: {
sans: ["Inter var", ...defaultTheme.fontFamily.sans], sans: ["Inter var", ...defaultTheme.fontFamily.sans],
inter: ["Inter var", "sans-serif"],
mackinac: ["P22 Mackinac Pro", "sans-serif"],
}, },
colors: { colors: {
primary: { primary: {
@ -20,10 +22,130 @@ module.exports = {
800: "#39236b", 800: "#39236b",
900: "#1f163f", 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: {}, variants: {
plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")], 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}"], purge: ["{pages,app}/**/*.{js,ts,jsx,tsx}"],
}; };

120
test/test-api-handler.ts Normal file
View 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();
}
}