Skip to content

Commit

Permalink
feat: add contact form dialog
Browse files Browse the repository at this point in the history
Signed-off-by: Griko Nibras <[email protected]>
  • Loading branch information
grikomsn committed Jan 11, 2024
1 parent 23763c1 commit 008ebc9
Show file tree
Hide file tree
Showing 11 changed files with 875 additions and 13 deletions.
626 changes: 625 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"@react-stately/table": "^3.11.4",
"@sentry/nextjs": "^7.93.0",
"@skip-router/core": "^1.2.3",
"@tailwindcss/forms": "^0.5.7",
"@tanstack/query-sync-storage-persister": "^5.17.9",
"@tanstack/react-query": "^5.17.9",
"@tanstack/react-query-persist-client": "^5.17.9",
Expand All @@ -84,6 +85,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"resend": "^2.1.0",
"tailwindcss": "^3.4.1",
"tinykeys": "^2.1.0",
"undici": "^6.3.0",
Expand Down
134 changes: 134 additions & 0 deletions src/components/ContactDialog/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { ArrowLeftIcon, EnvelopeIcon } from "@heroicons/react/20/solid";
import * as Dialog from "@radix-ui/react-dialog";
import { clsx } from "clsx";
import { FormEvent } from "react";
import toast from "react-hot-toast";

import { useDisclosureKey } from "@/context/disclosures";

export function ContactDialog() {
const [isOpen, { close }] = useDisclosureKey("contactDialog");
return (
<Dialog.Root modal={false} open={isOpen}>
<Dialog.Content className="absolute inset-0 bg-white rounded-3xl animate-fade-zoom-in">
<form
className={clsx(
"flex flex-col h-full px-4 py-6 text-sm",
"[&_input]:border-neutral-300 [&_input]:rounded-md [&_input]:text-sm",
"[&_textarea]:border-neutral-300 [&_textarea]:rounded-md [&_textarea]:text-sm",
)}
onSubmit={handleSubmit}
>
<div className="flex items-center gap-4 pb-4">
<button
className="hover:bg-neutral-100 w-8 h-8 rounded-full flex items-center justify-center transition-colors"
onClick={close}
>
<ArrowLeftIcon className="w-6 h-6" />
</button>
<h3 className="font-bold text-xl">Contact Us</h3>
</div>
<label htmlFor="txHash">
Transaction Hash <span className="text-red-500">*</span>
</label>
<input
type="text"
id="txHash"
name="txHash"
placeholder="C1CEA71D07932CEE8F4DC691..."
className="form-input rounded mb-2"
required
/>
<label htmlFor="submitChain">
Submitted Transaction Chain <span className="text-red-500">*</span>
</label>
<input
type="text"
id="submitChain"
name="submitChain"
placeholder="cosmoshub-4"
className="form-input rounded mb-2"
required
/>
<label htmlFor="signerAddress">
Signer Account Address <span className="text-red-500">*</span>
</label>
<input
type="text"
id="signerAddress"
name="signerAddress"
placeholder="cosmos1kpzxx2lxg05xxn8mf..."
className="form-input rounded mb-2"
required
/>
<div className="grid grid-cols-1 sm:grid-cols-2 sm:gap-4 mb-2">
<div className="grid grid-flow-row">
<label htmlFor="Name">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
placeholder="Skippy McSkipper"
className="form-input rounded mb-2"
required
/>
</div>
<div className="grid grid-flow-row">
<label htmlFor="email">
Email <span className="text-red-500">*</span>
</label>
<input
type="email"
id="email"
name="email"
placeholder="[email protected]"
className="form-input rounded mb-2"
required
/>
</div>
</div>

<label htmlFor="message">Describe your issue</label>
<textarea name="message" id="message" className="form-textarea" />
<div className="flex-grow" />
<button
type="submit"
className="flex items-center gap-2 bg-[#FF486E] rounded-md font-medium p-2 text-white justify-center text-base"
>
<EnvelopeIcon className="w-5 h-5" />
<span>Send Message</span>
</button>
</form>
</Dialog.Content>
</Dialog.Root>
);
}

