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,393 @@
import { AnimatePresence, motion } from "motion/react"
import React, { useEffect, useLayoutEffect, useRef, useState } from "react"
import { Link, useFetcher } from "react-router"
import { Resend } from "resend"
import { Streamdown } from "streamdown"
import { ChatBox } from "~/chat/chat-box"
import {
duplicateEmailMessage,
INITIAL_MESSAGES,
troubleMessage,
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"
const PAGE_TITLE = "Aelis - Next Generation AI Assistant"
const PAGE_DESCRIPTION =
"Meet Aelis, a personal assistant that stays one step ahead of your day. Join the waitlist now."
export function meta({}: Route.MetaArgs) {
return [
{ title: PAGE_TITLE },
{
name: "description",
content: PAGE_DESCRIPTION,
},
{ property: "og:title", content: PAGE_TITLE },
{ property: "og:description", content: PAGE_DESCRIPTION },
{ property: "og:image", content: "https://ael.is/social-media-preview.png" },
{ property: "og:url", content: "https://ael.is" },
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: PAGE_TITLE },
{ name: "twitter:description", content: PAGE_DESCRIPTION },
{ name: "twitter:image", content: "https://ael.is/social-media-preview.png" },
]
}
const FormError = {
Duplicate: "duplicate",
Resend: "resend",
} as const
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 segmentId = "b80fb036-74a1-4f7d-bca5-2c035b696071"
const dup = await resend.contacts.get({
email,
})
if (dup.data) {
return { error: FormError.Duplicate }
}
const res = await resend.contacts.create({
email,
segments: [{ id: segmentId }],
})
if (res.error) {
console.log("Error adding contact to Resend:", res.error)
return { error: FormError.Resend, message: res.error.message }
}
const emailRes = await resend.emails.send({
from: "Aelis <no-reply@ael.is>",
to: email,
template: {
id: "waitlist-confirmation",
},
})
if (emailRes.error) {
// swallow the error since the user is already added to the waitlist, but log it for debugging
console.log("Error sending confirmation email:", emailRes.error)
}
return { email }
}
export default function Home() {
const [messages, setMessages] = useState<Message[]>(INITIAL_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) {
if (!isAnimatingSend) {
let errorMessage: SystemMessage
switch (fetcher.data.error) {
case FormError.Duplicate:
errorMessage = duplicateEmailMessage()
break
default: {
console.error(fetcher.data.error)
errorMessage = troubleMessage()
break
}
}
setMessages((messages) => [...messages, errorMessage])
}
}
}, [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-12 px-6 md:px-0 flex justify-center z-20"
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}
<ProgressiveBlur
direction="up"
className="absolute bottom-0 left-0 right-0 h-24 z-10 pointer-events-none"
/>
<footer className="absolute bottom-4 z-20">
<Link to="/privacy" className="text-xs opacity-50 underline">
Privacy policy
</Link>
</footer>
</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
}) {
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)
}

View File

