Merge branch 'master' into outgoing-calls

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

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 {
await loginMutation(values);
props.onSuccess?.();
} catch (error) {
} catch (error: any) {
if (error instanceof AuthenticationError) {
return { [FORM_ERROR]: "Sorry, those credentials are invalid" };
} else {

View File

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

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 db, { GlobalRole } from "../../../db";
import db from "../../../db";
import { Login } from "../validations";
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) => {
try {
await forgotPasswordMutation(values);
} catch (error) {
} catch (error: any) {
return {
[FORM_ERROR]: "Sorry, we had an unexpected error. Please try again.",
};

View File

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

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 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
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,
},
});
} catch (error) {
} catch (error: any) {
if (error.code !== "P2002") {
logger.error(error);
// we might want to `throw error`;

View File

@ -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 */
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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 {
// TODO
// await updateUser({ email, data: { name } });
} catch (error) {
} catch (error: any) {
logger.error(error.response, "error updating user infos");
if (error.response.status === 401) {

View File

@ -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) {

View File

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