async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await toast.promise(
fetch("/api/contact", {
method: "POST",
body: new FormData(event.currentTarget),
}),
{
loading: "Sending message...",
success: "Message sent!",
error: (
<p>
<strong>Something went wrong!</strong>
<br />
Please try again later, or contact us directly at{" "}
<a
href="mailto:[email protected]"
className="text-red-500 hover:underline"
>
[email protected]
</a>
</p>
),
},
);
}
31 changes: 31 additions & 0 deletions src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ChatBubbleOvalLeftIcon as ContactIcon } from "@heroicons/react/16/solid";
import { clsx } from "clsx";

import { useDisclosureKey } from "@/context/disclosures";

export function Footer() {
const [isOpen, { open }] = useDisclosureKey("contactDialog");
return (
<footer
className={clsx(
"z-10 pt-8",
"sm:fixed sm:bottom-0 sm:right-4",
//
)}
>
<button
className={clsx(
"group px-4 py-2 flex items-center gap-2 sm:shadow-xl rounded-t-lg",
"bg-white text-[#FF486E] hover:bg-red-50 sm:hover:pb-3 sm:active:pb-2.5",
"transition-[background,padding,transform] ease-[cubic-bezier(0.08,0.82,0.17,1)] duration-500",
"sm:data-[open=true]:translate-y-full sm:data-[open=false]:translate-y-0",
)}
onClick={() => open({ closeAll: true })}
data-open={isOpen}
>
<ContactIcon className="w-4 h-4" />
<span>Contact Us</span>
</button>
</footer>
);
}
2 changes: 2 additions & 0 deletions src/components/SwapWidget/SwapWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useChains as useSkipChains } from "@/hooks/useChains";
import { AdaptiveLink } from "../AdaptiveLink";
import AssetInput from "../AssetInput";
import { ConnectedWalletButton } from "../ConnectedWalletButton";
import { ContactDialog } from "../ContactDialog";
import { HistoryButton } from "../HistoryButton";
import { HistoryDialog } from "../HistoryDialog";
import { JsonDialog } from "../JsonDialog";
Expand Down Expand Up @@ -308,6 +309,7 @@ export function SwapWidget() {
</div>
)}
</div>
<ContactDialog />
<HistoryDialog />
<SettingsDialog />
<JsonDialog />
Expand Down
28 changes: 17 additions & 11 deletions src/context/disclosures.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { createJSONStorage, persist } from "zustand/middleware";

const defaultValues = {
contactDialog: false,
historyDialog: false,
priceImpactDialog: false,
settingsDialog: false,
Expand All @@ -26,16 +27,17 @@ const disclosureStore = create(
historyDialog: state.historyDialog,
}),
skipHydration: true,
storage: createJSONStorage(() => window.sessionStorage),
}),
);

