14 Commits

Author SHA1 Message Date
ramvignesh-b f2a1abe7eb refactor: refactor E2E auth helper and mail parsing logic 2026-04-28 19:54:22 +05:30
ramvignesh-b df73fb6b6a refactor: update email notification to account for proper arguments 2026-04-28 18:42:00 +05:30
ramvignesh-b 412abd912c Merge branch 'main' of https://github.com/ramvignesh-b/pi-ku into feature/saajan-persona 2026-04-28 18:34:53 +05:30
ramvignesh-b 8a9ded42b5 feat: add secure proxy configuration for http to https 2026-04-28 18:02:27 +05:30
ramvignesh-b f522a369ab chore: streamline frontend Docker build arguments 2026-04-28 17:17:24 +05:30
ramvignesh-b 72346d8721 fix: remove render test with no value and add aria helper for btn identification 2026-04-28 03:20:48 +05:30
ramvignesh-b ac2c7b0eac feat: add ssajan in lots of flows 2026-04-28 03:12:25 +05:30
ramvignesh-b 935a43c311 refactor: expose props on ui components 2026-04-28 03:11:31 +05:30
ramvignesh-b 4f178a3b03 refactor: add proper props interfaces 2026-04-28 03:10:42 +05:30
ramvignesh-b 867b01bd1e feat: add post seal modal for vault 2026-04-28 03:08:34 +05:30
ramvignesh-b c9ee9f7825 feat: add aesthetic noise background and implement Saajan component in register and login 2026-04-28 03:06:44 +05:30
ramvignesh-b 02070cee4a feat: init saajan component 2026-04-28 01:01:27 +05:30
ramvignesh-b 409fc76619 feat: add template based email content (html + plaintext fallback) 2026-04-28 01:00:34 +05:30
RamVignesh B 48b6a06571 Feature/s3 integration (#2)
* feat: add s3 storage for media

* refactor: update letter decryption test to look for key request properties

* fix: update db url to be ipv4 for ssl context match

* ci: output backend logs to the console

* ci: unset email host creds for local testing

---------

Co-authored-by: ramvignesh-b <ramvignesh-b@github.com>
2026-04-26 21:40:00 +05:30
37 changed files with 654 additions and 235 deletions
+18
View File
@@ -25,9 +25,12 @@ env_file = os.environ.get("PIKU_ENV_FILE", os.path.join(BASE_DIR.parent, ".env")
if os.path.exists(env_file): if os.path.exists(env_file):
environ.Env.read_env(env_file, overwrite=False) environ.Env.read_env(env_file, overwrite=False)
# Security Settings
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["127.0.0.1"]) ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["127.0.0.1"])
ALLOWED_HOSTS.append(env("FRONTEND_DOMAIN", default="127.0.0.1")) ALLOWED_HOSTS.append(env("FRONTEND_DOMAIN", default="127.0.0.1"))
ALLOWED_HOSTS.append(env("BACKEND_DOMAIN", default="127.0.0.1")) ALLOWED_HOSTS.append(env("BACKEND_DOMAIN", default="127.0.0.1"))
# NOTE: Set to forward https when using reverse proxy
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[]) CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
@@ -81,6 +84,21 @@ MIDDLEWARE = [
"django_structlog.middlewares.RequestMiddleware", "django_structlog.middlewares.RequestMiddleware",
] ]
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
ROOT_URLCONF = "config.urls" ROOT_URLCONF = "config.urls"
+20 -1
View File
@@ -3,8 +3,10 @@ from datetime import UTC, datetime
import structlog import structlog
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from django.core.mail import send_mail from django.core.mail import send_mail
from django.template.loader import render_to_string
from config import settings from config import settings
from config.settings import FRONTEND_URLS
from letters.models import Letter from letters.models import Letter
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@@ -23,9 +25,26 @@ def notify_unlocked_letter(letter):
""" """
author = letter.user.get_username() author = letter.user.get_username()
try: try:
send_mail(subject="", message="", from_email=settings.FROM_EMAIL, recipient_list=[author], fail_silently=False) letter_link = f"{FRONTEND_URLS[0]}/read/{letter.public_id}"
subject = "A letter. Written for this exact moment."
context = {
"pen_name": letter.user.first_name,
"cta": {"title": "View what you wrote", "link": letter_link},
"footnote": True,
}
plaint_content = render_to_string("email/vault_unlock.txt", context=context)
html_content = render_to_string("email/vault_unlock.html", context=context)
send_mail(
subject=subject,
message=plaint_content,
from_email=settings.FROM_EMAIL,
recipient_list=[author],
fail_silently=False,
html_message=html_content,
)
letter.notified_at = datetime.now(UTC) letter.notified_at = datetime.now(UTC)
letter.save() letter.save()
logger.info(f"Successfully notified {author} of unlocked letter")
except Exception: except Exception:
logger.exception(f"Failed to notify {author} of unlocked letter") logger.exception(f"Failed to notify {author} of unlocked letter")
+1
View File
@@ -396,6 +396,7 @@ class LetterTaskTest(TestCase):
from_email=settings.FROM_EMAIL, from_email=settings.FROM_EMAIL,
recipient_list=[self.user.email], recipient_list=[self.user.email],
fail_silently=False, fail_silently=False,
html_message=ANY,
) )
self.assertIsNotNone(letter_to_notify1.notified_at) self.assertIsNotNone(letter_to_notify1.notified_at)
+22
View File
@@ -0,0 +1,22 @@
{% extends 'email/base.html' %}
{% block content %}
<div style="padding: 15px; font-style: italic">
<p>{{ pen_name }},</p>
<p>
Your destination is one train away.
</p>
<p>I've been keeping a place for your words.<br/>
Come when you're ready.</p>
</div>
{% endblock %}
{% block footnote %}
This link expires in 24 hours.<br/>
I'm patient, but not endlessly so.
{% endblock %}
{% block footer %}
Didn't write to me? Then someone else did.<br/>
Ignore this. I'll forget you were ever here.
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
pi. ku.
-------------------------------------------
{{pen_name}},
Your destination is one train away.
I've been keeping a place for your words.
Come when you're ready.
{{ cta.title }} -> {{ cta.link }}
-------------------------------------------
This link expires in 24 hours.
I'm patient, but not endlessly so.
-------------------------------------------
Didn't write to me? Then someone else did.
Ignore this. I'll forget you were ever here.
+100
View File
@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>pi. ku.</title>
</head>
<body style="margin:0; padding:0; background-color:#1a1712;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="background-color:#1a1712;">
<tr>
<td align="center" style="padding: 48px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"
style="max-width:480px; width:100%;">
{# Logo #}
<tr>
<td align="left" style="padding-bottom: 36px;">
<img src="https://cdn.jsdelivr.net/gh/ramvignesh-b/cdn@main/pi-ku_logo.png" width="100"
height="50" alt="Pi.Ku"
style="display:block;">
</td>
</tr>
{# Body #}
<tr>
<td style="font-family: Lora, Georgia, serif;
font-size: 16px;
line-height: 1.9;
color: #cdccca;
font-style: italic;
padding-bottom: 32px;">
{% block content %}
{% endblock %}
</td>
</tr>
{# CTA #}
{% if cta %}
<tr>
<td align="left" style="padding-bottom: 32px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="background-color: #301e19; border-radius: 3px;">
<a href='{{ cta.link }}'
style="display: inline-block;
padding: 12px 28px;
font-family: Georgia, serif;
font-size: 14px;
color: #f5e6c8;
text-decoration: none;
letter-spacing: 0.04em;">
{{ cta.title }}
</a>
</td>
</tr>
</table>
</td>
</tr>
{% endif %}
{% if footnote %}
<tr>
<td style="font-family: Lora, Georgia, serif;
font-size: 13px;
font-style: italic;
color: #7a7974;
padding-bottom: 40px;
line-height: 1.8;">
{% block footnote %}
{% endblock %}
</td>
</tr>
{% endif %}
{# Footer #}
<tr>
<td style="border-top: 1px solid #2e2c29; padding-bottom: 24px; font-size: 0;">&nbsp;</td>
</tr>
<tr>
<td style="font-family: Lora, serif;
font-size: 13px;
font-style: italic;
color: #5a5957;
line-height: 1.8;">
{% block footer %}
{% endblock %}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
{% extends 'email/base.html' %}
{% block content %}
<p>
Time has a way of making things clearer.<br/>
Or heavier. Sometimes both.
</p>
<p>
You had something to say at this exact moment.<br/>
I kept it exactly as you left it. <br/>
Not a word changed. Not a word read.
</p>
{% endblock %}
{% block footnote %}
<p>
You're ready now. Or maybe you're still not.<br/>
Open it anyway. You won't regret it.
</p>
{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
pi. ku.
-------------------------------------------
{{pen_name}},
Time has a way of making things clearer.
Or heavier. Sometimes both.
You had something to say at this exact moment.
I kept it exactly as you left it.
Not a word changed. Not a word read.
{{ cta.title }} -> {{ cta.link }}
-------------------------------------------
You're ready now. Or maybe you're still not.
Open it anyway. You won't regret it.
+20 -10
View File
@@ -1,6 +1,7 @@
from django.conf import settings from django.conf import settings
from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.tokens import default_token_generator
from django.core.mail import send_mail from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.encoding import force_bytes from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode from django.utils.http import urlsafe_base64_encode
@@ -9,16 +10,25 @@ def send_activation_email(user):
token = default_token_generator.make_token(user) token = default_token_generator.make_token(user)
uid = urlsafe_base64_encode(force_bytes(user.public_id)) uid = urlsafe_base64_encode(force_bytes(user.public_id))
activation_url = f"{settings.FRONTEND_URLS[0]}/activate/{uid}/{token}" activation_url = f"{settings.FRONTEND_URLS[0]}/activate/{uid}/{token}"
subject = "Activate Your Piku Account" subject = "Activate your pi. ku. account"
message = f"""Hi {user.full_name}, context = {
"pen_name": user.full_name,
Welcome to Pi Ku. "footnote": True,
"cta": {
Please click the link below to activate your account: "title": "Onboard",
>> {activation_url} "link": activation_url,
},
If you did not create this account, please ignore this email.""" }
send_mail(subject, message, settings.FROM_EMAIL, [user.email], fail_silently=False) html_content = render_to_string("email/activation.html", context)
plain_content = render_to_string("email/activation.txt", context)
send_mail(
subject=subject,
message=plain_content,
from_email=settings.FROM_EMAIL,
recipient_list=[user.email],
fail_silently=False,
html_message=html_content,
)
return True return True
-7
View File
@@ -4,15 +4,8 @@ COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
COPY . . COPY . .
ARG BACKEND_DOMAIN
ARG BACKEND_PORT
ARG SSL_ENABLED
ARG VITE_API_URL ARG VITE_API_URL
ENV BACKEND_DOMAIN=$BACKEND_DOMAIN
ENV BACKEND_PORT=$BACKEND_PORT
ENV SSL_ENABLED=$SSL_ENABLED
ENV VITE_API_URL=$VITE_API_URL ENV VITE_API_URL=$VITE_API_URL
RUN bun run build:prod RUN bun run build:prod
+1 -1
View File
@@ -185,7 +185,7 @@ test.describe("Letter Drafting (Real Backend)", () => {
await expect(page.getByText(/your letter is sealed/i)).toBeVisible({ await expect(page.getByText(/your letter is sealed/i)).toBeVisible({
timeout: 10000, timeout: 10000,
}); });
await page.getByRole("button", { name: /keep it/i }).click(); await page.getByRole("button", { name: /keep it to myself/i }).click();
// Open "Kept" section - search for the section with id='kept' and click its toggle button // Open "Kept" section - search for the section with id='kept' and click its toggle button
logger.info(">> [Drawer] Opening Kept section..."); logger.info(">> [Drawer] Opening Kept section...");
+5 -7
View File
@@ -14,13 +14,13 @@ const logger = pino({
/** /**
* Completes the full registration -> activation -> login cycle. * Completes the full registration -> activation -> login cycle.
*/ */
export async function registerAndLogin( async function registerAndLogin(
page: Page, page: Page,
email: string, email: string,
fullName: string, fullName: string,
password: string, password: string,
) { ) {
// 1. Registration // Register the User
logger.info(`[Auth] Registering user: ${email}`); logger.info(`[Auth] Registering user: ${email}`);
await page.goto("/onboard"); await page.goto("/onboard");
await page.getByLabel(/pen name/i).fill(fullName); await page.getByLabel(/pen name/i).fill(fullName);
@@ -31,7 +31,7 @@ export async function registerAndLogin(
await expect(page).toHaveURL(/\/verify-email/); await expect(page).toHaveURL(/\/verify-email/);
// 2. Activation via Mailpit // Get activation URL from Mailpit and activate user
logger.info(`[Auth] Polling Mailpit for activation email...`); logger.info(`[Auth] Polling Mailpit for activation email...`);
const activationLink = await MailpitHelper.getActivationLink(email); const activationLink = await MailpitHelper.getActivationLink(email);
@@ -40,11 +40,11 @@ export async function registerAndLogin(
await expect(page.getByText(/account activated/i)).toBeVisible(); await expect(page.getByText(/account activated/i)).toBeVisible();
await page.getByRole("button", { name: /start writing/i }).click(); await page.getByRole("button", { name: /start writing/i }).click();
// 3. Login // Dismiss the Welcom Modal and Perform Login
logger.info(`[Auth] Logging in...`); logger.info(`[Auth] Logging in...`);
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/);
const welcomeButton = page.getByRole("button", { name: /i understand/i }); const welcomeButton = page.getByRole("button", { name: /I'll remember/i });
await welcomeButton.waitFor({ state: "visible", timeout: 10000 }); await welcomeButton.waitFor({ state: "visible", timeout: 10000 });
await welcomeButton.click(); await welcomeButton.click();
await expect(welcomeButton).toBeHidden(); await expect(welcomeButton).toBeHidden();
@@ -56,6 +56,4 @@ export async function registerAndLogin(
await expect(page).toHaveURL(/\/drawer/); await expect(page).toHaveURL(/\/drawer/);
logger.info(`[Auth] Successfully authenticated ${email}`); logger.info(`[Auth] Successfully authenticated ${email}`);
} }
// Maintain backward compatibility if needed, or update callers
export const AuthHelper = { registerAndLogin }; export const AuthHelper = { registerAndLogin };
+2 -2
View File
@@ -31,8 +31,8 @@ export const MailpitHelper = {
); );
const details = await detailRes.json(); const details = await detailRes.json();
const body = details.HTML || details.Text || ""; const body = details.Text || "";
const match = body.match(/https?:\/\/\S+activate\/\S+/); const match = body.match(/https?:\/\/\S*activate\S*/);
if (match) return match[0]; if (match) return match[0];
} }
+1 -1
View File
@@ -31,7 +31,7 @@ export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<main className="min-h-screen bg-base-200 flex items-center justify-center w-full"> <main className="relative min-h-screen min-w-screen flex items-center justify-center w-full bg-base-200 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/noise.gif')]">
<Suspense fallback={<SplashScreen />}> <Suspense fallback={<SplashScreen />}>
<Routes> <Routes>
<Route path={ROUTES.HOME} element={<Home />} /> <Route path={ROUTES.HOME} element={<Home />} />
Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+5 -5
View File
@@ -1,7 +1,7 @@
import { DotIcon } from "@phosphor-icons/react"; import { DotIcon } from "@phosphor-icons/react";
import "@fontsource/knewave/400.css"; import "@fontsource/knewave/400.css";
export default function Logo({ scale = 2 }) { export default function Logo({ scale = 1 }) {
return ( return (
<div <div
role="img" role="img"
@@ -9,16 +9,16 @@ export default function Logo({ scale = 2 }) {
className="inline-flex items-baseline justify-center leading-none select-none" className="inline-flex items-baseline justify-center leading-none select-none"
style={{ fontFamily: "'Knewave', serif", scale }} style={{ fontFamily: "'Knewave', serif", scale }}
> >
<span className={`text-xl font-light text-accent`}>&nbsp;Pi</span> <span className={`text-3xl font-light text-accent`}>Pi</span>
<DotIcon <DotIcon
weight="fill" weight="fill"
size={6} size={12}
className={`text-primary translate-y-1 -mx-px`} className={`text-primary translate-y-1 -mx-px`}
/> />
<span className={`text-xl font-light text-accent`}>&nbsp;Ku</span> <span className={`text-3xl font-light text-accent`}>&nbsp;Ku</span>
<DotIcon <DotIcon
weight="fill" weight="fill"
size={6} size={12}
className={`text-primary translate-y-1 -mx-px`} className={`text-primary translate-y-1 -mx-px`}
/> />
</div> </div>
+10 -8
View File
@@ -2,6 +2,15 @@ import { LockIcon, LockKeyOpenIcon } from "@phosphor-icons/react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { PATHS } from "../../config/routes"; import { PATHS } from "../../config/routes";
interface LetterItemProps {
preview: string;
timestamp: string;
id: string;
status: "DRAFT" | "SEALED" | "BURNED";
unlock_at?: string;
isLocked?: boolean;
}
export function LetterItem({ export function LetterItem({
preview, preview,
timestamp, timestamp,
@@ -9,14 +18,7 @@ export function LetterItem({
status, status,
unlock_at, unlock_at,
isLocked = false, isLocked = false,
}: { }: LetterItemProps) {
preview: string;
timestamp: string;
id: string;
status: "DRAFT" | "SEALED" | "BURNED";
unlock_at?: string;
isLocked?: boolean;
}) {
const navigate = useNavigate(); const navigate = useNavigate();
function handleNavigate(): void { function handleNavigate(): void {
if (isLocked) return; if (isLocked) return;
@@ -5,11 +5,13 @@ import { PATHS, ROUTES } from "../../config/routes";
interface PostSealModalProps { interface PostSealModalProps {
sealedTargetId: string | null; sealedTargetId: string | null;
navigate: NavigateFunction; navigate: NavigateFunction;
type: "KEPT" | "VAULT";
} }
export function PostSealModal({ export function PostSealModal({
sealedTargetId, sealedTargetId,
navigate, navigate,
type = "KEPT",
}: PostSealModalProps) { }: PostSealModalProps) {
if (!sealedTargetId) return null; if (!sealedTargetId) return null;
return ( return (
@@ -20,33 +22,61 @@ export function PostSealModal({
<p className="text-base-content/60"> <p className="text-base-content/60">
It's encrypted and always safe in your drawer. It's encrypted and always safe in your drawer.
</p> </p>
<p className="text-base-content font-sans"> {type === "KEPT" ? (
When you're ready, <p className="text-base-content/80 text-sm font-sans">
<br /> When you're ready,
you can{" "} <br />
<span className="text-primary font-bold font-display">read</span> it,{" "} you can{" "}
<span className="text-accent font-bold font-display">send</span> it to <span className="text-primary font-bold font-display">read</span>{" "}
someone, or{" "} it, <span className="text-accent font-bold font-display">send</span>{" "}
<span className="text-error font-bold font-display">burn</span> it to it to someone, or{" "}
release <span className="text-error font-bold font-display">burn</span> it
</p> to release
</p>
) : (
<p className="text-base-content/80 text-sm font-sans">
Be assured that the letter will find you when the time is right.
<br />
Till then,{" "}
<span className="font-bold font-display text-primary">
take a deep breath
</span>
,{" "}
<span className="font-bold font-display text-accent">manifest</span>
, and{" "}
<span className="font-bold font-display text-success">
let it rest
</span>
.
</p>
)}
<div className="modal-action w-full justify-center gap-3 mt-4 mb-4"> <div className="modal-action w-full justify-center gap-3 mt-4 mb-4">
<button {type === "KEPT" ? (
type="button" <>
className="btn btn-ghost btn-sm" <button
onClick={() => navigate(ROUTES.DRAWER)} type="button"
> className="btn btn-ghost btn-sm"
Keep it to myself onClick={() => navigate(ROUTES.DRAWER)}
</button> >
<button Keep it to myself
type="button" </button>
className="btn btn-primary btn-sm" <button
onClick={() => type="button"
navigate(PATHS.read(sealedTargetId), { replace: true }) className="btn btn-primary btn-sm"
} onClick={() => navigate(PATHS.read(sealedTargetId))}
> >
View letter View letter
</button> </button>
</>
) : (
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => navigate(ROUTES.DRAWER)}
>
Step Away...
</button>
)}
</div> </div>
</div> </div>
</div> </div>
+43 -24
View File
@@ -102,10 +102,24 @@ export function ToolBar({
</div> </div>
<button <button
type="button" type="button"
aria-label="Help"
onClick={() => setSealBtnClicked(false)} onClick={() => setSealBtnClicked(false)}
className={`bg-transparent cursor-pointer -mt-2 absolute z-1000001 right-0 text-primary ${sealBtnClicked ? "" : "hidden"}`} className={`bg-transparent cursor-pointer -mt-2 absolute z-1000001 right-0 text-primary ${sealBtnClicked ? "" : "hidden"}`}
> >
<QuestionIcon weight="duotone" size={20} className={""} /> <div className="tooltip tooltip-left">
<div className="tooltip-content -translate-x-38 text-left">
<span className="font-bold text-accent">Seal</span> puts the letter
in an envelope, ready to be read right away.
<div className="divider my-0"></div>
<span className="font-bold text-success">Vault</span> keeps it
locked away until the right moment, even from yourself.
</div>
<QuestionIcon
weight="duotone"
size={20}
className={"absolute -translate-x-38 -translate-y-3"}
/>
</div>
</button> </button>
</div> </div>
); );
@@ -136,21 +150,23 @@ export function VaultConfirmModal({
setUnlockDate, setUnlockDate,
}: VaultConfirmModalProps) { }: VaultConfirmModalProps) {
return ( return (
<div className={"modal modal-open bg-base-100/20 backdrop-blur-md"}> <div className={"modal modal-open bg-base-100/10 backdrop-blur-md"}>
<div className="modal-box p-12 flex flex-col items-center"> <div className="modal-box p-12 flex flex-col items-center bg-base-100/90">
<VaultIcon <VaultIcon
size={48} size={48}
className="text-primary mx-auto mb-8 animate-pulse" className="text-primary mx-auto mb-8 animate-pulse"
/> />
<h3 className="font-serif text-3xl">Vault this letter?</h3> <h3 className="font-serif text-3xl">Take it away, then?</h3>
<p className="text-base-content/60 text-sm text-center mt-4"> <p className="text-base-content/60 text-sm text-center mt-4">
Vaulting locks the letter permanently and will be{" "} By vaulting this letter, you ask me to hold on to this.
<span className={"font-bold text-primary"}>mailed</span> to you
automatically on the unlock date.
<br /> <br />
<span className={"underline"}> I'll remember to mail you this on the unlock date.
You cannot edit or view the contents of the letter until then. <br />
<span className={"font-bold text-primary"}>
{" "}
But I won't let you read or rewrite this letter until then.
</span> </span>
<br />
</p> </p>
<form <form
onSubmit={async (e) => { onSubmit={async (e) => {
@@ -158,11 +174,13 @@ export function VaultConfirmModal({
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const unlockDateStr = formData.get("vault-date") as string; const unlockDateStr = formData.get("vault-date") as string;
const newUnlockDate = new Date(unlockDateStr); const newUnlockDate = new Date(unlockDateStr);
console.log(newUnlockDate);
setUnlockDate(newUnlockDate); setUnlockDate(newUnlockDate);
await onSave("VAULT", newUnlockDate); await onSave("VAULT", newUnlockDate);
setConfirmModal(null); setConfirmModal(null);
}} }}
id="vault-form" id="vault-form"
className="min-w-75"
> >
<div className={"divider tracking-tightest font-display text-sm"}> <div className={"divider tracking-tightest font-display text-sm"}>
Set an unlock date Set an unlock date
@@ -173,21 +191,22 @@ export function VaultConfirmModal({
className="input input-bordered w-full" className="input input-bordered w-full"
name="vault-date" name="vault-date"
/> />
<button <div className="w-full flex justify-center gap-8 mt-4">
className="btn btn-primary mt-4" <button
type="submit" type="button"
form="vault-form" className="btn btn-ghost btn-sm mt-4"
> onClick={() => setConfirmModal(null)}
Vault >
</button> I need time
</button>
<button <button
type="button" className="btn btn-primary btn-sm mt-4"
className="btn btn-ghost mt-4" type="submit"
onClick={() => setConfirmModal(null)} form="vault-form"
> >
Cancel Take it
</button> </button>
</div>
</form> </form>
</div> </div>
</div> </div>
+8 -1
View File
@@ -1,12 +1,19 @@
import { CampfireIcon, FlameIcon, XCircleIcon } from "@phosphor-icons/react"; import { CampfireIcon, FlameIcon, XCircleIcon } from "@phosphor-icons/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
interface BurnModalProps {
burnLetter: () => void;
isBurning: boolean;
setShowBurnModal: (show: boolean) => void;
setRevealState: (state: "sealed" | "revealed" | "burning" | "burned") => void;
}
export function BurnModal({ export function BurnModal({
burnLetter, burnLetter,
isBurning, isBurning,
setShowBurnModal, setShowBurnModal,
setRevealState, setRevealState,
}) { }: BurnModalProps) {
const [flameOn, setFlameOn] = useState(0); const [flameOn, setFlameOn] = useState(0);
const [rotate, setRotate] = useState(0); const [rotate, setRotate] = useState(0);
const [burnClicked, setBurnClicked] = useState(false); const [burnClicked, setBurnClicked] = useState(false);
@@ -9,6 +9,7 @@ export interface EnvelopeRevealProps {
onRevealComplete: () => void; onRevealComplete: () => void;
ignite: boolean; ignite: boolean;
isFlip?: boolean; isFlip?: boolean;
isInteractive?: boolean;
} }
export function EnvelopeReveal({ export function EnvelopeReveal({
@@ -17,6 +18,7 @@ export function EnvelopeReveal({
onRevealComplete, onRevealComplete,
ignite, ignite,
isFlip, isFlip,
isInteractive = true,
}: EnvelopeRevealProps) { }: EnvelopeRevealProps) {
const [revealLetter, setRevealLetter] = useState(false); const [revealLetter, setRevealLetter] = useState(false);
const [isFlipped, setIsFlipped] = useState(!!isFlip); const [isFlipped, setIsFlipped] = useState(!!isFlip);
@@ -67,6 +69,7 @@ export function EnvelopeReveal({
type="checkbox" type="checkbox"
className="transition checkbox absolute h-full w-full text-transparent bg-transparent z-100" className="transition checkbox absolute h-full w-full text-transparent bg-transparent z-100"
ref={flapCheckbox} ref={flapCheckbox}
disabled={!isInteractive}
/> />
</div> </div>
<img <img
@@ -103,6 +106,7 @@ export function EnvelopeReveal({
<button <button
id="env-front" id="env-front"
type="button" type="button"
disabled={!isInteractive}
className={`text-left p-10 absolute inset-0 backface-hidden w-110 bg-base-200 z-99 rounded-md -translate-x-2 ${isFlipped ? "pointer-events-none" : ""}`} className={`text-left p-10 absolute inset-0 backface-hidden w-110 bg-base-200 z-99 rounded-md -translate-x-2 ${isFlipped ? "pointer-events-none" : ""}`}
onClick={() => setIsFlipped((prev) => !prev)} onClick={() => setIsFlipped((prev) => !prev)}
> >
@@ -1,7 +1,11 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ROUTES } from "../../config/routes"; import { ROUTES } from "../../config/routes";
export function PostActionOverlay({ revealState }) { interface PostActionOverlayProps {
revealState: "sealed" | "revealed" | "burning" | "burned";
}
export function PostActionOverlay({ revealState }: PostActionOverlayProps) {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<div <div
@@ -4,7 +4,12 @@ import {
XCircleIcon, XCircleIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
export function ShareModal({ shareLink, setShareLink }) { interface ShareModalProps {
shareLink: string | null;
setShareLink: (link: string | null) => void;
}
export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
const copyToClipboard = async () => { const copyToClipboard = async () => {
if (!shareLink) return; if (!shareLink) return;
await navigator.clipboard.writeText(shareLink); await navigator.clipboard.writeText(shareLink);
+3
View File
@@ -6,6 +6,7 @@ interface FormFieldProps {
placeholder?: string; placeholder?: string;
registration: UseFormRegisterReturn; registration: UseFormRegisterReturn;
error?: string; error?: string;
handleFocus?: () => void;
} }
export default function FormField({ export default function FormField({
@@ -14,6 +15,7 @@ export default function FormField({
placeholder, placeholder,
registration, registration,
error, error,
handleFocus,
}: FormFieldProps) { }: FormFieldProps) {
return ( return (
<div className="form-control"> <div className="form-control">
@@ -31,6 +33,7 @@ export default function FormField({
className={`input input-bordered focus:input-primary ${ className={`input input-bordered focus:input-primary ${
error ? "input-error" : "" error ? "input-error" : ""
}`} }`}
onFocus={handleFocus}
/> />
{error && <p className="text-error">{error}</p>} {error && <p className="text-error">{error}</p>}
</div> </div>
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
import sf_mini from "../../assets/sf_mini.png";
interface SaajanProps {
message: string;
position?: "top" | "left" | "right" | "bottom";
}
export default function Saajan({ message, position = "right" }: SaajanProps) {
const [animate, setAnimate] = useState<boolean>(false);
const [tooltipPosition, setTooltipPosition] =
useState<string>("tooltip-right");
const [alignment, setAlignment] = useState<string>("justify-start");
useEffect(() => {
setAnimate(true);
const timeout = setTimeout(() => {
setAnimate(false);
}, 1000);
return () => {
clearTimeout(timeout);
};
}, []);
useEffect(() => {
setTooltipPosition(`tooltip-${position}`);
if (position === "top") {
setAlignment("justify-center");
}
if (position === "right") {
setAlignment("justify-start");
}
if (position === "left") {
setAlignment("justify-end");
}
}, [position]);
return (
<div className={`relative w-full flex ${alignment}`}>
<div
className={`tooltip tooltip-open ${tooltipPosition} before:max-w-xs before:whitespace-pre-line italic before:text-left`}
data-tip={message}
>
<img
src={sf_mini}
alt="saajan"
className={`sepia-20 w-35 -mb-3 ${animate ? "animate-[pulse_.5s_ease_2]" : ""}`}
/>
</div>
</div>
);
}
+1 -1
View File
@@ -86,7 +86,7 @@ export function useLetters() {
return { return {
drafts: letters.filter((l) => l.status === "DRAFT"), drafts: letters.filter((l) => l.status === "DRAFT"),
kept: letters.filter((l) => l.type === "KEPT" && l.status === "SEALED"), kept: letters.filter((l) => l.type === "KEPT" && l.status === "SEALED"),
vault: letters.filter((l) => l.type === "VAULT"), vault: letters.filter((l) => l.type === "VAULT" && l.status === "SEALED"),
sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"), sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"),
}; };
}, [letters]); }, [letters]);
+1 -4
View File
@@ -47,10 +47,7 @@
--font-display: "Playwrite HR Lijeva Variable", cursive; --font-display: "Playwrite HR Lijeva Variable", cursive;
--font-sans: "Jost Variable", sans-serif; --font-sans: "Jost Variable", sans-serif;
--font-serif: "Playfair Display Variable", serif; --font-serif: "Playfair Display Variable", serif;
--color-glass-bg: rgba(28, --color-glass-bg: rgba(28, 22, 16, 0.45);
22,
16,
0.45);
--shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6); --shadow-warm: 0 20px 50px -12px rgba(30, 20, 12, 0.6);
--radius-xl: 1.5rem; --radius-xl: 1.5rem;
--color-paper: oklch(97% 0.008 80); --color-paper: oklch(97% 0.008 80);
+7 -9
View File
@@ -16,8 +16,6 @@ export default function Activate() {
useEffect(() => { useEffect(() => {
if (!(uidb64 && token) || hasCalled.current) return; if (!(uidb64 && token) || hasCalled.current) return;
// prevent double api calls
hasCalled.current = true; hasCalled.current = true;
const activateAccount = async () => { const activateAccount = async () => {
@@ -46,7 +44,7 @@ export default function Activate() {
)} )}
{status === "success" && ( {status === "success" && (
<div className="flex flex-col items-center gap-6 animate-in fade-in zoom-in duration-500"> <div className="flex flex-col items-center gap-6 duration-500">
<div className="bg-success/10 p-4 rounded-full"> <div className="bg-success/10 p-4 rounded-full">
<CheckCircleIcon <CheckCircleIcon
size={64} size={64}
@@ -57,13 +55,12 @@ export default function Activate() {
<h2 className="font-display text-xl text-success"> <h2 className="font-display text-xl text-success">
Account Activated! Account Activated!
</h2> </h2>
<p className="opacity-70 mb-8 leading-relaxed"> <p className="opacity-70 leading-relaxed">
Welcome to <Logo /> Welcome to <Logo scale={1} />
<br /> <br />
Your identity is now verified and ready for timeless letters. Your identity is now verified and ready for timeless letters.
</p> </p>
<div className="divider opacity-10"></div> <div className="divider opacity-10 my-0"></div>
<button <button
type="button" type="button"
className="btn btn-primary w-full shadow-lg" className="btn btn-primary w-full shadow-lg"
@@ -85,16 +82,17 @@ export default function Activate() {
<XCircleIcon size={64} weight="duotone" className="text-error" /> <XCircleIcon size={64} weight="duotone" className="text-error" />
</div> </div>
<h2 className="font-display text-xl text-error">Activation Failed</h2> <h2 className="font-display text-xl text-error">Activation Failed</h2>
<p className="opacity-70 mb-8 leading-relaxed"> <p className="opacity-70 leading-relaxed">
The link might be expired or already used. Please try registering The link might be expired or already used. Please try registering
again. again.
</p> </p>
<div className="divider opacity-10 my-0"></div>
<button <button
type="button" type="button"
className="btn btn-ghost w-full" className="btn btn-ghost w-full"
onClick={() => navigate(ROUTES.ONBOARD)} onClick={() => navigate(ROUTES.ONBOARD)}
> >
Back to Registration Register Again
</button> </button>
</div> </div>
)} )}
+7
View File
@@ -5,6 +5,7 @@ import { DrawerSection } from "../components/drawer/DrawerSection.tsx";
import { LetterItem } from "../components/drawer/LetterItem.tsx"; import { LetterItem } from "../components/drawer/LetterItem.tsx";
import { PasskeyModal } from "../components/drawer/PasskeyModal.tsx"; import { PasskeyModal } from "../components/drawer/PasskeyModal.tsx";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
import Saajan from "../components/ui/Saajan.tsx";
import { PATHS } from "../config/routes"; import { PATHS } from "../config/routes";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { useLetters } from "../hooks/useLetters"; import { useLetters } from "../hooks/useLetters";
@@ -165,6 +166,12 @@ export default function Drawer() {
<footer className="mt-25 font-sans text-[0.6rem] tracking-[0.2em] uppercase text-base-content/10 z-10"> <footer className="mt-25 font-sans text-[0.6rem] tracking-[0.2em] uppercase text-base-content/10 z-10">
For your unsaid. For your unsaid.
</footer> </footer>
<div className="absolute bottom-0 z-50 font-sans">
<Saajan
message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`}
position="top"
/>
</div>
</div> </div>
); );
} }
-2
View File
@@ -83,11 +83,9 @@ describe("Editor Page", () => {
expect(screen.queryByText(/Opening your draft/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Opening your draft/i)).not.toBeInTheDocument();
}); });
// Initial state: DRAFT (not read-only)
const canvas = screen.getByTestId("canvas"); const canvas = screen.getByTestId("canvas");
expect(canvas.getAttribute("data-readonly")).toBe("false"); expect(canvas.getAttribute("data-readonly")).toBe("false");
// Click Seal in the main toolbar (it's in the div with id="writer-toolbar")
const toolbar = container.querySelector("#writer-toolbar"); const toolbar = container.querySelector("#writer-toolbar");
const sealBtn = toolbar?.querySelector(".btn-primary"); const sealBtn = toolbar?.querySelector(".btn-primary");
if (!sealBtn) throw new Error("Seal button not found"); if (!sealBtn) throw new Error("Seal button not found");
+6 -2
View File
@@ -293,7 +293,7 @@ export default function Editor() {
setLetterStatus(status); setLetterStatus(status);
setLastSavedPulseTick((prev) => prev + 1); setLastSavedPulseTick((prev) => prev + 1);
if (status === "SEALED") { if (status === "SEALED" || status === "VAULT") {
setSealedTargetId(targetId); setSealedTargetId(targetId);
} }
setSaveOverlay("saved"); setSaveOverlay("saved");
@@ -419,7 +419,11 @@ export default function Editor() {
/> />
)} )}
{sealedTargetId && ( {sealedTargetId && (
<PostSealModal sealedTargetId={sealedTargetId} navigate={navigate} /> <PostSealModal
sealedTargetId={sealedTargetId}
navigate={navigate}
type={status === "VAULT" ? "VAULT" : "KEPT"}
/>
)} )}
<div className="max-w-180 mx-auto px-1 md:px-0"> <div className="max-w-180 mx-auto px-1 md:px-0">
-10
View File
@@ -14,16 +14,6 @@ describe("Login Page", () => {
server.resetHandlers(); server.resetHandlers();
}); });
it("should render the sign-in form correctly", () => {
render(
<MemoryRouter>
<Login />
</MemoryRouter>,
);
expect(screen.getByText("Sign in to")).toBeInTheDocument();
});
it("should display a technical issues message when the server is down", async () => { it("should display a technical issues message when the server is down", async () => {
server.use( server.use(
http.post(`${API_URL}${endpoints.LOGIN}`, () => http.post(`${API_URL}${endpoints.LOGIN}`, () =>
+55 -17
View File
@@ -1,5 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { ShieldCheckIcon, WarningIcon } from "@phosphor-icons/react"; import {
HandPalmIcon,
ShieldCheckIcon,
WarningIcon,
} from "@phosphor-icons/react";
import axios from "axios"; import axios from "axios";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -8,6 +12,7 @@ import { z } from "zod";
import { api, publicApi } from "../api/apiClient"; import { api, publicApi } from "../api/apiClient";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
import FormField from "../components/ui/FormField"; import FormField from "../components/ui/FormField";
import Saajan from "../components/ui/Saajan";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
import { ROUTES } from "../config/routes"; import { ROUTES } from "../config/routes";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
@@ -20,10 +25,19 @@ const loginSchema = z.object({
type LoginInputs = z.infer<typeof loginSchema>; type LoginInputs = z.infer<typeof loginSchema>;
function WelcomeModal({ setShowWelcome }) { function WelcomeModal({
setShowWelcome,
}: {
setShowWelcome: (show: boolean) => void;
}) {
return ( return (
<div className="modal modal-open backdrop-blur-sm transition-all duration-1000"> <div className="modal modal-open backdrop-blur-sm transition-all duration-1000">
<div className="modal-box border border-primary/20 shadow-2xl p-8"> <div className="absolute bottom-1">
<Saajan
message={"I've lost words before.\nI know what it feels like."}
/>
</div>
<div className="modal-box border bg-base-100/20 border-primary/20 shadow-2xl p-8">
<div className="flex flex-col items-center text-center gap-4"> <div className="flex flex-col items-center text-center gap-4">
<div className="bg-primary/10 p-4 rounded-full animate-pulse"> <div className="bg-primary/10 p-4 rounded-full animate-pulse">
<ShieldCheckIcon <ShieldCheckIcon
@@ -33,19 +47,22 @@ function WelcomeModal({ setShowWelcome }) {
/> />
</div> </div>
<h3 className="font-display text-2xl font-bold text-primary"> <h3 className="font-display text-2xl font-bold text-primary">
Welcome to <Logo />! Welcome to &nbsp;
<Logo /> &nbsp;!
</h3> </h3>
<p className="text-base-content/80 leading-relaxed"> <p className="text-base-content/80 leading-relaxed">
To ensure <span className="font-bold">complete privacy</span>, all Before we begin, let me make a small promise.
your letters are{" "} <HandPalmIcon
<span className="font-bold underline"> size={18}
sealed with your password className="inline text-primary"
</span> weight="fill"
, which only you have access to. />
<div className="divider my-0"></div>
<br /> <br />
<span className="font-bold"> Everything you write here is sealed with your password,{" "}
The server never sees it, and it's a solemn promise! <span className="font-display text-success">cryptographically</span>
</span> , before it leaves your hands.
<br />A fancy way of saying, I couldn't if I tried.
</p> </p>
<div className="alert alert-warning bg-paper/20 border-paper/20 flex items-start gap-3 text-left py-3"> <div className="alert alert-warning bg-paper/20 border-paper/20 flex items-start gap-3 text-left py-3">
@@ -53,6 +70,19 @@ function WelcomeModal({ setShowWelcome }) {
<p className="text-sm font-medium text-primary-content"> <p className="text-sm font-medium text-primary-content">
If you ever happen to forget your password, your letters are lost If you ever happen to forget your password, your letters are lost
to time, forever. to time, forever.
<br />
<span className="font-bold mt-2">
I highly, highly recommend storing this password in your{" "}
<a
href="https://www.privacyguides.org/en/passwords/"
target="_blank"
className="link link-primary-content"
rel="noopener"
>
password manager
</a>{" "}
or somewhere safe to remember it.
</span>
</p> </p>
</div> </div>
@@ -62,7 +92,7 @@ function WelcomeModal({ setShowWelcome }) {
onClick={() => setShowWelcome(false)} onClick={() => setShowWelcome(false)}
className="btn btn-primary w-full shadow-lg" className="btn btn-primary w-full shadow-lg"
> >
I understand I'll remember
</button> </button>
</div> </div>
</div> </div>
@@ -78,6 +108,9 @@ export default function Login() {
const [apiError, setApiError] = useState<string | null>(null); const [apiError, setApiError] = useState<string | null>(null);
const { setAuthStore } = useAuth(); const { setAuthStore } = useAuth();
const [showWelcome, setShowWelcome] = useState(!!location.state?.firstTime); const [showWelcome, setShowWelcome] = useState(!!location.state?.firstTime);
const [saajanMessage, setSaajanMessage] = useState<string>(
"I was wondering when you'd return.",
);
const nextRoute = location.state?.redirectUrl || ROUTES.DRAWER; const nextRoute = location.state?.redirectUrl || ROUTES.DRAWER;
const { const {
@@ -125,12 +158,13 @@ export default function Login() {
}; };
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col items-center">
{!showWelcome && <Saajan message={saajanMessage} position="top" />}
{showWelcome && <WelcomeModal setShowWelcome={setShowWelcome} />} {showWelcome && <WelcomeModal setShowWelcome={setShowWelcome} />}
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom"> <div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4"> <form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
<h1 className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight"> <h1 className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight">
Sign in to <Logo /> Enter <Logo /> Archive
</h1> </h1>
{apiError && ( {apiError && (
@@ -142,9 +176,10 @@ export default function Login() {
<FormField <FormField
label="Email" label="Email"
type="email" type="email"
placeholder="you@email.com" placeholder="f.kafka@wrongtrain.com"
registration={register("email")} registration={register("email")}
error={errors.email?.message} error={errors.email?.message}
handleFocus={() => setSaajanMessage("I remember you.")}
/> />
<FormField <FormField
@@ -153,6 +188,9 @@ export default function Login() {
placeholder="••••••••" placeholder="••••••••"
registration={register("password")} registration={register("password")}
error={errors.password?.message} error={errors.password?.message}
handleFocus={() =>
setSaajanMessage("The one thing I cannot know for you.")
}
/> />
<div className="card-actions mt-4"> <div className="card-actions mt-4">
+1 -1
View File
@@ -44,7 +44,7 @@ export default function Reader() {
const [isDecrypting, setIsDecrypting] = useState(true); const [isDecrypting, setIsDecrypting] = useState(true);
const [revealState, setRevealState] = useState< const [revealState, setRevealState] = useState<
"sealed" | "revealed" | "burned" "sealed" | "revealed" | "burned" | "burning"
>("sealed"); >("sealed");
const [error, setError] = useState<{ const [error, setError] = useState<{
message: string; message: string;
+88 -61
View File
@@ -8,6 +8,7 @@ import { z } from "zod";
import { publicApi } from "../api/apiClient"; import { publicApi } from "../api/apiClient";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
import FormField from "../components/ui/FormField"; import FormField from "../components/ui/FormField";
import Saajan from "../components/ui/Saajan";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
import { ROUTES } from "../config/routes"; import { ROUTES } from "../config/routes";
import { CryptoUtils } from "../utils/crypto"; import { CryptoUtils } from "../utils/crypto";
@@ -31,6 +32,9 @@ export default function Register() {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string | null>(null); const [apiError, setApiError] = useState<string | null>(null);
const [saajanMessage, setSaajanMessage] = useState<string>(
"I didn't think I'd be here either.\nAnd yet, here we are.",
);
const { const {
register, register,
@@ -41,6 +45,7 @@ export default function Register() {
}); });
const onSubmit = async (data: RegisterInputs) => { const onSubmit = async (data: RegisterInputs) => {
setSaajanMessage("Good. I'll remember that.");
setIsLoading(true); setIsLoading(true);
setApiError(null); setApiError(null);
try { try {
@@ -68,74 +73,96 @@ export default function Register() {
}; };
return ( return (
<div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom"> <div className="flex flex-col">
<form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4"> <Saajan message={saajanMessage} position="right" />
<h1 className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight"> <div className="glass-card w-full max-w-sm p-2 transition-all duration-500 hover:shadow-2xl fade-zoom">
Create a <Logo /> Account <form onSubmit={handleSubmit(onSubmit)} className="card-body gap-4">
</h1> <div className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight whitespace-nowrap">
Create a <Logo /> Account
{apiError && (
<div className="alert alert-error text-xs py-2 rounded-md">
<span>{apiError}</span>
</div> </div>
)}
<FormField {apiError && (
label="Pen Name" <div className="alert alert-error text-xs py-2 rounded-md">
placeholder="Word Smith" <span>{apiError}</span>
registration={register("full_name")} </div>
error={errors.full_name?.message} )}
/>
<FormField <FormField
label="Email" label="Pen Name"
type="email" placeholder="Word Smith"
placeholder="f.kafka@email.com" registration={register("full_name")}
registration={register("email")} error={errors.full_name?.message}
error={errors.email?.message} handleFocus={() =>
/> setSaajanMessage("Hello friend. What should I call you?")
}
/>
<FormField <FormField
label="Password" label="Email"
type="password" type="email"
placeholder="••••••••" placeholder="f.kafka@email.com"
registration={register("password")} registration={register("email")}
error={errors.password?.message} error={errors.email?.message}
/> handleFocus={() =>
setSaajanMessage(
"Where should I send your letters?\nNo empty lunchboxes, please.",
)
}
/>
<FormField <FormField
label="Confirm Password" label="Password"
type="password" type="password"
placeholder="••••••••" placeholder="••••••••"
registration={register("confirm_password")} registration={register("password")}
error={errors.confirm_password?.message} error={errors.password?.message}
/> handleFocus={() =>
setSaajanMessage(
"Something only you know.\nI have one of those too.",
)
}
/>
{/* Warning */} <FormField
<div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20"> label="Confirm Password"
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" /> type="password"
<p className="text-sm font-semibold"> placeholder="••••••••"
Choose a password you won't forget. <br /> registration={register("confirm_password")}
<span className="underline decoration-2">There is no reset.</span>{" "} error={errors.confirm_password?.message}
If you lose it, your letters cannot be recovered. handleFocus={() =>
</p> setSaajanMessage(
</div> "Just once? Trust me, \nsome things are worth repeating twice.",
)
}
/>
<div className="card-actions mt-4"> {/* Warning */}
<button <div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20">
type="submit" <InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
disabled={isLoading} <p className="text-sm font-semibold">
aria-label="Register" Choose a password you won't forget. <br />
className="btn btn-primary w-full shadow-lg" Just like life,{" "}
> <span className="underline decoration-2">there is no reset</span>{" "}
{isLoading ? ( here. If you lose it, your letters cannot be recovered.
<span className="loading loading-spinner loading-sm" /> </p>
) : ( </div>
"Register"
)} <div className="card-actions mt-4">
</button> <button
</div> type="submit"
</form> disabled={isLoading}
aria-label="Register"
className="btn btn-primary w-full shadow-lg"
>
{isLoading ? (
<span className="loading loading-spinner loading-sm" />
) : (
"Register"
)}
</button>
</div>
</form>
</div>
</div> </div>
); );
} }
+46 -32
View File
@@ -1,41 +1,55 @@
import { EnvelopeSimpleOpenIcon } from "@phosphor-icons/react"; import { EnvelopeSimpleOpenIcon } from "@phosphor-icons/react";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
import Saajan from "../components/ui/Saajan";
export default function VerifyEmail() { export default function VerifyEmail() {
return ( return (
<div className="glass-card w-full max-w-sm p-8 text-center flex flex-col items-center gap-6 fade-zoom"> <div className="relative">
<div className="auth-icon-container"> <Saajan
<EnvelopeSimpleOpenIcon message={"I sent something to your inbox.\nOpen it, and we can begin."}
size={32} />
weight="duotone"
className="text-primary" <div className="glass-card w-full max-w-sm p-8 text-center flex flex-col items-center gap-6 fade-zoom">
/> <div className="auth-icon-container">
<EnvelopeSimpleOpenIcon
size={32}
weight="duotone"
className="text-primary"
/>
</div>
<div className="space-y-2">
<h2 className="font-display text-xl text-primary">
Check Your Mailbox
</h2>
<p className="text-sm opacity-80 leading-relaxed font-sans mt-6">
You're one train away from starting your <Logo scale={0.8} />{" "}
journey.
</p>
</div>
<div className="divider opacity-10 my-0"></div>
<div className="alert bg-base-200/50 p-4 rounded-lg text-xs leading-relaxed opacity-70 text-center">
<p>
Nothing yet? Sometimes letters take the wrong train. Check your spam
folder.
<br />
<span className="underline font-bold">
The link expires in 24 hours.
</span>
<br /> I'm patient... but not endlessly so
</p>
</div>
<button
type="button"
className="text-xs italic opacity-40 cursor-pointer underline"
onClick={() => window.close()}
>
You can close this window now.
</button>
</div> </div>
<div className="space-y-2">
<h2 className="font-display text-xl text-primary">Check Your Email</h2>
<p className="text-sm opacity-80 leading-relaxed font-sans">
We've sent an activation link to your inbox. <br />
Please click it to verify your <Logo /> account.
</p>
</div>
<div className="divider opacity-10"></div>
<div className="alert bg-base-200/50 p-4 rounded-lg text-xs leading-relaxed text-left opacity-70">
<p>
Didn't receive it? Check your spam folder or wait for a few minutes.
The link will expire in 24 hours.
</p>
</div>
<button
type="button"
className="text-xs italic opacity-40 cursor-pointer underline"
onClick={() => window.close()}
>
You can close this window now.
</button>
</div> </div>
); );
} }