@@ -0,0 +1,246 @@
import { Link } from "react-router"
import { Streamdown } from "streamdown"
import { AnimatedLogo, AnimatedLogoState } from "~/components/animated-logo"
import type { Route } from "./+types/privacy-policy"
export function meta({}: Route.MetaArgs) {
return [
{ title: "Privacy Policy — Aelis" },
{ name: "description", content: "Aelis privacy policy" },
]
}
export default function PrivacyPolicy() {
return (
<main className="relative max-w-2xl mx-auto px-6 py-16">
<Link to="/" className="block w-fit mb-8">
<AnimatedLogo className="size-10 pointer-events-none" state={AnimatedLogoState.Idle} />
</Link>
<Streamdown
isAnimating={false}
linkSafety={{ enabled: false }}
components={{
a: ({ className, ...props }) => <a className={`underline ${className}`} {...props} />,
}}
>
{POLICY}
</Streamdown>
<footer className="mt-16 pt-8 border-t border-stone-200 dark:border-stone-700">
<Link to="/" className="text-sm opacity-50 hover:opacity-75 underline">
Back to home
</Link>
</footer>
</main>
)
}
const POLICY = `# Privacy Policy
**Last updated:** March 5, 2026
This Privacy Policy describes how **Aelis** ("we", "us", or "our") collects, uses, and protects your personal information when you visit **https://ael.is** or interact with our services.
If you do not agree with this Privacy Policy, please do not use the website.
For any questions, contact: **[kenneth@nym.sh](mailto:kenneth@nym.sh)**
---
## 1. Information We Collect
### Personal Information You Provide
**In Short:** We collect personal information that you provide to us.
We collect personal information that you voluntarily provide when you express interest in our services, contact us, or sign up for the waitlist.
We collect your email address when you sign up for the waitlist so we can notify you when the product launches or provide related updates.
### Personal Information Provided by You
The personal information we collect may include:
* email addresses
You are responsible for ensuring the personal information you provide is accurate and up to date.
### Sensitive Information
We **do not collect or process sensitive personal information**.
### Information From Third Parties
We **do not collect personal information from third parties**.
---
## 2. How We Use Your Information
We process your information for the following purposes:
* To operate and maintain our services
* To communicate with you about product updates and launch announcements
* To send administrative information such as policy updates
* To prevent fraud or abuse
* To comply with legal obligations
* To protect someones safety when necessary
We only process personal information when we have a valid legal reason to do so.
---
## 3. Legal Bases for Processing (EU / UK)
If you are located in the European Economic Area (EEA) or the United Kingdom, we rely on the following legal bases to process personal information:
### Consent
You have given permission for us to process your personal information for a specific purpose.
### Contract
Processing is necessary to provide services you requested.
### Legal Obligations
Processing is required to comply with applicable laws.
### Vital Interests
Processing is necessary to protect someone's safety.
You may withdraw consent at any time by contacting us.
---
## 4. When and With Whom We Share Personal Information
We may share your personal information in limited situations.
### Service Providers
We may share information with trusted service providers that help us operate our website or manage communications.
### Business Transfers
We may transfer information during negotiations of a merger, sale of assets, financing, or acquisition of our business.
We **do not sell your personal information**.
---
## 5. How Long We Keep Your Information
We retain personal information only as long as necessary to:
* provide services
* comply with legal obligations
* resolve disputes
* enforce agreements
When we no longer need personal information, we delete or anonymize it where possible.
---
## 6. How We Keep Your Information Safe
We implement reasonable technical and organizational safeguards designed to protect personal information.
However, no electronic transmission or storage system is completely secure. We cannot guarantee absolute security.
---
## 7. Information From Minors
Our services are **not intended for individuals under 18 years old**.
We do not knowingly collect personal information from children. If we discover that we have collected such information, we will delete it.
If you believe a child has provided personal information, contact **[kenneth@nym.sh](mailto:kenneth@nym.sh)**.
---
## 8. Your Privacy Rights
Depending on your location, you may have rights regarding your personal information, including:
* the right to access your data
* the right to correct inaccurate data
* the right to delete your data
* the right to restrict processing
* the right to data portability
* the right to object to processing
* the right to withdraw consent
To exercise these rights, submit a request:
https://app.termly.io/dsar/b8633d03-406f-4133-b16e-ded63e893997
Or contact us at **[kenneth@nym.sh](mailto:kenneth@nym.sh)**.
---
## 9. Do Not Track (DNT)
Many browsers include a **Do Not Track (DNT)** feature.
Because there is currently no consistent standard for responding to DNT signals, we do not respond to them.
---
## 10. Global Privacy Control
We recognize **Global Privacy Control (GPC)** signals.
If your browser sends a GPC signal, we treat it as a request to opt out of the sale or sharing of personal information where applicable.
More information: https://globalprivacycontrol.org
---
## 11. Privacy Rights in Other Regions
Additional privacy rights may apply depending on your location, including:
* European Economic Area (EEA)
* United Kingdom
* Switzerland
* Canada
* United States
* Australia
* New Zealand
If you believe we are processing your personal information unlawfully, you may contact your local data protection authority.
---
## 12. Updates to This Privacy Policy
We may update this Privacy Policy from time to time.
When we do, we will update the **Last updated** date at the top of this document.
We encourage users to review this Privacy Policy regularly.
---
## 13. Contact Information
If you have questions or comments about this Privacy Policy, you may contact us:
**Aelis**
Email: **[kenneth@nym.sh](mailto:kenneth@nym.sh)**
---
## 14. Request Access, Update, or Deletion
Depending on applicable law, you may request access to, correction of, or deletion of your personal information.
Email:
**[kenneth@nym.sh](mailto:kenneth@nym.sh)**
`