mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
332
apps/waitlist-website/app/routes/home.tsx
Normal file
332
apps/waitlist-website/app/routes/home.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react"
|
||||
import { useFetcher } from "react-router"
|
||||
import { Resend } from "resend"
|
||||
import { Streamdown } from "streamdown"
|
||||
|
||||
import { ChatBox } from "~/chat/chat-box"
|
||||
import {
|
||||
INITLAL_MESSAGES,
|
||||
waitListJoinedMessage,
|
||||
type Message,
|
||||
type SystemMessage,
|
||||
type UserMessage,
|
||||
} from "~/chat/message"
|
||||
import { useFakeStreaming } from "~/chat/use-fake-streaming"
|
||||
import {
|
||||
AnimatedLogo,
|
||||
AnimatedLogoState,
|
||||
AnimatedLogoState as TAnimatedLogoState,
|
||||
} from "~/components/animated-logo"
|
||||
import { ProgressiveBlur } from "~/components/progressive-blur"
|
||||
|
||||
import type { Route } from "./+types/home"
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "New React Router App" },
|
||||
{ name: "description", content: "Welcome to React Router!" },
|
||||
]
|
||||
}
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const formData = await request.formData()
|
||||
const email = formData.get("email")
|
||||
|
||||
if (typeof email !== "string" || !isValidEmail(email)) {
|
||||
return { error: "Invalid email" }
|
||||
}
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
|
||||
const res = await resend.contacts.create({
|
||||
email,
|
||||
segments: [
|
||||
{
|
||||
// resend segment id for "Waitlist" segment
|
||||
id: "b80fb036-74a1-4f7d-bca5-2c035b696071",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (res.error) {
|
||||
console.log("Error adding contact to Resend:", res.error)
|
||||
return { error: res.error.message }
|
||||
}
|
||||
|
||||
return { email }
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [messages, setMessages] = useState<Message[]>(INITLAL_MESSAGES)
|
||||
const [emailSent, setEmailSent] = useState("")
|
||||
const [isAnimatingSend, setIsAnimatingSend] = useState(false)
|
||||
const [logoState, setLogoState] = useState<TAnimatedLogoState>(AnimatedLogoState.Idle)
|
||||
const chatBoxRef = useRef<HTMLDivElement>(null)
|
||||
const fetcher = useFetcher()
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.data?.email && !isAnimatingSend) {
|
||||
setMessages((messages) => [...messages, waitListJoinedMessage(fetcher.data.email)])
|
||||
} else if (fetcher.data?.error) {
|
||||
console.error(fetcher.data.error)
|
||||
}
|
||||
}, [fetcher.data?.email, fetcher.data?.error, isAnimatingSend])
|
||||
|
||||
const insertEmailMessage = (email: string) => {
|
||||
setEmailSent(email)
|
||||
setIsAnimatingSend(true)
|
||||
setLogoState(AnimatedLogoState.Loading)
|
||||
setMessages((messages) => [
|
||||
...messages,
|
||||
{
|
||||
role: "user",
|
||||
message: email,
|
||||
bubbleLayoutId: "test",
|
||||
},
|
||||
])
|
||||
|
||||
fetcher.submit({ email }, { method: "post" })
|
||||
}
|
||||
|
||||
let chatBox: React.ReactNode
|
||||
if (emailSent && isAnimatingSend) {
|
||||
const chatBoxRect = chatBoxRef.current?.getBoundingClientRect()
|
||||
const mainRect = chatBoxRef.current?.offsetParent?.getBoundingClientRect()
|
||||
chatBox = (
|
||||
<MorphingChatBox
|
||||
chatBoxWidth={chatBoxRef.current?.offsetWidth ?? 0}
|
||||
chatBoxHeight={chatBoxRef.current?.offsetHeight ?? 0}
|
||||
chatBoxLeft={(chatBoxRect?.left ?? 0) - (mainRect?.left ?? 0)}
|
||||
chatBoxTop={(chatBoxRect?.top ?? 0) - (mainRect?.top ?? 0)}
|
||||
onAnimationEnd={() => {
|
||||
setIsAnimatingSend(false)
|
||||
}}
|
||||
>
|
||||
{emailSent}
|
||||
</MorphingChatBox>
|
||||
)
|
||||
} else if (!emailSent) {
|
||||
chatBox = (
|
||||
<AnimatePresence>
|
||||
{logoState === AnimatedLogoState.Idle && !emailSent && (
|
||||
<motion.div
|
||||
ref={chatBoxRef}
|
||||
key="test"
|
||||
className="w-full max-w-2xl absolute bottom-8 px-6 md:px-0 flex justify-center"
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30, mass: 1.5 }}
|
||||
>
|
||||
<ChatBox
|
||||
className="w-full max-w-2xl"
|
||||
validate={isValidEmail}
|
||||
disabled={fetcher.state === "submitting" || fetcher.state === "loading"}
|
||||
onSubmit={insertEmailMessage}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
} else {
|
||||
chatBox = null
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative w-full h-full flex flex-col items-center justify-start gap-4 overflow-hidden">
|
||||
<ProgressiveBlur className="absolute top-0 left-0 right-0 h-24 z-10" />
|
||||
<AnimatedLogo
|
||||
className="absolute top-4 md:top-8 size-10 z-20 cursor-pointer"
|
||||
state={logoState}
|
||||
/>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
showLastMessage={!isAnimatingSend}
|
||||
onMessageStreamStart={() => {
|
||||
setLogoState(AnimatedLogoState.Loading)
|
||||
}}
|
||||
onMessageStreamEnd={() => {
|
||||
setLogoState(AnimatedLogoState.Idle)
|
||||
}}
|
||||
/>
|
||||
{chatBox}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function MorphingChatBox({
|
||||
chatBoxWidth,
|
||||
chatBoxHeight,
|
||||
chatBoxLeft,
|
||||
chatBoxTop,
|
||||
onAnimationEnd,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
chatBoxWidth: number
|
||||
chatBoxHeight: number
|
||||
chatBoxLeft: number
|
||||
chatBoxTop: number
|
||||
onAnimationEnd: () => void
|
||||
}>) {
|
||||
const [targetWidth, setTargetWidth] = useState(-1)
|
||||
const [targetHeight, setTargetHeight] = useState(-1)
|
||||
const [targetCoords, setTargetCoords] = useState([0, 0])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const bubble = document.getElementById("test")
|
||||
if (bubble) {
|
||||
const mainRect = bubble.closest("main")?.getBoundingClientRect()
|
||||
const rect = bubble.getBoundingClientRect()
|
||||
setTargetWidth(bubble.offsetWidth)
|
||||
setTargetHeight(bubble.offsetHeight)
|
||||
setTargetCoords([rect.left - (mainRect?.left ?? 0), rect.top - (mainRect?.top ?? 0)])
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (targetWidth < 0 || targetHeight < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute rounded-lg bg-stone-100 dark:bg-stone-800 px-4 py-2 border border-stone-200 dark:border-stone-700"
|
||||
initial={{
|
||||
width: chatBoxWidth,
|
||||
height: chatBoxHeight,
|
||||
borderRadius: 8,
|
||||
left: chatBoxLeft,
|
||||
top: chatBoxTop,
|
||||
}}
|
||||
animate={{
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
borderTopLeftRadius: 100,
|
||||
borderTopRightRadius: 100,
|
||||
borderBottomRightRadius: 24,
|
||||
borderBottomLeftRadius: 100,
|
||||
left: targetCoords[0],
|
||||
top: targetCoords[1],
|
||||
}}
|
||||
transition={{
|
||||
left: { duration: 0.45, ease: [0.05, 0.8, 0.3, 1] },
|
||||
top: { duration: 0.45, ease: [0.3, 0, 0.2, 1] },
|
||||
width: { duration: 0.45, ease: [0.05, 0.8, 0.3, 1] },
|
||||
height: { duration: 0.45, ease: [0.05, 0.8, 0.3, 1] },
|
||||
}}
|
||||
onAnimationComplete={onAnimationEnd}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageList({
|
||||
messages,
|
||||
showLastMessage,
|
||||
onMessageStreamStart,
|
||||
onMessageStreamEnd,
|
||||
}: {
|
||||
messages: Message[]
|
||||
showLastMessage: boolean
|
||||
onMessageStreamStart: () => void
|
||||
onMessageStreamEnd: () => void
|
||||
}) {
|
||||
console.log({ messages, showLastMessage })
|
||||
return (
|
||||
<ul className="w-full flex flex-col gap-8 overflow-auto px-6 pt-20 md:px-0 md:pt-24 pb-34">
|
||||
{messages.map((message, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className={`flex justify-center ${index === messages.length - 1 && !showLastMessage ? "invisible" : ""}`}
|
||||
>
|
||||
<MessageContent
|
||||
message={message}
|
||||
onMessageStreamStart={onMessageStreamStart}
|
||||
onMessageStreamEnd={onMessageStreamEnd}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageContent({
|
||||
message,
|
||||
onMessageStreamStart,
|
||||
onMessageStreamEnd,
|
||||
}: {
|
||||
message: Message
|
||||
onMessageStreamStart: () => void
|
||||
onMessageStreamEnd: () => void
|
||||
}) {
|
||||
switch (message.role) {
|
||||
case "user":
|
||||
return <UserMessageBubble message={message} />
|
||||
case "system":
|
||||
return (
|
||||
<SystemMessageBubble
|
||||
message={message}
|
||||
onStreamStart={onMessageStreamStart}
|
||||
onStreamEnd={onMessageStreamEnd}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function UserMessageBubble({ message }: { message: UserMessage }) {
|
||||
return (
|
||||
<div className="w-full max-w-2xl flex justify-end">
|
||||
<div
|
||||
id={message.bubbleLayoutId}
|
||||
className="rounded-[100px_100px_24px_100px] bg-stone-100 dark:bg-stone-800 border border-stone-200 dark:border-stone-700 px-4 py-2"
|
||||
>
|
||||
{message.message}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SystemMessageBubble({
|
||||
message,
|
||||
onStreamStart,
|
||||
onStreamEnd,
|
||||
}: {
|
||||
message: SystemMessage
|
||||
onStreamStart: () => void
|
||||
onStreamEnd: () => void
|
||||
}) {
|
||||
const { currentContent, isStreaming } = useFakeStreaming(message.message)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
ref.current?.scrollIntoView({ behavior: "smooth", block: "end" })
|
||||
}, [currentContent])
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
onStreamStart()
|
||||
} else {
|
||||
onStreamEnd()
|
||||
}
|
||||
}, [isStreaming])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full max-w-2xl flex justify-start font-serif text-lg scroll-mb-34">
|
||||
<Streamdown
|
||||
animated={{ animation: "slideUp" }}
|
||||
isAnimating={isStreaming}
|
||||
linkSafety={{ enabled: false }}
|
||||
components={{
|
||||
// @ts-expect-error
|
||||
a: ({ className, ...props }) => <a className={`underline ${className}`} {...props} />,
|
||||
}}
|
||||
>
|
||||
{currentContent}
|
||||
</Streamdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isValidEmail(value: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
||||
}
|
||||
Reference in New Issue
Block a user