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>
This commit is contained in:
2026-03-08 02:54:56 +00:00
committed by GitHub
parent badc00c43b
commit 0a08706cf9
33 changed files with 2403 additions and 27 deletions

View File

@@ -0,0 +1,117 @@
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>
)
}

View File

@@ -0,0 +1,66 @@
export interface UserMessage {
role: "user"
message: string
bubbleLayoutId?: string
}
export interface SystemMessage {
role: "system"
message: string
}
export type Message = UserMessage | SystemMessage
function timeOfDay() {
const hours = new Date().getHours()
if (hours >= 5 && hours < 12) {
return "morning"
} else if (hours >= 12 && hours < 18) {
return "afternoon"
} else if (hours >= 18 && hours < 22) {
return "evening"
}
return "night"
}
export const INITIAL_MESSAGES: Message[] = [
{
role: "user",
message: "Who are you?",
},
{
role: "system",
message: `Hey! I'm **Aelis** — your personal assistant that brings you the right thing, at the right time, in the right place.
- Jubilee line down? I've already found you an alternative route.
- Dinner reservation at 8? I'll have the restaurant, directions, and the menu ready before you head out.
I learn your routines, anticipate what's next, and surface what matters before you even think to look for it.
I'm not ready yet — [@kennethnym](https://x.com/kennethnym) is still building me. **Drop your email below** and I'll let you know when I'm available.`,
},
]
export function waitListJoinedMessage(email: string): SystemMessage {
return {
role: "system",
message: `Thanks for joining the waitlist! I've sent you a confirmation email.
I'll send an email to **${email}** when I'm ready.
Have a good ${timeOfDay()}!`,
}
}
export function duplicateEmailMessage(): SystemMessage {
return {
role: "system",
message: `I appreciate your excitement! You are already on the waitlist. When I am ready, I will reach out again. Have a good ${timeOfDay()} :)`,
}
}
export function troubleMessage(): SystemMessage {
return {
role: "system",
message: `I apologize, but I am having trouble adding you to the waitlist. Could you refresh the page and try again please in a moment?`,
}
}

View File

@@ -0,0 +1,23 @@
import { useEffect, useMemo, useState } from "react"
export function useFakeStreaming(fullContent: string) {
const [currentContent, setCurrentContent] = useState("")
const [isStreaming, setIsStreaming] = useState(true)
useEffect(() => {
const words = fullContent.split(" ")
let i = 0
const id = setInterval(() => {
if (i > words.length) {
setIsStreaming(false)
clearInterval(id)
} else {
setCurrentContent(words.slice(0, i).join(" ") + " ")
i++
}
}, 20)
}, [fullContent])
return useMemo(() => ({ currentContent, isStreaming }), [currentContent, isStreaming])
}