Merge branch 'master' into outgoing-calls
This commit is contained in:
@ -1,4 +0,0 @@
|
||||
export type ApiError = {
|
||||
statusCode: number;
|
||||
errorMessage: string;
|
||||
};
|
@ -1,56 +0,0 @@
|
||||
import type { BlitzApiRequest, BlitzApiResponse } from "blitz";
|
||||
import zod from "zod";
|
||||
|
||||
import type { ApiError } from "../../_types";
|
||||
import appLogger from "integrations/logger";
|
||||
import { addSubscriber } from "integrations/mailchimp";
|
||||
|
||||
type Response = {} | ApiError;
|
||||
|
||||
const logger = appLogger.child({ route: "/api/newsletter/subscribe" });
|
||||
|
||||
const bodySchema = zod.object({
|
||||
email: zod.string().email(),
|
||||
});
|
||||
|
||||
export default async function subscribeToNewsletter(req: BlitzApiRequest, res: BlitzApiResponse<Response>) {
|
||||
if (req.method !== "POST") {
|
||||
const statusCode = 405;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: `Method ${req.method} Not Allowed`,
|
||||
};
|
||||
logger.error(apiError);
|
||||
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = bodySchema.parse(req.body);
|
||||
} catch (error) {
|
||||
const statusCode = 400;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
errorMessage: "Body is malformed",
|
||||
};
|
||||
logger.error(error);
|
||||
|
||||
res.status(statusCode).send(apiError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await addSubscriber(body.email);
|
||||
} catch (error) {
|
||||
console.log("error", error.response?.data);
|
||||
|
||||
if (error.response?.data.title !== "Member Exists") {
|
||||
return res.status(error.response?.status ?? 400).end();
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).end();
|
||||
}
|
@ -24,7 +24,7 @@ export const LoginForm = (props: LoginFormProps) => {
|
||||
try {
|
||||
await loginMutation(values);
|
||||
props.onSuccess?.();
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
return { [FORM_ERROR]: "Sorry, those credentials are invalid" };
|
||||
} else {
|
||||
|
@ -24,7 +24,7 @@ export const SignupForm = (props: SignupFormProps) => {
|
||||
try {
|
||||
await signupMutation(values);
|
||||
props.onSuccess?.();
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2002" && error.meta?.target?.includes("email")) {
|
||||
// This error comes from Prisma
|
||||
return { email: "This email is already being used" };
|
||||
|
@ -1,59 +0,0 @@
|
||||
import { hash256, Ctx } from "blitz";
|
||||
import previewEmail from "preview-email";
|
||||
|
||||
import forgotPassword from "./forgot-password";
|
||||
import db from "../../../db";
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.$reset();
|
||||
});
|
||||
|
||||
const generatedToken = "plain-token";
|
||||
jest.mock("blitz", () => ({
|
||||
...jest.requireActual<object>("blitz")!,
|
||||
generateToken: () => generatedToken,
|
||||
}));
|
||||
jest.mock("preview-email", () => jest.fn());
|
||||
|
||||
describe.skip("forgotPassword mutation", () => {
|
||||
it("does not throw error if user doesn't exist", async () => {
|
||||
await expect(forgotPassword({ email: "no-user@email.com" }, {} as Ctx)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("works correctly", async () => {
|
||||
// Create test user
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
email: "user@example.com",
|
||||
tokens: {
|
||||
// Create old token to ensure it's deleted
|
||||
create: {
|
||||
type: "RESET_PASSWORD",
|
||||
hashedToken: "token",
|
||||
expiresAt: new Date(),
|
||||
sentTo: "user@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { tokens: true },
|
||||
});
|
||||
|
||||
// Invoke the mutation
|
||||
await forgotPassword({ email: user.email }, {} as Ctx);
|
||||
|
||||
const tokens = await db.token.findMany({ where: { userId: user.id } });
|
||||
const token = tokens[0];
|
||||
if (!user.tokens[0]) throw new Error("Missing user token");
|
||||
if (!token) throw new Error("Missing token");
|
||||
|
||||
// delete's existing tokens
|
||||
expect(tokens.length).toBe(1);
|
||||
|
||||
expect(token.id).not.toBe(user.tokens[0].id);
|
||||
expect(token.type).toBe("RESET_PASSWORD");
|
||||
expect(token.sentTo).toBe(user.email);
|
||||
expect(token.hashedToken).toBe(hash256(generatedToken));
|
||||
expect(token.expiresAt > new Date()).toBe(true);
|
||||
expect(previewEmail).toBeCalled();
|
||||
});
|
||||
});
|
@ -1,6 +1,6 @@
|
||||
import { resolver, SecurePassword, AuthenticationError } from "blitz";
|
||||
|
||||
import db, { GlobalRole } from "../../../db";
|
||||
import db from "../../../db";
|
||||
import { Login } from "../validations";
|
||||
|
||||
export const authenticateUser = async (rawEmail: string, rawPassword: string) => {
|
||||
|
@ -1,75 +0,0 @@
|
||||
import { hash256, SecurePassword } from "blitz";
|
||||
|
||||
import db from "../../../db";
|
||||
import resetPassword from "./reset-password";
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.$reset();
|
||||
});
|
||||
|
||||
const mockCtx: any = {
|
||||
session: {
|
||||
$create: jest.fn,
|
||||
},
|
||||
};
|
||||
|
||||
describe.skip("resetPassword mutation", () => {
|
||||
it("works correctly", async () => {
|
||||
expect(true).toBe(true);
|
||||
|
||||
// Create test user
|
||||
const goodToken = "randomPasswordResetToken";
|
||||
const expiredToken = "expiredRandomPasswordResetToken";
|
||||
const future = new Date();
|
||||
future.setHours(future.getHours() + 4);
|
||||
const past = new Date();
|
||||
past.setHours(past.getHours() - 4);
|
||||
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
email: "user@example.com",
|
||||
tokens: {
|
||||
// Create old token to ensure it's deleted
|
||||
create: [
|
||||
{
|
||||
type: "RESET_PASSWORD",
|
||||
hashedToken: hash256(expiredToken),
|
||||
expiresAt: past,
|
||||
sentTo: "user@example.com",
|
||||
},
|
||||
{
|
||||
type: "RESET_PASSWORD",
|
||||
hashedToken: hash256(goodToken),
|
||||
expiresAt: future,
|
||||
sentTo: "user@example.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: { tokens: true },
|
||||
});
|
||||
|
||||
const newPassword = "newPassword";
|
||||
|
||||
// Non-existent token
|
||||
await expect(
|
||||
resetPassword({ token: "no-token", password: "", passwordConfirmation: "" }, mockCtx),
|
||||
).rejects.toThrowError();
|
||||
|
||||
// Expired token
|
||||
await expect(
|
||||
resetPassword({ token: expiredToken, password: newPassword, passwordConfirmation: newPassword }, mockCtx),
|
||||
).rejects.toThrowError();
|
||||
|
||||
// Good token
|
||||
await resetPassword({ token: goodToken, password: newPassword, passwordConfirmation: newPassword }, mockCtx);
|
||||
|
||||
// Delete's the token
|
||||
const numberOfTokens = await db.token.count({ where: { userId: user.id } });
|
||||
expect(numberOfTokens).toBe(0);
|
||||
|
||||
// Updates user's password
|
||||
const updatedUser = await db.user.findFirst({ where: { id: user.id } });
|
||||
expect(await SecurePassword.verify(updatedUser!.hashedPassword, newPassword)).toBe(SecurePassword.VALID);
|
||||
});
|
||||
});
|
@ -27,7 +27,7 @@ const ForgotPasswordPage: BlitzPage = () => {
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await forgotPasswordMutation(values);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
return {
|
||||
[FORM_ERROR]: "Sorry, we had an unexpected error. Please try again.",
|
||||
};
|
||||
|
@ -19,7 +19,7 @@ const ResetPasswordPage: BlitzPage = () => {
|
||||
<div>
|
||||
<h2>Password Reset Successfully</h2>
|
||||
<p>
|
||||
Go to the <Link href={Routes.Home()}>homepage</Link>
|
||||
Go to the <Link href={Routes.LandingPage()}>homepage</Link>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -34,7 +34,7 @@ const ResetPasswordPage: BlitzPage = () => {
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await resetPasswordMutation(values);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.name === "ResetPasswordError") {
|
||||
return {
|
||||
[FORM_ERROR]: error.message,
|
||||
|
12
app/blog/components/avatar.tsx
Normal file
12
app/blog/components/avatar.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Avatar({ name, picture }: any) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-12 relative mr-4">
|
||||
<Image src={picture.url} layout="fill" className="rounded-full" alt={name} />
|
||||
</div>
|
||||
<div className="text-xl font-bold">{name}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
28
app/blog/components/cover-image.tsx
Normal file
28
app/blog/components/cover-image.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { Image } from "react-datocms";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function CoverImage({ title, responsiveImage, slug }: any) {
|
||||
const image = (
|
||||
<Image
|
||||
data={{
|
||||
...responsiveImage,
|
||||
alt: `Cover Image for ${title}`,
|
||||
}}
|
||||
className={clsx("shadow-small", {
|
||||
"hover:shadow-medium transition-shadow duration-200": slug,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="sm:mx-0">
|
||||
{slug ? (
|
||||
<Link href={`/posts/${slug}`}>
|
||||
<a aria-label={title}>{image}</a>
|
||||
</Link>
|
||||
) : (
|
||||
image
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
10
app/blog/components/date.tsx
Normal file
10
app/blog/components/date.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
const formatter = Intl.DateTimeFormat("en-US", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export default function DateComponent({ dateString }: any) {
|
||||
const date = new Date(dateString);
|
||||
return <time dateTime={dateString}>{formatter.format(date)}</time>;
|
||||
}
|
82
app/blog/components/more-stories.tsx
Normal file
82
app/blog/components/more-stories.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { Link, Routes } from "blitz";
|
||||
import PostPreview from "./post-preview";
|
||||
import type { Post } from "../../../integrations/datocms";
|
||||
|
||||
type Props = {
|
||||
posts: Post[];
|
||||
};
|
||||
|
||||
const formatter = Intl.DateTimeFormat("en-US", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export default function MoreStories({ posts }: Props) {
|
||||
return (
|
||||
<aside>
|
||||
<div className="relative max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="pb-12 md:pb-20">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h4 className="h4 font-mackinac mb-8">Related articles</h4>
|
||||
|
||||
{/* Articles container */}
|
||||
<div className="grid gap-4 sm:gap-6 sm:grid-cols-2">
|
||||
{posts.map((post) => (
|
||||
<article key={post.slug} className="relative group p-6 text-white">
|
||||
<figure>
|
||||
<img
|
||||
className="absolute inset-0 w-full h-full object-cover opacity-50 group-hover:opacity-75 transition duration-700 ease-out"
|
||||
src={post.coverImage.responsiveImage.src}
|
||||
width="372"
|
||||
height="182"
|
||||
alt="Related post"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 bg-primary-500 opacity-75 group-hover:opacity-50 transition duration-700 ease-out"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</figure>
|
||||
<div className="relative flex flex-col h-full">
|
||||
<header className="flex-grow">
|
||||
<Link href={Routes.PostPage({ slug: post.slug })}>
|
||||
<a className="hover:underline">
|
||||
<h3 className="text-lg font-mackinac font-bold tracking-tight mb-2">
|
||||
{post.title}
|
||||
</h3>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="text-sm opacity-80">
|
||||
{formatter.format(new Date(post.date))}
|
||||
</div>
|
||||
</header>
|
||||
<footer>
|
||||
{/* Author meta */}
|
||||
<div className="flex items-center text-sm mt-5">
|
||||
<a href="#0">
|
||||
<img
|
||||
className="rounded-full flex-shrink-0 mr-3"
|
||||
src={post.author.picture.url}
|
||||
width="32"
|
||||
height="32"
|
||||
alt={post.author.name}
|
||||
/>
|
||||
</a>
|
||||
<div>
|
||||
<span className="opacity-75">By </span>
|
||||
<span className="font-medium hover:underline">
|
||||
{post.author.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
16
app/blog/components/post-body.tsx
Normal file
16
app/blog/components/post-body.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import styles from "../styles/post-body.module.css";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
export default function PostBody({ content }: Props) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div
|
||||
className={`prose prose-lg prose-blue ${styles.markdown}`}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
25
app/blog/components/post-preview.tsx
Normal file
25
app/blog/components/post-preview.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import Avatar from "./avatar";
|
||||
import Date from "./date";
|
||||
import CoverImage from "./cover-image";
|
||||
|
||||
export default function PostPreview({ title, coverImage, date, excerpt, author, slug }: any) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5">
|
||||
<CoverImage slug={slug} title={title} responsiveImage={coverImage.responsiveImage} />
|
||||
</div>
|
||||
<h3 className="text-3xl mb-3 leading-snug">
|
||||
<Link href={`/posts/${slug}`}>
|
||||
<a className="hover:underline">{title}</a>
|
||||
</Link>
|
||||
</h3>
|
||||
<div className="text-lg mb-4">
|
||||
<Date dateString={date} />
|
||||
</div>
|
||||
<p className="text-lg leading-relaxed mb-4">{excerpt}</p>
|
||||
<Avatar name={author.name} picture={author.picture} />
|
||||
</div>
|
||||
);
|
||||
}
|
3
app/blog/components/section-separator.tsx
Normal file
3
app/blog/components/section-separator.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function SectionSeparator() {
|
||||
return <hr className="border-accent-2 mt-28 mb-24" />;
|
||||
}
|
@ -1,8 +1,13 @@
|
||||
import { BlitzPage, GetStaticPaths, GetStaticProps, Head, useRouter } from "blitz";
|
||||
import type { BlitzPage, GetStaticPaths, GetStaticProps } from "blitz";
|
||||
import { Head, useRouter } from "blitz";
|
||||
import ErrorPage from "next/error";
|
||||
|
||||
import type { Post } from "integrations/datocms";
|
||||
import { getAllPostsWithSlug, getPostAndMorePosts } from "integrations/datocms";
|
||||
import { getAllPostsWithSlug, getPostAndMorePosts, markdownToHtml } from "integrations/datocms";
|
||||
import Header from "../../../public-area/components/header";
|
||||
import PostBody from "../../components/post-body";
|
||||
import SectionSeparator from "../../components/section-separator";
|
||||
import MoreStories from "../../components/more-stories";
|
||||
|
||||
type Props = {
|
||||
post: Post;
|
||||
@ -10,15 +15,96 @@ type Props = {
|
||||
preview: boolean;
|
||||
};
|
||||
|
||||
const formatter = Intl.DateTimeFormat("en-US", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const PostPage: BlitzPage<Props> = ({ post, morePosts, preview }) => {
|
||||
const router = useRouter();
|
||||
if (!router.isFallback && !post?.slug) {
|
||||
return <ErrorPage statusCode={404} />;
|
||||
}
|
||||
|
||||
console.log("post", post);
|
||||
|
||||
// TODO
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen overflow-hidden">
|
||||
<Header />
|
||||
|
||||
<main className="flex-grow">
|
||||
<section className="relative">
|
||||
{/* Background image */}
|
||||
<div className="absolute inset-0 h-128 pt-16 box-content">
|
||||
<img
|
||||
className="absolute inset-0 w-full h-full object-cover opacity-25"
|
||||
src={post.coverImage.responsiveImage.src}
|
||||
width={post.coverImage.responsiveImage.width}
|
||||
height={post.coverImage.responsiveImage.height}
|
||||
alt={post.coverImage.responsiveImage.alt ?? `${post.title} cover image`}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-t from-white dark:from-gray-900"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="pt-32 pb-12 md:pt-40 md:pb-20">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<article>
|
||||
{/* Article header */}
|
||||
<header className="mb-8">
|
||||
{/* Title and excerpt */}
|
||||
<div className="text-center md:text-left">
|
||||
<h1 className="h1 font-mackinac mb-4">{post.title}</h1>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-400">{post.excerpt}</p>
|
||||
</div>
|
||||
{/* Article meta */}
|
||||
<div className="md:flex md:items-center md:justify-between mt-5">
|
||||
{/* Author meta */}
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
className="rounded-full flex-shrink-0 mr-3"
|
||||
src={post.author.picture.url}
|
||||
width="32"
|
||||
height="32"
|
||||
alt="Author 04"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-gray-600 dark:text-gray-400">By </span>
|
||||
<a
|
||||
className="font-medium text-gray-800 dark:text-gray-300 hover:underline"
|
||||
href="#0"
|
||||
>
|
||||
{post.author.name}
|
||||
</a>
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{" "}
|
||||
· {formatter.format(new Date(post.date))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<hr className="w-5 h-px pt-px bg-gray-400 dark:bg-gray-500 border-0 mb-8" />
|
||||
|
||||
{/* Article content */}
|
||||
<div className="text-lg text-gray-600 dark:text-gray-400">
|
||||
<PostBody content={post.content} />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<SectionSeparator />
|
||||
{morePosts.length > 0 && <MoreStories posts={morePosts} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
/*return (
|
||||
<Layout preview={preview}>
|
||||
<Container>
|
||||
@ -49,8 +135,6 @@ const PostPage: BlitzPage<Props> = ({ post, morePosts, preview }) => {
|
||||
</Container>
|
||||
</Layout>
|
||||
);*/
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default PostPage;
|
||||
@ -63,7 +147,7 @@ export const getStaticProps: GetStaticProps = async ({ params, preview = false }
|
||||
}
|
||||
|
||||
const data = await getPostAndMorePosts(params.slug, preview);
|
||||
const content = /*await markdownToHtml(data.post.content || "");*/ "";
|
||||
const content = await markdownToHtml(data.post.content || "");
|
||||
|
||||
return {
|
||||
props: {
|
||||
|
12
app/blog/pages/blog.tsx
Normal file
12
app/blog/pages/blog.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import type { BlitzPage } from "blitz";
|
||||
|
||||
import Layout from "../../public-area/components/layout";
|
||||
|
||||
const Blog: BlitzPage = () => {
|
||||
return <article className="m-auto">Coming soon.</article>;
|
||||
};
|
||||
|
||||
Blog.getLayout = (page) => <Layout title="Blog">{page}</Layout>;
|
||||
Blog.suppressFirstRenderFlicker = true;
|
||||
|
||||
export default Blog;
|
18
app/blog/styles/post-body.module.css
Normal file
18
app/blog/styles/post-body.module.css
Normal file
@ -0,0 +1,18 @@
|
||||
.markdown {
|
||||
@apply text-lg leading-relaxed;
|
||||
}
|
||||
|
||||
.markdown p,
|
||||
.markdown ul,
|
||||
.markdown ol,
|
||||
.markdown blockquote {
|
||||
@apply my-6;
|
||||
}
|
||||
|
||||
.markdown h2 {
|
||||
@apply text-3xl mt-12 mb-4 leading-snug;
|
||||
}
|
||||
|
||||
.markdown h3 {
|
||||
@apply text-2xl mt-8 mb-4 leading-snug;
|
||||
}
|
21
app/core/hooks/use-panelbear.ts
Normal file
21
app/core/hooks/use-panelbear.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import * as Panelbear from "@panelbear/panelbear-js";
|
||||
import type { PanelbearConfig } from "@panelbear/panelbear-js";
|
||||
|
||||
export const usePanelbear = (siteId?: string, config: PanelbearConfig = {}) => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
Panelbear.load(siteId, { scriptSrc: "/bear.js", ...config });
|
||||
Panelbear.trackPageview();
|
||||
const handleRouteChange = () => Panelbear.trackPageview();
|
||||
router.events.on("routeChangeComplete", handleRouteChange);
|
||||
|
||||
return () => router.events.off("routeChangeComplete", handleRouteChange);
|
||||
}, [siteId]);
|
||||
};
|
@ -45,7 +45,7 @@ export default resolver.pipe(
|
||||
keys_auth: subscription.keys.auth,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.code !== "P2002") {
|
||||
logger.error(error);
|
||||
// we might want to `throw error`;
|
||||
|
@ -1,3 +1,140 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter var";
|
||||
font-weight: 100 900;
|
||||
font-display: optional;
|
||||
font-style: normal;
|
||||
font-named-instance: "Regular";
|
||||
src: url("/fonts/inter-roman.var.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter var";
|
||||
font-weight: 100 900;
|
||||
font-display: optional;
|
||||
font-style: italic;
|
||||
font-named-instance: "Italic";
|
||||
src: url("/fonts/inter-italic.var.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "P22 Mackinac Pro";
|
||||
src: url("/fonts/P22MackinacPro-Book.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: optional;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "P22 Mackinac Pro";
|
||||
src: url("/fonts/P22MackinacPro-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: optional;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "P22 Mackinac Pro";
|
||||
src: url("/fonts/P22MackinacPro-ExtraBold.woff2") format("woff2");
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: optional;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "P22 Mackinac Pro";
|
||||
src: url("/fonts/P22MackinacPro-Medium.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: optional;
|
||||
}
|
||||
|
||||
.h1 {
|
||||
@apply text-4xl font-extrabold tracking-tighter;
|
||||
}
|
||||
|
||||
.h2 {
|
||||
@apply text-3xl font-extrabold tracking-tighter;
|
||||
}
|
||||
|
||||
.h3 {
|
||||
@apply text-3xl font-extrabold;
|
||||
}
|
||||
|
||||
.h4 {
|
||||
@apply text-2xl font-extrabold tracking-tight;
|
||||
}
|
||||
|
||||
@screen md {
|
||||
.h1 {
|
||||
@apply text-5xl;
|
||||
}
|
||||
|
||||
.h2 {
|
||||
@apply text-4xl;
|
||||
}
|
||||
}
|
||||
|
||||
.btn,
|
||||
.btn-sm {
|
||||
@apply font-medium inline-flex items-center justify-center border border-transparent rounded leading-snug transition duration-150 ease-in-out;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-8 py-3;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply px-4 py-2;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-multiselect,
|
||||
.form-select,
|
||||
.form-checkbox,
|
||||
.form-radio {
|
||||
@apply bg-white border border-gray-300 focus:border-gray-400;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-multiselect,
|
||||
.form-select,
|
||||
.form-checkbox {
|
||||
@apply rounded;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-multiselect,
|
||||
.form-select {
|
||||
@apply leading-snug py-3 px-4;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
@apply placeholder-gray-500;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
@apply pr-10;
|
||||
}
|
||||
|
||||
.form-checkbox,
|
||||
.form-radio {
|
||||
@apply text-primary-600;
|
||||
}
|
||||
|
||||
/* Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
@ -23,8 +23,8 @@ const insertMessagesQueue = Queue<Payload>(
|
||||
|
||||
const sms = messages
|
||||
.map<Message>((message) => ({
|
||||
organizationId,
|
||||
id: message.sid,
|
||||
organizationId,
|
||||
phoneNumberId: phoneNumber.id,
|
||||
content: encrypt(message.body, phoneNumber.organization.encryptionKey),
|
||||
from: message.from,
|
||||
|
@ -46,7 +46,7 @@ const notifyIncomingMessageQueue = Queue<Payload>(
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(webPushSubscription, JSON.stringify(notification));
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(error);
|
||||
if (error instanceof WebPushError) {
|
||||
// subscription most likely expired or has been revoked
|
||||
|
@ -34,7 +34,7 @@ const sendMessageQueue = Queue<Payload>(
|
||||
where: { organizationId_phoneNumberId_id: { id, organizationId, phoneNumberId } },
|
||||
data: { id: message.sid },
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
// TODO: handle twilio error
|
||||
console.log(error.code); // 21211
|
||||
console.log(error.moreInfo); // https://www.twilio.com/docs/errors/21211
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { testApiHandler } from "next-test-api-route-handler";
|
||||
import twilio from "twilio";
|
||||
|
||||
import { testApiHandler } from "../../../../test/test-api-handler";
|
||||
import db from "db";
|
||||
import handler from "./incoming-message";
|
||||
import insertIncomingMessageQueue from "../queue/insert-incoming-message";
|
||||
|
@ -2,11 +2,15 @@ import type { BlitzApiRequest, BlitzApiResponse } from "blitz";
|
||||
import { getConfig } from "blitz";
|
||||
import twilio from "twilio";
|
||||
|
||||
import type { ApiError } from "../../../_types";
|
||||
import appLogger from "../../../../integrations/logger";
|
||||
import db from "../../../../db";
|
||||
import insertIncomingMessageQueue from "../queue/insert-incoming-message";
|
||||
|
||||
type ApiError = {
|
||||
statusCode: number;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
const logger = appLogger.child({ route: "/api/webhook/incoming-message" });
|
||||
const { serverRuntimeConfig } = getConfig();
|
||||
|
||||
@ -83,7 +87,7 @@ export default async function incomingMessageHandler(req: BlitzApiRequest, res:
|
||||
|
||||
res.setHeader("content-type", "text/html");
|
||||
res.status(200).send("<Response></Response>");
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const statusCode = error.statusCode ?? 500;
|
||||
const apiError: ApiError = {
|
||||
statusCode,
|
||||
|
@ -10,21 +10,22 @@ export default function Conversation() {
|
||||
const router = useRouter();
|
||||
const recipient = decodeURIComponent(router.params.recipient);
|
||||
const conversation = useConversation(recipient)[0];
|
||||
const messages = conversation?.messages ?? [];
|
||||
const messagesListRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesListRef.current?.querySelector("li:last-child")?.scrollIntoView();
|
||||
}, [conversation, messagesListRef]);
|
||||
}, [messages, messagesListRef]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col space-y-6 p-6 pt-12 pb-16">
|
||||
<ul ref={messagesListRef}>
|
||||
{conversation.length === 0 ? "empty state" : null}
|
||||
{conversation.map((message, index) => {
|
||||
{messages.length === 0 ? "empty state" : null}
|
||||
{messages.map((message, index) => {
|
||||
const isOutbound = message.direction === Direction.Outbound;
|
||||
const nextMessage = conversation![index + 1];
|
||||
const previousMessage = conversation![index - 1];
|
||||
const nextMessage = messages![index + 1];
|
||||
const previousMessage = messages![index - 1];
|
||||
const isNextMessageFromSameSender = message.from === nextMessage?.from;
|
||||
const isPreviousMessageFromSameSender = message.from === previousMessage?.from;
|
||||
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { Link, useQuery, Routes } from "blitz";
|
||||
import { DateTime } from "luxon";
|
||||
import { faChevronRight } from "@fortawesome/pro-regular-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import getConversationsQuery from "../queries/get-conversations";
|
||||
|
||||
@ -11,21 +14,20 @@ export default function ConversationsList() {
|
||||
|
||||
return (
|
||||
<ul className="divide-y">
|
||||
{Object.entries(conversations).map(([recipient, messages]) => {
|
||||
{Object.values(conversations).map(({ recipient, formattedPhoneNumber, messages }) => {
|
||||
const lastMessage = messages[messages.length - 1]!;
|
||||
return (
|
||||
<li key={recipient} className="py-2">
|
||||
<Link
|
||||
href={Routes.ConversationPage({
|
||||
recipient: encodeURI(recipient),
|
||||
})}
|
||||
>
|
||||
<li key={recipient} className="py-2 p-4">
|
||||
<Link href={Routes.ConversationPage({ recipient })}>
|
||||
<a className="flex flex-col">
|
||||
<div className="flex flex-row justify-between">
|
||||
<strong>{recipient}</strong>
|
||||
<div>{new Date(lastMessage.sentAt).toLocaleString("fr-FR")}</div>
|
||||
<strong>{formattedPhoneNumber}</strong>
|
||||
<div className="text-gray-700 flex flex-row gap-x-1">
|
||||
{formatMessageDate(lastMessage.sentAt)}
|
||||
<FontAwesomeIcon className="w-4 h-4 my-auto" icon={faChevronRight} />
|
||||
</div>
|
||||
</div>
|
||||
<div>{lastMessage.content}</div>
|
||||
<div className="line-clamp-2 text-gray-700">{lastMessage.content}</div>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
@ -34,3 +36,20 @@ export default function ConversationsList() {
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function formatMessageDate(date: Date): string {
|
||||
const messageDate = DateTime.fromJSDate(date);
|
||||
const diff = messageDate.diffNow("days");
|
||||
|
||||
const isToday = diff.days > -1;
|
||||
if (isToday) {
|
||||
return messageDate.toFormat("HH:mm", { locale: "fr-FR" });
|
||||
}
|
||||
|
||||
const isDuringLastWeek = diff.days > -8;
|
||||
if (isDuringLastWeek) {
|
||||
return messageDate.weekdayLong;
|
||||
}
|
||||
|
||||
return messageDate.toFormat("dd/MM/yyyy", { locale: "fr-FR" });
|
||||
}
|
||||
|
@ -59,14 +59,20 @@ const NewMessageArea: FunctionComponent<Props> = ({ recipient, onSend }) => {
|
||||
(conversations) => {
|
||||
const nextConversations = { ...conversations };
|
||||
if (!nextConversations[recipient]) {
|
||||
nextConversations[recipient] = [];
|
||||
nextConversations[recipient] = {
|
||||
recipient,
|
||||
formattedPhoneNumber: recipient,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
nextConversations[recipient] = [...nextConversations[recipient]!, message];
|
||||
nextConversations[recipient]!.messages = [...nextConversations[recipient]!.messages, message];
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(nextConversations).sort(
|
||||
([, a], [, b]) => b[b.length - 1]!.sentAt.getTime() - a[a.length - 1]!.sentAt.getTime(),
|
||||
([, a], [, b]) =>
|
||||
b.messages[b.messages.length - 1]!.sentAt.getTime() -
|
||||
a.messages[a.messages.length - 1]!.sentAt.getTime(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -9,7 +9,7 @@ export default function useConversation(recipient: string) {
|
||||
{
|
||||
select(conversations) {
|
||||
if (!conversations[recipient]) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
return conversations[recipient]!;
|
||||
|
@ -27,7 +27,7 @@ export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({
|
||||
const twilioClient = getTwilioClient(organization);
|
||||
try {
|
||||
await twilioClient.lookups.v1.phoneNumbers(to).fetch();
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(error);
|
||||
return;
|
||||
}
|
||||
|
@ -7,12 +7,14 @@ import { faLongArrowLeft, faInfoCircle, faPhoneAlt as faPhone } from "@fortaweso
|
||||
import Layout from "../../../core/layouts/layout";
|
||||
import Conversation from "../../components/conversation";
|
||||
import useRequireOnboarding from "../../../core/hooks/use-require-onboarding";
|
||||
import useConversation from "../../hooks/use-conversation";
|
||||
|
||||
const ConversationPage: BlitzPage = () => {
|
||||
useRequireOnboarding();
|
||||
|
||||
const router = useRouter();
|
||||
const recipient = decodeURIComponent(router.params.recipient);
|
||||
const conversation = useConversation(recipient)[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -20,7 +22,7 @@ const ConversationPage: BlitzPage = () => {
|
||||
<span className="col-start-1 col-span-1 pl-2 cursor-pointer" onClick={router.back}>
|
||||
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faLongArrowLeft} />
|
||||
</span>
|
||||
<strong className="col-span-1 text-center">{recipient}</strong>
|
||||
<strong className="col-span-1">{conversation?.formattedPhoneNumber ?? recipient}</strong>
|
||||
<span className="col-span-1 flex justify-end space-x-4 pr-2">
|
||||
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faPhone} />
|
||||
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faInfoCircle} />
|
||||
|
@ -1,10 +1,17 @@
|
||||
import { resolver, NotFoundError } from "blitz";
|
||||
import { z } from "zod";
|
||||
import PhoneNumber from "awesome-phonenumber";
|
||||
|
||||
import db, { Direction, Message, Prisma } from "../../../db";
|
||||
import { decrypt } from "../../../db/_encryption";
|
||||
import { enforceSuperAdminIfNotCurrentOrganization, setDefaultOrganizationId } from "../../core/utils";
|
||||
|
||||
type Conversation = {
|
||||
recipient: string;
|
||||
formattedPhoneNumber: string;
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
export default resolver.pipe(
|
||||
resolver.zod(z.object({ organizationId: z.string().optional() })),
|
||||
resolver.authorize(),
|
||||
@ -25,7 +32,7 @@ export default resolver.pipe(
|
||||
orderBy: { sentAt: Prisma.SortOrder.desc },
|
||||
});
|
||||
|
||||
let conversations: Record<string, Message[]> = {};
|
||||
let conversations: Record<string, Conversation> = {};
|
||||
for (const message of messages) {
|
||||
let recipient: string;
|
||||
if (message.direction === Direction.Outbound) {
|
||||
@ -33,21 +40,28 @@ export default resolver.pipe(
|
||||
} else {
|
||||
recipient = message.from;
|
||||
}
|
||||
const formattedPhoneNumber = new PhoneNumber(recipient).getNumber("international");
|
||||
|
||||
if (!conversations[recipient]) {
|
||||
conversations[recipient] = [];
|
||||
conversations[recipient] = {
|
||||
recipient,
|
||||
formattedPhoneNumber,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
conversations[recipient]!.push({
|
||||
conversations[recipient]!.messages.push({
|
||||
...message,
|
||||
content: decrypt(message.content, organization.encryptionKey),
|
||||
});
|
||||
|
||||
conversations[recipient]!.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
|
||||
conversations[recipient]!.messages.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
|
||||
}
|
||||
conversations = Object.fromEntries(
|
||||
Object.entries(conversations).sort(
|
||||
([, a], [, b]) => b[b.length - 1]!.sentAt.getTime() - a[a.length - 1]!.sentAt.getTime(),
|
||||
([, a], [, b]) =>
|
||||
b.messages[b.messages.length - 1]!.sentAt.getTime() -
|
||||
a.messages[a.messages.length - 1]!.sentAt.getTime(),
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -52,13 +52,13 @@ async function getTwimlApplication(
|
||||
}
|
||||
|
||||
const twimlApps = await twilioClient.applications.list();
|
||||
const twimlApp = twimlApps.find((app) => app.friendlyName === "Shellphone");
|
||||
const twimlApp = twimlApps.find((app) => app.friendlyName.startsWith("Shellphone"));
|
||||
if (twimlApp) {
|
||||
return updateTwimlApplication(twilioClient, twimlApp.sid);
|
||||
}
|
||||
|
||||
return twilioClient.applications.create({
|
||||
friendlyName: "Shellphone",
|
||||
friendlyName: getTwiMLName(),
|
||||
smsUrl: `https://${serverRuntimeConfig.app.baseUrl}/api/webhook/incoming-message`,
|
||||
smsMethod: "POST",
|
||||
voiceUrl: `https://${serverRuntimeConfig.app.baseUrl}/api/webhook/call`,
|
||||
@ -77,4 +77,15 @@ async function updateTwimlApplication(twilioClient: twilio.Twilio, twimlAppSid:
|
||||
return twilioClient.applications.get(twimlAppSid).fetch();
|
||||
}
|
||||
|
||||
function getTwiMLName() {
|
||||
switch (serverRuntimeConfig.app.baseUrl) {
|
||||
case "local.shellphone.app":
|
||||
return "Shellphone LOCAL";
|
||||
case "dev.shellphone.app":
|
||||
return "Shellphone DEV";
|
||||
case "www.shellphone.app":
|
||||
return "Shellphone";
|
||||
}
|
||||
}
|
||||
|
||||
export default setTwilioWebhooks;
|
||||
|
@ -30,6 +30,13 @@ const HelpModal: FunctionComponent<Props> = ({ isHelpModalOpen, closeModal }) =>
|
||||
</a>{" "}
|
||||
and we will help you get started!
|
||||
</p>
|
||||
<p>
|
||||
Don't miss out on free $10 Twilio credit by using{" "}
|
||||
<a className="underline" href="https://www.twilio.com/referral/gNvX8p">
|
||||
our referral link
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -32,7 +32,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
|
||||
await session.$revoke();
|
||||
return {
|
||||
redirect: {
|
||||
destination: Routes.Home().pathname,
|
||||
destination: Routes.LandingPage().pathname,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
@ -94,7 +94,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async ({ req, res }
|
||||
await session.$revoke();
|
||||
return {
|
||||
redirect: {
|
||||
destination: Routes.Home().pathname,
|
||||
destination: Routes.LandingPage().pathname,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
@ -133,7 +133,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
|
||||
await session.$revoke();
|
||||
return {
|
||||
redirect: {
|
||||
destination: Routes.Home().pathname,
|
||||
destination: Routes.LandingPage().pathname,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
|
@ -7,13 +7,19 @@ import {
|
||||
AuthorizationError,
|
||||
ErrorFallbackProps,
|
||||
useQueryErrorResetBoundary,
|
||||
getConfig,
|
||||
} from "blitz";
|
||||
|
||||
import LoginForm from "../auth/components/login-form";
|
||||
import { usePanelbear } from "../core/hooks/use-panelbear";
|
||||
|
||||
import "app/core/styles/index.css";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
usePanelbear(publicRuntimeConfig.panelBear.siteId);
|
||||
|
||||
const getLayout = Component.getLayout || ((page) => page);
|
||||
|
||||
return (
|
||||
|
@ -23,6 +23,23 @@ class MyDocument extends Document {
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="msapplication-starturl" content="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#663399" />
|
||||
<meta name="apple-mobile-web-app-title" content="Shellphone: Your Personal Cloud Phone" />
|
||||
<meta name="application-name" content="Shellphone: Your Personal Cloud Phone" />
|
||||
<meta name="msapplication-TileColor" content="#663399" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/inter-roman.var.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
|
@ -1,39 +0,0 @@
|
||||
import { GlobalRole } from "db";
|
||||
import { render } from "../../test/utils";
|
||||
import Home from "./index";
|
||||
import useCurrentUser from "../core/hooks/use-current-user";
|
||||
|
||||
jest.mock("../core/hooks/use-current-user");
|
||||
const mockUseCurrentUser = useCurrentUser as jest.MockedFunction<typeof useCurrentUser>;
|
||||
|
||||
test.skip("renders blitz documentation link", () => {
|
||||
// This is an example of how to ensure a specific item is in the document
|
||||
// But it's disabled by default (by test.skip) so the test doesn't fail
|
||||
// when you remove the the default content from the page
|
||||
|
||||
// This is an example on how to mock api hooks when testing
|
||||
mockUseCurrentUser.mockReturnValue({
|
||||
organization: undefined,
|
||||
user: {
|
||||
id: uuidv4(),
|
||||
name: "name",
|
||||
email: "email@test.com",
|
||||
role: GlobalRole.CUSTOMER,
|
||||
memberships: [],
|
||||
},
|
||||
hasFilledTwilioCredentials: false,
|
||||
hasCompletedOnboarding: undefined,
|
||||
});
|
||||
|
||||
const { getByText } = render(<Home />);
|
||||
const linkElement = getByText(/Documentation/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
function uuidv4() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0,
|
||||
v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
@ -1,274 +0,0 @@
|
||||
import { Suspense } from "react";
|
||||
import type { BlitzPage } from "blitz";
|
||||
import { Link, useMutation, Routes } from "blitz";
|
||||
|
||||
import BaseLayout from "../core/layouts/base-layout";
|
||||
import logout from "../auth/mutations/logout";
|
||||
import useCurrentUser from "../core/hooks/use-current-user";
|
||||
|
||||
/*
|
||||
* This file is just for a pleasant getting started page for your new app.
|
||||
* You can delete everything in here and start from scratch if you like.
|
||||
*/
|
||||
|
||||
const UserInfo = () => {
|
||||
const { user } = useCurrentUser();
|
||||
const [logoutMutation] = useMutation(logout);
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="button small"
|
||||
onClick={async () => {
|
||||
await logoutMutation();
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
<div>
|
||||
User id: <code>{user.id}</code>
|
||||
<br />
|
||||
User role: <code>{user.role}</code>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<Link href={Routes.SignUp()}>
|
||||
<a className="button small">
|
||||
<strong>Sign Up</strong>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={Routes.SignIn()}>
|
||||
<a className="button small">
|
||||
<strong>Login</strong>
|
||||
</a>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const Home: BlitzPage = () => {
|
||||
return (
|
||||
<div className="container">
|
||||
<main>
|
||||
<div className="logo">
|
||||
<img src="/logo.png" alt="blitz.js" />
|
||||
</div>
|
||||
<p>
|
||||
<strong>Congrats!</strong> Your app is ready, including user sign-up and log-in.
|
||||
</p>
|
||||
<div className="buttons" style={{ marginTop: "1rem", marginBottom: "1rem" }}>
|
||||
<Suspense fallback="Loading...">
|
||||
<UserInfo />
|
||||
</Suspense>
|
||||
</div>
|
||||
<p>
|
||||
<strong>
|
||||
To add a new model to your app, <br />
|
||||
run the following in your terminal:
|
||||
</strong>
|
||||
</p>
|
||||
<pre>
|
||||
<code>blitz generate all project name:string</code>
|
||||
</pre>
|
||||
<div style={{ marginBottom: "1rem" }}>(And select Yes to run prisma migrate)</div>
|
||||
<div>
|
||||
<p>
|
||||
Then <strong>restart the server</strong>
|
||||
</p>
|
||||
<pre>
|
||||
<code>Ctrl + c</code>
|
||||
</pre>
|
||||
<pre>
|
||||
<code>blitz dev</code>
|
||||
</pre>
|
||||
<p>
|
||||
and go to{" "}
|
||||
<Link href="/projects">
|
||||
<a>/projects</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="buttons" style={{ marginTop: "5rem" }}>
|
||||
<a
|
||||
className="button"
|
||||
href="https://blitzjs.com/docs/getting-started?utm_source=blitz-new&utm_medium=app-template&utm_campaign=blitz-new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
<a
|
||||
className="button-outline"
|
||||
href="https://github.com/blitz-js/blitz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Github Repo
|
||||
</a>
|
||||
<a
|
||||
className="button-outline"
|
||||
href="https://discord.blitzjs.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Discord Community
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<a
|
||||
href="https://blitzjs.com?utm_source=blitz-new&utm_medium=app-template&utm_campaign=blitz-new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Powered by Blitz.js
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
<style jsx global>{`
|
||||
@import url("https://fonts.googleapis.com/css2?family=Libre+Franklin:wght@300;700&display=swap");
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: "Libre Franklin", -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 5rem 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
main p {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
p {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
border-top: 1px solid #eaeaea;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #45009d;
|
||||
}
|
||||
|
||||
footer a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #f4f4f4;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-gap: 0.5rem;
|
||||
}
|
||||
.button {
|
||||
font-size: 1rem;
|
||||
background-color: #6700eb;
|
||||
padding: 1rem 2rem;
|
||||
color: #f4f4f4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button.small {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: #45009d;
|
||||
}
|
||||
|
||||
.button-outline {
|
||||
border: 2px solid #6700eb;
|
||||
padding: 1rem 2rem;
|
||||
color: #6700eb;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button-outline:hover {
|
||||
border-color: #45009d;
|
||||
color: #45009d;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #fafafa;
|
||||
border-radius: 5px;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
code {
|
||||
font-size: 0.9rem;
|
||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
max-width: 800px;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.grid {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Home.suppressFirstRenderFlicker = true;
|
||||
|
||||
Home.getLayout = (page) => <BaseLayout title="Home">{page}</BaseLayout>;
|
||||
|
||||
export default Home;
|
@ -3,13 +3,17 @@ import { getConfig } from "blitz";
|
||||
import twilio from "twilio";
|
||||
import type { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
|
||||
|
||||
import type { ApiError } from "../../../_types";
|
||||
import db, { CallStatus, Direction } from "../../../../db";
|
||||
import appLogger from "../../../../integrations/logger";
|
||||
|
||||
const { serverRuntimeConfig } = getConfig();
|
||||
const logger = appLogger.child({ route: "/api/webhook/call" });
|
||||
|
||||
type ApiError = {
|
||||
statusCode: number;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export default async function incomingCallHandler(req: BlitzApiRequest, res: BlitzApiResponse) {
|
||||
console.log("req.body", req.body);
|
||||
|
||||
|
32
app/public-area/components/base-layout.tsx
Normal file
32
app/public-area/components/base-layout.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { Head } from "blitz";
|
||||
|
||||
import Header from "./header";
|
||||
import Footer from "./footer";
|
||||
|
||||
const BaseLayout: FunctionComponent = ({ children }) => (
|
||||
<>
|
||||
<Head>
|
||||
<title>Shellphone: Your Personal Cloud Phone</title>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/P22MackinacPro-ExtraBold.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<section className="font-inter antialiased bg-white text-gray-900 tracking-tight">
|
||||
<section className="flex flex-col min-h-screen overflow-hidden">
|
||||
<Header />
|
||||
|
||||
<main className="flex-grow">{children}</main>
|
||||
|
||||
<Footer />
|
||||
</section>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
export default BaseLayout;
|
11
app/public-area/components/checkmark.tsx
Normal file
11
app/public-area/components/checkmark.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
export default function Checkmark() {
|
||||
return (
|
||||
<svg
|
||||
className="w-3 h-3 fill-current text-primary-400 mr-2 flex-shrink-0"
|
||||
viewBox="0 0 12 12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M10.28 2.28L3.989 8.575 1.695 6.28A1 1 0 00.28 7.695l3 3a1 1 0 001.414 0l7-7A1 1 0 0010.28 2.28z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
48
app/public-area/components/cta-form.tsx
Normal file
48
app/public-area/components/cta-form.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { useMutation } from "blitz";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as Panelbear from "@panelbear/panelbear-js";
|
||||
|
||||
import joinWaitlist from "../mutations/join-waitlist";
|
||||
|
||||
type Form = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export default function CTAForm() {
|
||||
const [joinWaitlistMutation] = useMutation(joinWaitlist);
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { isSubmitted },
|
||||
} = useForm<Form>();
|
||||
const onSubmit = handleSubmit(async ({ email }) => {
|
||||
if (isSubmitted) {
|
||||
return;
|
||||
}
|
||||
|
||||
Panelbear.track("join-waitlist");
|
||||
return joinWaitlistMutation({ email });
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="mt-8">
|
||||
{isSubmitted ? (
|
||||
<p className="text-center md:text-left mt-2 opacity-75 text-green-900 text-md">
|
||||
You're on the list! We will be in touch soon
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row justify-center max-w-sm mx-auto sm:max-w-md md:mx-0">
|
||||
<input
|
||||
{...register("email")}
|
||||
type="email"
|
||||
className="form-input w-full mb-2 sm:mb-0 sm:mr-2 focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Enter your email address"
|
||||
/>
|
||||
<button type="submit" className="btn text-white bg-primary-500 hover:bg-primary-400 flex-shrink-0">
|
||||
Request access
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
86
app/public-area/components/faqs.tsx
Normal file
86
app/public-area/components/faqs.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export default function FAQs() {
|
||||
return (
|
||||
<section className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="py-12 md:py-20 border-t border-gray-200">
|
||||
<div className="max-w-3xl mx-auto text-center pb-20">
|
||||
<h2 className="h2 font-mackinac">Questions & Answers</h2>
|
||||
</div>
|
||||
|
||||
<ul className="max-w-3xl mx-auto pl-12">
|
||||
<Accordion title="How does it work?">
|
||||
Shellphone is your go-to app to use your phone number over the internet. It integrates
|
||||
seamlessly with Twilio to provide the best experience for your personal cloud phone.
|
||||
</Accordion>
|
||||
<Accordion title="What do I need to use Shellphone?">
|
||||
Shellphone is still in its early stages and we're working hard to make it as easy-to-use as
|
||||
possible. Currently, you must have a Twilio account to set up your personal cloud phone with
|
||||
Shellphone.
|
||||
</Accordion>
|
||||
<Accordion title="Why would I use this over an eSIM?">
|
||||
Chances are you're currently using an eSIM-compatible device. eSIMs are a reasonable way of
|
||||
using a phone number internationally but they are still subject to some irky limitations. For
|
||||
example, you can only use an eSIM on one device at a time and you are still subject to
|
||||
exorbitant rates from your carrier.
|
||||
</Accordion>
|
||||
<span className="block border-t border-gray-200" aria-hidden="true" />
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const Accordion: FunctionComponent<{ title: string }> = ({ title, children }) => {
|
||||
return (
|
||||
<Disclosure>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button className="flex items-center w-full text-lg font-medium text-left py-5 border-t border-gray-200">
|
||||
<svg
|
||||
className="w-4 h-4 fill-current text-blue-500 flex-shrink-0 mr-8 -ml-12"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
y="7"
|
||||
width="16"
|
||||
height="2"
|
||||
rx="1"
|
||||
className={clsx("transform origin-center transition duration-200 ease-out", {
|
||||
"rotate-180": open,
|
||||
})}
|
||||
/>
|
||||
<rect
|
||||
y="7"
|
||||
width="16"
|
||||
height="2"
|
||||
rx="1"
|
||||
className={clsx("transform origin-center transition duration-200 ease-out", {
|
||||
"rotate-90": !open,
|
||||
"rotate-180": open,
|
||||
})}
|
||||
/>
|
||||
</svg>
|
||||
<span>{title}</span>
|
||||
</Disclosure.Button>
|
||||
|
||||
<Transition
|
||||
enter="transition duration-300 ease-in-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Disclosure.Panel className="text-gray-600 overflow-hidden">
|
||||
<p className="pb-5">{children}</p>
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
41
app/public-area/components/footer.tsx
Normal file
41
app/public-area/components/footer.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import type { LinkProps } from "blitz";
|
||||
import { Link, Routes } from "blitz";
|
||||
|
||||
export default function Footer() {
|
||||
// TODO
|
||||
const isDisabled = true;
|
||||
if (isDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className="bg-white">
|
||||
<div className="max-w-7xl mx-auto py-12 px-4 overflow-hidden sm:px-6 lg:px-8">
|
||||
<nav className="-mx-5 -my-2 flex flex-wrap justify-center" aria-label="Footer">
|
||||
<NavLink href={Routes.Blog()} name="Blog" />
|
||||
<NavLink href={Routes.PrivacyPolicy()} name="Privacy Policy" />
|
||||
<NavLink href={Routes.TermsOfService()} name="Terms of Service" />
|
||||
<NavLink href="mailto:support@shellphone.app" name="Email Us" />
|
||||
</nav>
|
||||
<p className="mt-8 text-center text-base text-gray-400">
|
||||
© 2021 Capsule Corp. Dev Pte. Ltd. All rights reserved.
|
||||
{/*© 2021 Mokhtar Mial All rights reserved.*/}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
href: LinkProps["href"];
|
||||
name: string;
|
||||
};
|
||||
|
||||
const NavLink: FunctionComponent<Props> = ({ href, name }) => (
|
||||
<div className="px-5 py-2">
|
||||
<Link href={href}>
|
||||
<a className="text-base text-gray-500 hover:text-gray-900">{name}</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
190
app/public-area/components/header.tsx
Normal file
190
app/public-area/components/header.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
import { Fragment, useState, useRef, useEffect } from "react";
|
||||
import type { LinkProps } from "blitz";
|
||||
import { Link, Routes } from "blitz";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { XIcon } from "@heroicons/react/outline";
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<header className="absolute w-full z-30">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="flex items-center justify-between h-20">
|
||||
<div className="flex-shrink-0 mr-5">
|
||||
<Link href="/">
|
||||
<a className="block">
|
||||
<img className="w-10 h-10" src="/shellphone.png" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="hidden md:flex md:flex-grow">
|
||||
<ul className="flex flex-grow flex-wrap items-center font-medium">
|
||||
<li>
|
||||
<DesktopNavLink href={Routes.Roadmap()} label="Roadmap" />
|
||||
</li>
|
||||
<li>
|
||||
<DesktopNavLink href={Routes.OpenMetrics()} label="Open Metrics" />
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<MobileNav />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
type NavLinkProps = {
|
||||
href: LinkProps["href"];
|
||||
label: string;
|
||||
};
|
||||
|
||||
function DesktopNavLink({ href, label }: NavLinkProps) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<a className="text-gray-600 hover:text-gray-900 px-5 py-2 flex items-center transition duration-150 ease-in-out">
|
||||
{label}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNav() {
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const mobileNav = useRef<HTMLDivElement>(null);
|
||||
|
||||
// close the mobile menu on click outside
|
||||
useEffect(() => {
|
||||
const clickHandler = ({ target }: MouseEvent) => {
|
||||
if (!mobileNav.current || !trigger.current) {
|
||||
return;
|
||||
}
|
||||
console.log(mobileNav.current.contains(target as Node));
|
||||
if (
|
||||
!mobileNavOpen ||
|
||||
mobileNav.current.contains(target as Node) ||
|
||||
trigger.current.contains(target as Node)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setMobileNavOpen(false);
|
||||
};
|
||||
document.addEventListener("click", clickHandler);
|
||||
return () => document.removeEventListener("click", clickHandler);
|
||||
});
|
||||
|
||||
// close the mobile menu if the esc key is pressed
|
||||
useEffect(() => {
|
||||
const keyHandler = ({ keyCode }: KeyboardEvent) => {
|
||||
if (!mobileNavOpen || keyCode !== 27) return;
|
||||
setMobileNavOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", keyHandler);
|
||||
return () => document.removeEventListener("keydown", keyHandler);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="inline-flex md:hidden">
|
||||
<button
|
||||
ref={trigger}
|
||||
className={`hamburger ${mobileNavOpen && "active"}`}
|
||||
aria-controls="mobile-nav"
|
||||
aria-expanded={mobileNavOpen}
|
||||
onClick={() => setMobileNavOpen(!mobileNavOpen)}
|
||||
>
|
||||
<span className="sr-only">Menu</span>
|
||||
<svg
|
||||
className="w-6 h-6 fill-current text-gray-900 hover:text-gray-900 transition duration-150 ease-in-out"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect y="4" width="24" height="2" rx="1" />
|
||||
<rect y="11" width="24" height="2" rx="1" />
|
||||
<rect y="18" width="24" height="2" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Transition.Root show={mobileNavOpen} as={Fragment}>
|
||||
<Dialog as="div" className="fixed z-40 inset-0 overflow-hidden" onClose={setMobileNavOpen}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-in-out duration-500"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in-out duration-500"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Dialog.Overlay className="absolute inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-y-0 right-0 pl-10 max-w-full flex">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<div ref={mobileNav} className="w-screen max-w-xs">
|
||||
<div className="h-full flex flex-col py-6 bg-white shadow-xl overflow-y-scroll">
|
||||
<div className="px-4 sm:px-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<Dialog.Title className="text-lg font-medium text-gray-900">
|
||||
Shellphone
|
||||
</Dialog.Title>
|
||||
<div className="ml-3 h-7 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
>
|
||||
<span className="sr-only">Close panel</span>
|
||||
<XIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 relative flex-1 px-4 sm:px-6">
|
||||
<div className="absolute inset-0 px-4 sm:px-6">
|
||||
<ul>
|
||||
<li>
|
||||
<MobileNavLink href={Routes.Roadmap()} label="Roadmap" />
|
||||
</li>
|
||||
<li>
|
||||
<MobileNavLink
|
||||
href={Routes.OpenMetrics()}
|
||||
label="Open Metrics"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
</div>
|
||||
);
|
||||
|
||||
function MobileNavLink({ href, label }: NavLinkProps) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<a onClick={() => setMobileNavOpen(false)} className="flex text-gray-600 hover:text-gray-900 py-2">
|
||||
{label}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Header;
|
44
app/public-area/components/hero.tsx
Normal file
44
app/public-area/components/hero.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import CTAForm from "./cta-form";
|
||||
import Checkmark from "./checkmark";
|
||||
import PhoneMockup from "./phone-mockup";
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<section className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="pt-32 pb-10 md:pt-34 md:pb-20">
|
||||
<div className="md:grid md:grid-cols-12 md:gap-12 lg:gap-20 items-center">
|
||||
<div className="md:col-span-7 lg:col-span-7 mb-8 md:mb-0 text-center md:text-left">
|
||||
<h1 className="h1 lg:text-5xl mb-4 font-extrabold font-mackinac">
|
||||
<strong className="bg-gradient-to-br from-primary-500 to-indigo-600 bg-clip-text decoration-clone text-transparent">
|
||||
Take your phone number
|
||||
</strong>
|
||||
<br />
|
||||
<strong className="text-[#24185B]">anywhere you go</strong>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Coming soon! 🐚 Keep your phone number and pay less for your communications, even
|
||||
abroad.
|
||||
</p>
|
||||
<CTAForm />
|
||||
<ul className="max-w-sm sm:max-w-md mx-auto md:max-w-none text-gray-600 mt-8 -mb-2">
|
||||
<li className="flex items-center mb-2">
|
||||
<Checkmark />
|
||||
<span>Send and receive SMS messages.</span>
|
||||
</li>
|
||||
<li className="flex items-center mb-2">
|
||||
<Checkmark />
|
||||
<span>Make and receive phone calls.</span>
|
||||
</li>
|
||||
<li className="flex items-center mb-2">
|
||||
<Checkmark />
|
||||
<span>No download required.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<PhoneMockup />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
23
app/public-area/components/layout.tsx
Normal file
23
app/public-area/components/layout.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
|
||||
import BaseLayout from "./base-layout";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
const Layout: FunctionComponent<Props> = ({ children, title }) => (
|
||||
<BaseLayout>
|
||||
<section className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="pt-32 pb-10 md:pt-34 md:pb-16">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h1 className="h1 mb-16 font-extrabold font-mackinac">{title}</h1>
|
||||
</div>
|
||||
|
||||
<div className="max-w-3xl mx-auto text-lg xl:text-xl flow-root">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
);
|
||||
|
||||
export default Layout;
|
26
app/public-area/components/phone-mockup.tsx
Normal file
26
app/public-area/components/phone-mockup.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import mockupImage from "../images/mockup-image-01.png";
|
||||
import iphoneMockup from "../images/iphone-mockup.png";
|
||||
|
||||
export default function PhoneMockup() {
|
||||
return (
|
||||
<div className="md:col-span-5 lg:col-span-5 text-center md:text-right">
|
||||
<div className="inline-flex relative justify-center items-center">
|
||||
<img
|
||||
className="absolute max-w-[84.33%]"
|
||||
src={mockupImage.src}
|
||||
width={290}
|
||||
height={624}
|
||||
alt="Features illustration"
|
||||
/>
|
||||
<img
|
||||
className="relative max-w-full mx-auto md:mr-0 md:max-w-none h-auto pointer-events-none"
|
||||
src={iphoneMockup.src}
|
||||
width={344}
|
||||
height={674}
|
||||
alt="iPhone mockup"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
36
app/public-area/components/referral-banner.tsx
Normal file
36
app/public-area/components/referral-banner.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { XIcon } from "@heroicons/react/outline";
|
||||
|
||||
export default function ReferralBanner() {
|
||||
// TODO
|
||||
const isDisabled = true;
|
||||
if (isDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative bg-primary-600">
|
||||
<div className="max-w-7xl mx-auto py-3 px-3 sm:px-6 lg:px-8">
|
||||
<div className="pr-16 sm:text-center sm:px-16">
|
||||
<p className="font-medium text-white">
|
||||
<span>🎉 New: Get one month free for every friend that joins and subscribe!</span>
|
||||
<span className="block sm:ml-2 sm:inline-block">
|
||||
<a href="#" className="text-white font-bold underline">
|
||||
{" "}
|
||||
Learn more <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute inset-y-0 right-0 pt-1 pr-1 flex items-start sm:pt-1 sm:pr-2 sm:items-start">
|
||||
<button
|
||||
type="button"
|
||||
className="flex p-2 rounded-md hover:bg-primary-500 focus:outline-none focus:ring-2 focus:ring-white"
|
||||
>
|
||||
<span className="sr-only">Dismiss</span>
|
||||
<XIcon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
BIN
app/public-area/images/iphone-mockup.png
Normal file
BIN
app/public-area/images/iphone-mockup.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 54 KiB |
BIN
app/public-area/images/mockup-image-01.jpg
Normal file
BIN
app/public-area/images/mockup-image-01.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 45 KiB |
BIN
app/public-area/images/mockup-image-01.png
Normal file
BIN
app/public-area/images/mockup-image-01.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 195 KiB |
23
app/public-area/mutations/join-waitlist.ts
Normal file
23
app/public-area/mutations/join-waitlist.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { resolver } from "blitz";
|
||||
import { z } from "zod";
|
||||
|
||||
import appLogger from "../../../integrations/logger";
|
||||
import { addSubscriber } from "../../../integrations/mailchimp";
|
||||
|
||||
const logger = appLogger.child({ mutation: "join-waitlist" });
|
||||
|
||||
const bodySchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export default resolver.pipe(resolver.zod(bodySchema), async ({ email }, ctx) => {
|
||||
try {
|
||||
await addSubscriber(email);
|
||||
} catch (error: any) {
|
||||
logger.error(error.response?.data);
|
||||
|
||||
if (error.response?.data.title !== "Member Exists") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
21
app/public-area/pages/index.tsx
Normal file
21
app/public-area/pages/index.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import type { BlitzPage } from "blitz";
|
||||
|
||||
import BaseLayout from "../components/base-layout";
|
||||
import ReferralBanner from "../components/referral-banner";
|
||||
import Hero from "../components/hero";
|
||||
import FAQs from "../components/faqs";
|
||||
|
||||
const LandingPage: BlitzPage = () => {
|
||||
return (
|
||||
<>
|
||||
<ReferralBanner />
|
||||
<Hero />
|
||||
<FAQs />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
LandingPage.getLayout = (page) => <BaseLayout>{page}</BaseLayout>;
|
||||
LandingPage.suppressFirstRenderFlicker = true;
|
||||
|
||||
export default LandingPage;
|
39
app/public-area/pages/open-metrics.tsx
Normal file
39
app/public-area/pages/open-metrics.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import type { BlitzPage } from "blitz";
|
||||
import { useQuery } from "blitz";
|
||||
|
||||
import getMetrics from "../queries/get-metrics";
|
||||
|
||||
import Layout from "../components/layout";
|
||||
|
||||
const initialData = {
|
||||
phoneNumbers: 0,
|
||||
smsExchanged: 0,
|
||||
minutesCalled: 0,
|
||||
};
|
||||
|
||||
const OpenMetrics: BlitzPage = () => {
|
||||
const [metrics] = useQuery(getMetrics, {}, { suspense: false, initialData });
|
||||
const { phoneNumbers, smsExchanged, minutesCalled } = metrics ?? initialData;
|
||||
|
||||
return (
|
||||
<dl className="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-3">
|
||||
<Card title="Phone Numbers Registered" value={phoneNumbers} />
|
||||
<Card title="SMS Exchanged" value={smsExchanged} />
|
||||
<Card title="Minutes on Call" value={minutesCalled} />
|
||||
</dl>
|
||||
);
|
||||
};
|
||||
|
||||
function Card({ title, value }: any) {
|
||||
return (
|
||||
<div className="px-4 py-5 bg-white shadow rounded-lg overflow-hidden sm:p-6">
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">{title}</dt>
|
||||
<dd className="mt-1 text-3xl font-semibold text-gray-900">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
OpenMetrics.getLayout = (page) => <Layout title="Open Metrics">{page}</Layout>;
|
||||
OpenMetrics.suppressFirstRenderFlicker = true;
|
||||
|
||||
export default OpenMetrics;
|
12
app/public-area/pages/privacy.tsx
Normal file
12
app/public-area/pages/privacy.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import type { BlitzPage } from "blitz";
|
||||
|
||||
import Layout from "../components/layout";
|
||||
|
||||
const PrivacyPolicy: BlitzPage = () => {
|
||||
return <article className="m-auto">Coming soon.</article>;
|
||||
};
|
||||
|
||||
PrivacyPolicy.getLayout = (page) => <Layout title="Privacy Policy">{page}</Layout>;
|
||||
PrivacyPolicy.suppressFirstRenderFlicker = true;
|
||||
|
||||
export default PrivacyPolicy;
|
143
app/public-area/pages/roadmap.tsx
Normal file
143
app/public-area/pages/roadmap.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import type { BlitzPage } from "blitz";
|
||||
import { Head } from "blitz";
|
||||
import clsx from "clsx";
|
||||
import { CheckIcon, XIcon, TerminalIcon } from "@heroicons/react/solid";
|
||||
|
||||
import Header from "../components/header";
|
||||
import Footer from "../components/footer";
|
||||
import Layout from "../components/layout";
|
||||
|
||||
const Roadmap: BlitzPage = () => {
|
||||
return (
|
||||
<ul role="list" className="-mb-8">
|
||||
{roadmap.map((feature, index) => {
|
||||
const isDone = feature.status === "done";
|
||||
const isInProgress = feature.status === "in-progress";
|
||||
return (
|
||||
<li key={feature.name}>
|
||||
<div className="relative pb-8">
|
||||
{index !== roadmap.length - 1 ? (
|
||||
<span
|
||||
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative flex space-x-3">
|
||||
<div>
|
||||
<span
|
||||
className={clsx(
|
||||
isDone ? "bg-green-500" : isInProgress ? "bg-yellow-500" : "bg-gray-400",
|
||||
"h-8 w-8 rounded-full flex items-center justify-center ring-8 ring-white",
|
||||
)}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckIcon className="h-5 w-5 text-white" aria-hidden="true" />
|
||||
) : isInProgress ? (
|
||||
<TerminalIcon className="h-5 w-5 text-white" aria-hidden="true" />
|
||||
) : (
|
||||
<XIcon className="h-5 w-5 text-white" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 items-center flex justify-between space-x-4">
|
||||
<div>
|
||||
<p className="text-md xl:text-lg text-gray-900">{feature.name}</p>
|
||||
</div>
|
||||
{isDone ? (
|
||||
<div className="text-right self-start text-md xl:text-lg whitespace-nowrap text-gray-500">
|
||||
<time>{formatter.format(feature.doneDate)}</time>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
type RoadmapItem = {
|
||||
name: string;
|
||||
doneDate?: unknown;
|
||||
} & (
|
||||
| {
|
||||
status: "done";
|
||||
doneDate: Date;
|
||||
}
|
||||
| {
|
||||
status: "in-progress";
|
||||
}
|
||||
| {
|
||||
status: "to-do";
|
||||
}
|
||||
);
|
||||
|
||||
const roadmap: RoadmapItem[] = [
|
||||
{
|
||||
name: "Send SMS",
|
||||
status: "done",
|
||||
doneDate: new Date("2021-07-18T15:33:08Z"),
|
||||
},
|
||||
{
|
||||
name: "Receive SMS",
|
||||
status: "done",
|
||||
doneDate: new Date("2021-08-01T10:54:51Z"),
|
||||
},
|
||||
{
|
||||
name: "Make a phone call",
|
||||
status: "in-progress",
|
||||
},
|
||||
{
|
||||
name: "Receive a phone call",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Get notified of incoming messages and calls",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Remove any phone call or message from history",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Allow incoming calls to go to voicemail",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Forward incoming messages and phone calls to your desired phone number",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Import contacts from your mobile phone",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Use Shellphone with multiple phone numbers at once",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Port your phone number to Shellphone - you won't have to deal with Twilio anymore!",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Send delayed messages",
|
||||
status: "to-do",
|
||||
},
|
||||
{
|
||||
name: "Record phone calls",
|
||||
status: "to-do",
|
||||
},
|
||||
];
|
||||
|
||||
const formatter = Intl.DateTimeFormat("en-US", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
Roadmap.getLayout = (page) => <Layout title="(Rough) Roadmap">{page}</Layout>;
|
||||
Roadmap.suppressFirstRenderFlicker = true;
|
||||
|
||||
export default Roadmap;
|
12
app/public-area/pages/terms.tsx
Normal file
12
app/public-area/pages/terms.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import type { BlitzPage } from "blitz";
|
||||
|
||||
import Layout from "../components/layout";
|
||||
|
||||
const TermsOfService: BlitzPage = () => {
|
||||
return <article className="m-auto">Coming soon.</article>;
|
||||
};
|
||||
|
||||
TermsOfService.getLayout = (page) => <Layout title="Terms of Service">{page}</Layout>;
|
||||
TermsOfService.suppressFirstRenderFlicker = true;
|
||||
|
||||
export default TermsOfService;
|
25
app/public-area/queries/get-metrics.ts
Normal file
25
app/public-area/queries/get-metrics.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { resolver } from "blitz";
|
||||
|
||||
import db from "../../../db";
|
||||
|
||||
export default resolver.pipe(async () => {
|
||||
const [phoneNumbers, smsExchanged, allPhoneCalls] = await Promise.all([
|
||||
db.phoneNumber.count(),
|
||||
db.message.count(),
|
||||
db.phoneCall.findMany(),
|
||||
]);
|
||||
const secondsCalled = allPhoneCalls.reduce<number>((seconds, phoneCall) => {
|
||||
if (!phoneCall.duration) {
|
||||
return seconds;
|
||||
}
|
||||
|
||||
return seconds + Number.parseInt(phoneCall.duration);
|
||||
}, 0);
|
||||
const minutesCalled = Math.round(secondsCalled / 60);
|
||||
|
||||
return {
|
||||
phoneNumbers,
|
||||
smsExchanged,
|
||||
minutesCalled,
|
||||
};
|
||||
});
|
@ -41,7 +41,7 @@ const ProfileInformations: FunctionComponent = () => {
|
||||
try {
|
||||
// TODO
|
||||
// await updateUser({ email, data: { name } });
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(error.response, "error updating user infos");
|
||||
|
||||
if (error.response.status === 401) {
|
||||
|
@ -38,7 +38,7 @@ const UpdatePassword: FunctionComponent = () => {
|
||||
try {
|
||||
// TODO
|
||||
// await customer.updateUser({ password: newPassword });
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error(error.response, "error updating user infos");
|
||||
|
||||
if (error.response.status === 401) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { BlitzPage } from "blitz";
|
||||
import { Routes } from "blitz";
|
||||
import { Routes, useMutation } from "blitz";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCreditCard, faUserCircle } from "@fortawesome/pro-regular-svg-icons";
|
||||
|
||||
@ -7,6 +7,7 @@ import Layout from "../../core/layouts/layout";
|
||||
|
||||
import appLogger from "../../../integrations/logger";
|
||||
import useRequireOnboarding from "../../core/hooks/use-require-onboarding";
|
||||
import logout from "../../auth/mutations/logout";
|
||||
|
||||
const logger = appLogger.child({ page: "/settings" });
|
||||
|
||||
@ -27,6 +28,7 @@ const navigation = [
|
||||
|
||||
const Settings: BlitzPage = () => {
|
||||
useRequireOnboarding();
|
||||
const [logoutMutation] = useMutation(logout);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -48,6 +50,8 @@ const Settings: BlitzPage = () => {
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<button onClick={() => logoutMutation()}>Log out</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
Reference in New Issue
Block a user