Files
aris/apps/waitlist-website/app/chat/chat-box.tsx

118 lines
3.9 KiB
TypeScript
Raw Permalink Normal View History

feat: init waitlist website (#60) * feat: init waitlist website Co-authored-by: Ona <no-reply@ona.com> * feat[waitlist]: tweak copy Co-authored-by: Ona <no-reply@ona.com> * fix[waitlist]: reminify lottie json Co-authored-by: Ona <no-reply@ona.com> * feat[waitlist]: seo and preview stuff * chore[waitlist]: clean up * build[waitlist]: add fly.io config * feat(waitlist): add time-of-day greeting and duplicate email message Co-authored-by: Ona <no-reply@ona.com> * feat(waitlist): handle duplicate emails and send confirmation Co-authored-by: Ona <no-reply@ona.com> * chore: remove stray console.log Co-authored-by: Ona <no-reply@ona.com> * feat(waitlist): add privacy policy page Co-authored-by: Ona <no-reply@ona.com> * feat(waitlist): add footer with bottom progressive blur Co-authored-by: Ona <no-reply@ona.com> * feat(waitlist): add trouble message and improve error handling Co-authored-by: Ona <no-reply@ona.com> * fix(waitlist): fix timeOfDay logic, typo, and add audienceId Co-authored-by: Ona <no-reply@ona.com> * feat(waitlist): add .ico fallback favicon and style error page Co-authored-by: Ona <no-reply@ona.com> * chore(waitlist): add robots.txt, sitemap, clean dockerignore Co-authored-by: Ona <no-reply@ona.com> * feat(waitlist): add footer to privacy policy page Co-authored-by: Ona <no-reply@ona.com> * fix(waitlist): use segments instead of audienceId Co-authored-by: Ona <no-reply@ona.com> * fix[waitlist]: remove segmentId from dup check Co-authored-by: Ona <no-reply@ona.com> * fix(waitlist): reset logo animation on mouse leave Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <no-reply@ona.com>
2026-03-08 02:54:56 +00:00
import clsx from "clsx"
import { ArrowUpIcon, FileIcon, ImageIcon, PlusIcon, XIcon } from "lucide-react"
import { motion, useAnimate } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components"
export function ChatBox({
className,
validate,
onSubmit,
}: {
className?: string
validate?: (value: string) => boolean
onSubmit: (email: string) => void
disabled?: boolean
}) {
const [scope, animate] = useAnimate()
const [shouldShowInvalid, setShouldShowInvalid] = useState(false)
const clearInvalidStateTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(
() => () => {
if (clearInvalidStateTimeout.current) {
clearTimeout(clearInvalidStateTimeout.current)
}
},
[],
)
function showInvalidState() {
animate(scope.current, { x: [0, -6, 6, -4, 4, -2, 2, 0] }, { duration: 0.4, ease: "easeOut" })
if (clearInvalidStateTimeout.current) {
clearTimeout(clearInvalidStateTimeout.current)
}
setShouldShowInvalid(true)
clearInvalidStateTimeout.current = setTimeout(() => {
setShouldShowInvalid(false)
}, 500)
}
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const email = formData.get("liame")
if (typeof email === "string") {
const trimmed = email.trim()
if (trimmed.length === 0) {
showInvalidState()
} else if (validate && !validate(trimmed)) {
showInvalidState()
} else {
onSubmit(trimmed)
e.currentTarget.reset()
}
}
}
return (
<motion.form
ref={scope}
onSubmit={onFormSubmit}
className={`min-h-20 px-3 pt-2 pb-1.5 flex flex-col justify-between rounded-lg bg-stone-100 dark:bg-stone-800 border border-stone-200 dark:border-stone-700 ${className ?? ""} shadow-xs hover:shadow-sm`}
>
<input
name="liame"
className="w-full bg-transparent outline-none focus:outline-none ring-0 focus:ring-0"
/>
<div className="w-full flex justify-between">
<MenuTrigger>
<Button className="bg-transparent hover:bg-stone-200 dark:hover:bg-stone-700 active:bg-stone-300 dark:active:bg-stone-600 data-[pressed]:bg-stone-200 dark:data-[pressed]:bg-stone-700 rounded-full flex items-center justify-center p-1 -ml-1.5 active:inset-shadow-sm outline-none transition-transform duration-200 data-[pressed]:rotate-45">
<PlusIcon size={16} />
</Button>
<Popover
offset={4}
className="origin-bottom-left rounded-lg border border-stone-200 dark:border-stone-700 bg-stone-100 dark:bg-stone-800 shadow-lg p-1 min-w-40 outline-none data-[entering]:animate-[popover-in_150ms_ease-out] data-[exiting]:animate-[popover-out_100ms_ease-in]"
placement="top start"
>
<AttachmentMenu />
</Popover>
</MenuTrigger>
<button
type="submit"
disabled={shouldShowInvalid}
className={clsx(
"transition-all rounded-full flex items-center justify-center p-1 -mr-1.5 active:scale-95",
shouldShowInvalid
? "bg-red-400 hover:bg-red-300 text-stone-200 dark:text-stone-700"
: "bg-teal-600 hover:bg-teal-500 active:bg-teal-600 text-stone-200",
)}
>
{shouldShowInvalid ? <XIcon size={16} /> : <ArrowUpIcon size={16} />}
</button>
</div>
</motion.form>
)
}
function AttachmentMenu() {
return (
<Menu className="outline-none">
<MenuItem
className="flex items-center gap-2 px-2 py-1 rounded-md cursor-default outline-none hover:bg-stone-200 dark:hover:bg-stone-700 focus:bg-stone-200 dark:focus:bg-stone-700"
onAction={() => {}}
>
<ImageIcon size={14} />
Photos
</MenuItem>
<MenuItem
className="flex items-center gap-2 px-2 py-1 rounded-md cursor-default outline-none hover:bg-stone-200 dark:hover:bg-stone-700 focus:bg-stone-200 dark:focus:bg-stone-700"
onAction={() => {}}
>
<FileIcon size={14} />
Files
</MenuItem>
</Menu>
)
}