const scrollStore = create<{ value: number[] }>(() => ({ value: [] }));
const persistScroll = () => {
function persistScroll() {
scrollStore.setState((prev) => ({
value: prev.value.concat(window.scrollY),
}));
};
const restoreScroll = () => {
}
function restoreScroll() {
let value: number | undefined;
scrollStore.setState((prev) => {
value = prev.value.pop();
Expand All @@ -45,13 +47,13 @@ const restoreScroll = () => {
top: value,
behavior: "smooth",
});
};
const scrollTop = () => {
}
function scrollTop() {
window.scrollTo({
top: 0,
behavior: "smooth",
});
};
}

export const disclosure = {
open: (key: DisclosureKey, { closeAll = false } = {}) => {
Expand Down Expand Up @@ -98,7 +100,7 @@ export const disclosure = {
rehydrate: () => disclosureStore.persist.rehydrate(),
};

export const useDisclosureKey = (key: DisclosureKey) => {
export function useDisclosureKey(key: DisclosureKey) {
const state = disclosureStore((state) => state[key]);
const actions = {
open: ({ closeAll = false } = {}) => disclosure.open(key, { closeAll }),
Expand All @@ -107,9 +109,9 @@ export const useDisclosureKey = (key: DisclosureKey) => {
set: (value: boolean) => disclosure.set(key, value),
};
return [state, actions] as const;
};
}

export const useJsonDisclosure = () => {
export function useJsonDisclosure() {
const state = disclosureStore((state) => state.json);
const actions = {
open: (json: NonNullable<DisclosureStore["json"]>) => {
Expand All @@ -120,4 +122,8 @@ export const useJsonDisclosure = () => {
},
};
return [state, actions] as const;
};
}

export function useAnyDisclosureOpen() {
return disclosureStore((state) => Object.values(state).some(Boolean));
}
2 changes: 2 additions & 0 deletions src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { WagmiConfig } from "wagmi";

import { getAssetLists, getChains } from "@/chains";
import { DefaultSeo } from "@/components/DefaultSeo";
import { Footer } from "@/components/Footer";
import Header from "@/components/Header";
import SkipBanner from "@/components/SkipBanner";
import { metadata } from "@/constants/seo";
Expand Down Expand Up @@ -60,6 +61,7 @@ export default function App({ Component, pageProps }: AppProps) {
<SkipBanner className="z-50 top-0 inset-x-0 sm:fixed w-screen" />
<Header />
<Component {...pageProps} />
<Footer />
</main>
<Toaster
position="bottom-center"
Expand Down
48 changes: 48 additions & 0 deletions src/pages/api/contact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { PageConfig } from "next";
import { NextRequest } from "next/server";
import { Resend } from "resend";

import { contactFormSchema } from "@/schemas/contact";

export const page: PageConfig = {
runtime: "edge",
};

if (!process.env.RESEND_API_KEY) {
throw new Error("RESEND_API_KEY is not set");
}

const resend = new Resend(process.env.RESEND_API_KEY);

export default async function handler(req: NextRequest) {
if (req.method !== "POST") {
return new Response(null, { status: 405 }); // Method Not Allowed
}

const formData = await req.formData();
const entries = Object.fromEntries(formData.entries());
const payload = await contactFormSchema.parseAsync(entries);

const emails = (process.env.CONTACT_FORM_DEST || "[email protected]")
.split(",")
.filter(Boolean);

const { data, error } = await resend.emails.send({
from: `${payload.name} <${payload.email}>`,
to: emails,
subject: `ibc.fun issue on ${payload.submitChain}`,
text: `Transaction Hash: ${payload.txHash}
Signer Account Address: ${payload.signerAddress}
Message: ${!payload.message ? "-" : ""}
${payload.message || ""}`,
});

if (!data || error) {
console.error(error);
return new Response(null, { status: 500 }); // Internal Server Error
}

console.log(data);
return new Response(null, { status: 200 }); // OK
}
2 changes: 1 addition & 1 deletion src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ export default function Home() {
}, 1000 * 2);

return (
<div className="flex flex-col items-center justify-center sm:pb-8">
<div className="flex flex-col items-center flex-grow">
<div className="bg-white shadow-xl sm:rounded-3xl p-6 relative w-screen sm:max-w-[450px]">
<WalletModalProvider>
<SwapWidget />
Expand Down
10 changes: 10 additions & 0 deletions src/schemas/contact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { z } from "zod";

export const contactFormSchema = z.object({
txHash: z.string(),
submitChain: z.string().min(4),
signerAddress: z.string(),
name: z.string(),
email: z.string().email(),
message: z.string().optional(),
});
3 changes: 3 additions & 0 deletions tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ module.exports = {
},
},
plugins: [
require("@tailwindcss/forms")({
strategy: "class",
}),
plugin(({ addUtilities }) => {
addUtilities({
".HistoryListTrigger[data-state='open'] > .HistoryListTriggerText::before":
Expand Down

0 comments on commit 008ebc9

Please sign in to comment.