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