Compare commits
5 Commits
df56754c55
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ac2f541ebe | |||
| 8d0ab979f5 | |||
| 8449377b6d | |||
| 3b5f140d21 | |||
| 740753cb33 |
+38
-23
@@ -19,11 +19,12 @@ jobs:
|
|||||||
mkcert -install
|
mkcert -install
|
||||||
mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1
|
mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1
|
||||||
|
|
||||||
- name: Cache certificates
|
- name: Upload certificates
|
||||||
uses: actions/cache/save@v4
|
uses: christopherHX/gitea-upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: certs
|
name: ssl-certs
|
||||||
key: certs-${{ runner.os }}-${{ github.sha }}
|
path: certs/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Frontend CI
|
name: Frontend CI
|
||||||
@@ -37,10 +38,10 @@ jobs:
|
|||||||
- uses: oven-sh/setup-bun@v2
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Restore certificates
|
- name: Restore certificates
|
||||||
uses: actions/cache/restore@v4
|
uses: christopherHX/gitea-download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: certs
|
name: ssl-certs
|
||||||
key: certs-${{ runner.os }}-${{ github.sha }}
|
path: certs/
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
@@ -61,15 +62,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: setup-environment
|
needs: setup-environment
|
||||||
services:
|
services:
|
||||||
postgres:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: piku
|
POSTGRES_DB: piku__test
|
||||||
POSTGRES_USER: user
|
POSTGRES_USER: test
|
||||||
POSTGRES_PASSWORD: password123
|
POSTGRES_PASSWORD: password123
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5442:5432
|
||||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
options: --tmpfs /var/lib/postgresql/data --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
@@ -82,18 +83,28 @@ jobs:
|
|||||||
cache-dependency-glob: "backend/uv.lock"
|
cache-dependency-glob: "backend/uv.lock"
|
||||||
|
|
||||||
- name: Restore certificates
|
- name: Restore certificates
|
||||||
uses: actions/cache/restore@v4
|
uses: christopherHX/gitea-download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: certs
|
name: ssl-certs
|
||||||
key: certs-${{ runner.os }}-${{ github.sha }}
|
path: certs/
|
||||||
|
|
||||||
- name: Setup Environment
|
- name: Setup & Test
|
||||||
run: |
|
run: |
|
||||||
cp ../.env.example ../.env
|
cp ../.env.example ../.env
|
||||||
uv sync
|
uv sync
|
||||||
|
|
||||||
- name: Lint & Test
|
export DB_NAME="piku__test"
|
||||||
run: |
|
export DB_USER="test"
|
||||||
|
export DB_PASSWORD="password123"
|
||||||
|
|
||||||
|
if [ "$GITEA_ACTIONS" = "true" ]; then
|
||||||
|
export DB_HOST="db"
|
||||||
|
export DB_PORT="5432"
|
||||||
|
else
|
||||||
|
export DB_HOST="127.0.0.1"
|
||||||
|
export DB_PORT="5442"
|
||||||
|
fi
|
||||||
|
|
||||||
uv run ruff check
|
uv run ruff check
|
||||||
uv run python manage.py test
|
uv run python manage.py test
|
||||||
|
|
||||||
@@ -101,23 +112,27 @@ jobs:
|
|||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: setup-environment
|
needs: setup-environment
|
||||||
|
# Skipping on Gitea pushes until cache server is configured
|
||||||
|
if: github.server_url == 'https://github.com' || github.event_name == 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Restore Certificates
|
- name: Restore Certificates
|
||||||
uses: actions/cache/restore@v4
|
uses: christopherHX/gitea-download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: certs
|
name: ssl-certs
|
||||||
key: certs-${{ runner.os }}-${{ github.sha }}
|
path: certs/
|
||||||
|
|
||||||
- name: Setup Tools
|
- name: Setup Tools
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v5
|
||||||
|
|
||||||
- uses: oven-sh/setup-bun@v2
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
|
||||||
- name: Cache Playwright
|
- name: Cache Playwright
|
||||||
id: playwright-cache
|
id: playwright-cache
|
||||||
|
# Disable cache when not using GitHub Actions because the runner spends ~3mins trying to upload the cache and failing
|
||||||
|
# TODO: setup cache server in Gitea
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
@@ -140,7 +155,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload Playwright Report
|
- name: Upload Playwright Report
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: christopherHX/gitea-upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: frontend/playwright-report/
|
path: frontend/playwright-report/
|
||||||
|
|||||||
+41
-90
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { AuthHelper } from "./utils/auth";
|
import { AuthHelper } from "./utils/auth";
|
||||||
|
import { revealEnvelope } from "./utils/envelope";
|
||||||
|
|
||||||
const logger = pino({
|
const logger = pino({
|
||||||
transport: {
|
transport: {
|
||||||
@@ -22,20 +23,19 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
await AuthHelper.registerAndLogin(page, email, name, password);
|
await AuthHelper.registerAndLogin(page, email, name, password);
|
||||||
|
|
||||||
logger.info(">> [Draft] Navigating to Editor via UI...");
|
logger.info(">> [Draft] Navigating to Editor via UI...");
|
||||||
await page.getByRole("button", { name: /write something/i }).click();
|
await page.getByTestId("write-letter-btn").click();
|
||||||
|
|
||||||
logger.info(`>> [Draft] Current URL after click: ${page.url()}`);
|
logger.info(`>> [Draft] Current URL after click: ${page.url()}`);
|
||||||
|
|
||||||
// Wait for the recipient input to be present in the DOM
|
// Editor page
|
||||||
const recipientInput = page.locator("#recipient");
|
await expect(page.getByTestId("recipient-input")).toBeVisible();
|
||||||
await recipientInput.waitFor({ state: "visible", timeout: 20000 });
|
const recipientInput = page.getByTestId("recipient-input");
|
||||||
|
|
||||||
const recipientName = "Dear Friend";
|
const recipientName = "Dear Friend";
|
||||||
await recipientInput.fill(recipientName);
|
await recipientInput.fill(recipientName);
|
||||||
|
|
||||||
// Initial load: verify textarea value (populated by Fabric when focused)
|
// Initial load: verify textarea value (populated by Fabric when focused)
|
||||||
const canvasInput = page.locator("textarea");
|
const canvasInput = page.locator("textarea");
|
||||||
await canvasInput.waitFor({ state: "attached" });
|
|
||||||
await canvasInput.focus();
|
await canvasInput.focus();
|
||||||
await expect(canvasInput).toHaveValue(/Take a deep breath/i);
|
await expect(canvasInput).toHaveValue(/Take a deep breath/i);
|
||||||
|
|
||||||
@@ -46,10 +46,10 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await page.keyboard.type("It should persist.");
|
await page.keyboard.type("It should persist.");
|
||||||
logger.info(">> [Draft] Clicking Draft...");
|
logger.info(">> [Draft] Clicking Draft...");
|
||||||
await page.getByRole("button", { name: /draft/i }).click();
|
await page.getByTestId("draft-btn").click();
|
||||||
|
|
||||||
// Verify Success Modal/Alert
|
// Verify Success Modal/Alert
|
||||||
await expect(page.getByText(/your letter is saved/i)).toBeVisible();
|
await expect(page.getByTestId("save-success-toast")).toBeVisible();
|
||||||
|
|
||||||
// Verify URL updated with a UUID
|
// Verify URL updated with a UUID
|
||||||
await expect(page).toHaveURL(/\/quill\/[0-9a-f-]{36}/);
|
await expect(page).toHaveURL(/\/quill\/[0-9a-f-]{36}/);
|
||||||
@@ -61,24 +61,16 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
await page.goto(savedUrl);
|
await page.goto(savedUrl);
|
||||||
|
|
||||||
// Wait for initial load overlay to appear and then definitely disappear
|
// Wait for initial load overlay to appear and then definitely disappear
|
||||||
await page
|
await expect(page.getByTestId("opening-draft-overlay")).toBeHidden();
|
||||||
.getByText(/opening your draft/i)
|
|
||||||
.waitFor({ state: "visible", timeout: 2000 })
|
|
||||||
.catch(() => {});
|
|
||||||
await expect(page.getByText(/opening your draft/i)).toBeHidden({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check recipient
|
// Check recipient
|
||||||
await expect(page.locator("#recipient")).toHaveValue(recipientName);
|
await expect(page.getByTestId("recipient-input")).toHaveValue(recipientName);
|
||||||
|
|
||||||
// Check canvas content
|
// Check canvas content
|
||||||
// We wait for the content to appear in the textarea.
|
// We wait for the content to appear in the textarea.
|
||||||
// toHaveValue will poll until it matches or timeouts.
|
// toHaveValue will poll until it matches or timeouts.
|
||||||
await canvasInput.focus();
|
await canvasInput.focus();
|
||||||
await expect(canvasInput).toHaveValue(/This is a secret draft/i, {
|
await expect(canvasInput).toHaveValue(/This is a secret draft/i);
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
await expect(canvasInput).toHaveValue(/It should persist/i);
|
await expect(canvasInput).toHaveValue(/It should persist/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,10 +84,9 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
await AuthHelper.registerAndLogin(page, email, name, password);
|
await AuthHelper.registerAndLogin(page, email, name, password);
|
||||||
|
|
||||||
logger.info(">> [Seal] Navigating to Editor via UI...");
|
logger.info(">> [Seal] Navigating to Editor via UI...");
|
||||||
await page.locator("#write-letter-btn").click();
|
await page.getByTestId("write-letter-btn").click();
|
||||||
|
|
||||||
const recipientInput = page.locator("#recipient");
|
const recipientInput = page.getByTestId("recipient-input");
|
||||||
await recipientInput.waitFor({ state: "visible", timeout: 10000 });
|
|
||||||
await recipientInput.fill("A Secret Guest");
|
await recipientInput.fill("A Secret Guest");
|
||||||
|
|
||||||
const canvasInput = page.locator("textarea");
|
const canvasInput = page.locator("textarea");
|
||||||
@@ -104,55 +95,41 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
|
|
||||||
// Click Seal (open menu, then confirm)
|
// Click Seal (open menu, then confirm)
|
||||||
logger.info(">> [Seal] Clicking Seal...");
|
logger.info(">> [Seal] Clicking Seal...");
|
||||||
await page
|
await page.getByTestId("seal-trigger-btn").click();
|
||||||
.getByRole("button", { name: /seal/i })
|
await page.getByTestId("seal-confirm-btn").click();
|
||||||
.filter({ visible: true })
|
|
||||||
.click();
|
|
||||||
await page
|
|
||||||
.getByRole("button", { name: /seal/i })
|
|
||||||
.filter({ visible: true })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Should show sealed confirmation modal
|
// Should show sealed confirmation modal
|
||||||
logger.info(">> [Seal] Verifying sealed modal...");
|
logger.info(">> [Seal] Verifying sealed modal...");
|
||||||
await expect(page.getByText(/your letter is sealed/i)).toBeVisible({
|
await expect(page.getByTestId("post-seal-modal")).toBeVisible();
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Navigate to Reader via "View letter"
|
// Navigate to Reader via "View letter"
|
||||||
await page.getByRole("button", { name: /view letter/i }).click();
|
await page.getByTestId("view-letter-btn").click();
|
||||||
|
|
||||||
// Should be on Reader URL
|
// Should be on Reader URL
|
||||||
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 });
|
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/);
|
||||||
|
|
||||||
// Open the envelope to reveal the letter
|
// Open the envelope to reveal the letter
|
||||||
await expect(page.getByText(/breaking the seal/i)).toBeHidden({
|
await expect(page.getByTestId("decryption-overlay")).toBeHidden();
|
||||||
timeout: 10000,
|
// Flip the envelope to show the seal and reveal the letter
|
||||||
});
|
await revealEnvelope(page);
|
||||||
// Flip the envelope to show the seal
|
await expect(page.getByTestId("envelope-letter")).toBeHidden();
|
||||||
await page.locator("#env-front").click();
|
|
||||||
await page.waitForTimeout(2500); // Wait for flip transition
|
|
||||||
|
|
||||||
await page.getByAltText("Seal").click();
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
await page.locator("#letter").click({ position: { x: 30, y: 15 } });
|
|
||||||
await expect(page.locator("#letter")).toBeHidden({ timeout: 20000 });
|
|
||||||
|
|
||||||
// Share on demand
|
// Share on demand
|
||||||
logger.info(">> [Seal] Clicking Share button in Reader...");
|
logger.info(">> [Seal] Clicking Share button in Reader...");
|
||||||
await page.locator("#share-letter-btn").click();
|
await page.getByTestId("share-letter-btn").click();
|
||||||
|
|
||||||
// Verify share modal with a valid link
|
// Verify share modal with a valid link
|
||||||
await expect(page.getByText(/send this letter/i)).toBeVisible();
|
await expect(page.getByTestId("share-letter-modal")).toBeVisible();
|
||||||
const linkInput = page.locator("#share-link-input");
|
const linkInput = page.locator("#share-link-input");
|
||||||
const linkValue = await linkInput.inputValue();
|
const linkValue = await linkInput.inputValue();
|
||||||
expect(linkValue).toContain("/read/");
|
expect(linkValue).toContain("/read/");
|
||||||
expect(linkValue).toContain("#");
|
expect(linkValue).toContain("#");
|
||||||
logger.info(`>> [Seal] Sharing link: ${linkValue}`);
|
logger.info(`>> [Seal] Sharing link: ${linkValue}`);
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: /copy/i })).toBeVisible();
|
await expect(page.getByTestId("copy-link-btn")).toBeVisible();
|
||||||
await page.getByRole("button", { name: /close/i }).click();
|
// Assuming Close button in ShareModal might need a testid too, but for now let's use text if unique or add testid
|
||||||
await expect(page.getByText(/send this letter/i)).toBeHidden();
|
await page.getByTestId("modal-close-btn").click();
|
||||||
|
await expect(page.getByTestId("share-letter-modal")).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should allow author to access sealed letter from drawer without sharing key", async ({
|
test("should allow author to access sealed letter from drawer without sharing key", async ({
|
||||||
@@ -167,10 +144,9 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
await AuthHelper.registerAndLogin(page, email, name, password);
|
await AuthHelper.registerAndLogin(page, email, name, password);
|
||||||
|
|
||||||
logger.info(">> [Drawer] Creating and sealing a letter...");
|
logger.info(">> [Drawer] Creating and sealing a letter...");
|
||||||
await page.getByRole("button", { name: /write something/i }).click();
|
await page.getByTestId("write-letter-btn").click();
|
||||||
|
|
||||||
const recipientInput = page.locator("#recipient");
|
const recipientInput = page.getByTestId("recipient-input");
|
||||||
await recipientInput.waitFor({ state: "visible" });
|
|
||||||
await recipientInput.fill(recipientName);
|
await recipientInput.fill(recipientName);
|
||||||
|
|
||||||
const canvasInput = page.locator("textarea");
|
const canvasInput = page.locator("textarea");
|
||||||
@@ -178,59 +154,34 @@ test.describe("Letter Drafting (Real Backend)", () => {
|
|||||||
await canvasInput.fill(letterContent);
|
await canvasInput.fill(letterContent);
|
||||||
|
|
||||||
// Click Seal (open menu, then confirm)
|
// Click Seal (open menu, then confirm)
|
||||||
await page
|
await page.getByTestId("seal-trigger-btn").click();
|
||||||
.getByRole("button", { name: /seal/i })
|
await page.getByTestId("seal-confirm-btn").click();
|
||||||
.filter({ visible: true })
|
|
||||||
.click();
|
|
||||||
await page
|
|
||||||
.getByRole("button", { name: /seal/i })
|
|
||||||
.filter({ visible: true })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Sealed modal should appear — click "Keep it" to go to Drawer
|
// Sealed modal should appear — click "Keep it" to go to Drawer
|
||||||
await expect(page.getByText(/your letter is sealed/i)).toBeVisible({
|
await expect(page.getByTestId("post-seal-modal")).toBeVisible();
|
||||||
timeout: 10000,
|
await page.getByTestId("keep-it-btn").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...");
|
||||||
const keptSection = page.locator("#kept");
|
await page.getByTestId("drawer-section-kept").click();
|
||||||
await keptSection.getByRole("button", { name: /kept/i }).click();
|
|
||||||
|
|
||||||
// Find the sealed letter in the drawer by recipient name and click it
|
// Find the sealed letter in the drawer by recipient name and click it
|
||||||
logger.info(">> [Drawer] Clicking sealed letter in drawer...");
|
logger.info(">> [Drawer] Clicking sealed letter in drawer...");
|
||||||
const sealedItem = page
|
const sealedItem = page
|
||||||
.getByRole("button", { name: new RegExp(recipientName, "i") })
|
.getByTestId(/^letter-item-/)
|
||||||
|
.filter({ hasText: recipientName })
|
||||||
.first();
|
.first();
|
||||||
await sealedItem.click();
|
await sealedItem.click();
|
||||||
|
|
||||||
// Verify it opens the Reader without a hash
|
// Verify it opens the Reader without a hash
|
||||||
logger.info(">> [Drawer] Verifying Reader page...");
|
logger.info(">> [Drawer] Verifying Reader page...");
|
||||||
// Give it a bit more time for decryption
|
// Give it a bit more time for decryption
|
||||||
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/, { timeout: 15000 }); // UUID without hash
|
await expect(page).toHaveURL(/\/read\/[a-f0-9-]{36}$/);
|
||||||
// Reveal and check decrypted content in Reader
|
// Reveal and check decrypted content in Reader
|
||||||
await expect(page.getByText(/breaking the seal/i)).toBeHidden({
|
await expect(page.getByTestId("decryption-overlay")).toBeHidden();
|
||||||
timeout: 10000,
|
// Flip the envelope and reveal the letter
|
||||||
});
|
await revealEnvelope(page);
|
||||||
// Check recipient on the front of the envelope
|
await expect(page.getByTestId("envelope-letter")).toBeHidden();
|
||||||
await expect(page.getByText(new RegExp(recipientName, "i"))).toBeVisible();
|
|
||||||
|
|
||||||
// Flip the envelope to the back
|
|
||||||
await page.getByText(new RegExp(recipientName, "i")).click();
|
|
||||||
// Wait for flip transition (2s)
|
|
||||||
await page.waitForTimeout(2500);
|
|
||||||
|
|
||||||
// Reveal the letter: click seal then click letter
|
|
||||||
await page.getByAltText("Seal").click();
|
|
||||||
// Wait for flap transition
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
|
|
||||||
// Click the letter to pull it out
|
|
||||||
await page.locator("#letter").click({ position: { x: 30, y: 15 } });
|
|
||||||
|
|
||||||
// Wait for reveal transition
|
|
||||||
await expect(page.locator("#letter")).toBeHidden({ timeout: 20000 });
|
|
||||||
|
|
||||||
// Also check if we are redirected to the Reader if we manually go to the Editor URL
|
// Also check if we are redirected to the Reader if we manually go to the Editor URL
|
||||||
const readerUrl = page.url();
|
const readerUrl = page.url();
|
||||||
|
|||||||
+15
-14
@@ -1,6 +1,7 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { MailpitHelper } from "./mailpit";
|
import { MailpitHelper } from "./mailpit";
|
||||||
|
import { handleWelcomeLetter } from "./envelope";
|
||||||
|
|
||||||
const logger = pino({
|
const logger = pino({
|
||||||
transport: {
|
transport: {
|
||||||
@@ -23,11 +24,11 @@ async function registerAndLogin(
|
|||||||
// Register the User
|
// 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.getByTestId("pen-name-input").fill(fullName);
|
||||||
await page.getByLabel("Email", { exact: true }).fill(email);
|
await page.getByTestId("email-input").fill(email);
|
||||||
await page.getByLabel("Password", { exact: true }).fill(password);
|
await page.getByTestId("password-input").fill(password);
|
||||||
await page.getByLabel(/confirm password/i).fill(password);
|
await page.getByTestId("confirm-password-input").fill(password);
|
||||||
await page.getByRole("button", { name: /^register$/i }).click();
|
await page.getByTestId("register-submit-btn").click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/verify-email/);
|
await expect(page).toHaveURL(/\/verify-email/);
|
||||||
|
|
||||||
@@ -37,23 +38,23 @@ async function registerAndLogin(
|
|||||||
|
|
||||||
await page.goto(activationLink);
|
await page.goto(activationLink);
|
||||||
|
|
||||||
await expect(page.getByText(/account activated/i)).toBeVisible();
|
await expect(page.getByTestId("activation-success-header")).toBeVisible();
|
||||||
await page.getByRole("button", { name: /start writing/i }).click();
|
await page.getByTestId("start-writing-btn").click();
|
||||||
|
|
||||||
// Dismiss the Welcom Modal and Perform 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'll remember/i });
|
await page.getByTestId("welcome-dismiss-btn").click();
|
||||||
await welcomeButton.waitFor({ state: "visible", timeout: 10000 });
|
await expect(page.getByTestId("welcome-dismiss-btn")).toBeHidden();
|
||||||
await welcomeButton.click();
|
|
||||||
await expect(welcomeButton).toBeHidden();
|
|
||||||
|
|
||||||
await page.getByLabel("Email", { exact: true }).fill(email);
|
await page.getByTestId("email-input").fill(email);
|
||||||
await page.getByLabel("Password", { exact: true }).fill(password);
|
await page.getByTestId("password-input").fill(password);
|
||||||
await page.getByRole("button", { name: /sign in/i }).click();
|
await page.getByTestId("login-submit-btn").click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/drawer/);
|
await expect(page).toHaveURL(/\/drawer/);
|
||||||
|
await handleWelcomeLetter(page);
|
||||||
logger.info(`[Auth] Successfully authenticated ${email}`);
|
logger.info(`[Auth] Successfully authenticated ${email}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthHelper = { registerAndLogin };
|
export const AuthHelper = { registerAndLogin };
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { type Page, expect } from "@playwright/test";
|
||||||
|
import pino from "pino";
|
||||||
|
|
||||||
|
const logger = pino({
|
||||||
|
transport: {
|
||||||
|
target: "pino-pretty",
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal a letter from an envelope.
|
||||||
|
*/
|
||||||
|
export async function revealEnvelope(page: Page) {
|
||||||
|
logger.info("[Envelope] Revealing envelope...");
|
||||||
|
// Click envelope to flip
|
||||||
|
await page.getByTestId("envelope-front").click();
|
||||||
|
|
||||||
|
// Click seal to open flap
|
||||||
|
await page.getByTestId("wax-seal").click();
|
||||||
|
|
||||||
|
// Click letter to reveal
|
||||||
|
await page.getByTestId("envelope-letter").click({ position: { x: 30, y: 15 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles and dismisses the first welcome letter
|
||||||
|
*/
|
||||||
|
export async function handleWelcomeLetter(page: Page) {
|
||||||
|
logger.info("[Envelope] Handling Welcome Letter...");
|
||||||
|
await revealEnvelope(page);
|
||||||
|
|
||||||
|
// Click "I'll see you" button
|
||||||
|
await page.getByTestId("dismiss-welcome-letter-btn").click();
|
||||||
|
await expect(page.getByTestId("dismiss-welcome-letter-btn")).toBeHidden();
|
||||||
|
}
|
||||||
+68
-68
@@ -1,70 +1,70 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b & vite build",
|
"build": "tsc -b & vite build",
|
||||||
"build:prod": "vite build --mode production",
|
"build:prod": "vite build --mode production",
|
||||||
"lint": "biome lint --write ./src",
|
"lint": "biome lint --write ./src",
|
||||||
"format": "biome format --write ./src",
|
"format": "biome format --write ./src",
|
||||||
"check": "biome check --write ./src",
|
"check": "biome check --write ./src",
|
||||||
"check-all": "biome check --write .",
|
"check-all": "biome check --write .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=43008"
|
"test:e2e:ui": "playwright test --ui --ui-host=0.0.0.0 --ui-port=43008"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/jost": "^5.2.8",
|
"@fontsource-variable/jost": "^5.2.8",
|
||||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||||
"@fontsource-variable/playwrite-hr-lijeva": "^5.2.7",
|
"@fontsource-variable/playwrite-hr-lijeva": "^5.2.7",
|
||||||
"@fontsource/architects-daughter": "^5.2.7",
|
"@fontsource/architects-daughter": "^5.2.7",
|
||||||
"@fontsource/cutive-mono": "^5.2.8",
|
"@fontsource/cutive-mono": "^5.2.8",
|
||||||
"@fontsource/kavivanar": "^5.2.8",
|
"@fontsource/kavivanar": "^5.2.8",
|
||||||
"@fontsource/knewave": "^5.2.7",
|
"@fontsource/knewave": "^5.2.7",
|
||||||
"@fontsource/redacted-script": "^5.2.8",
|
"@fontsource/redacted-script": "^5.2.8",
|
||||||
"@fontsource/space-mono": "^5.2.9",
|
"@fontsource/space-mono": "^5.2.9",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
"daisyui": "^5.5.19",
|
"daisyui": "^5.5.19",
|
||||||
"fabric": "^7.2.0",
|
"fabric": "^7.2.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"lenis": "^1.3.23",
|
"lenis": "^1.3.23",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.72.1",
|
"react-hook-form": "^7.72.1",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.11",
|
"@biomejs/biome": "^2.4.11",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^4.1.4",
|
"@vitest/coverage-v8": "^4.1.4",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"fake-indexeddb": "^6.2.5",
|
"fake-indexeddb": "^6.2.5",
|
||||||
"jsdom": "^29.0.2",
|
"jsdom": "^29.0.2",
|
||||||
"msw": "^2.13.2",
|
"msw": "^2.13.2",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.0.4",
|
"vite": "^8.0.4",
|
||||||
"vitest": "^4.1.4"
|
"vitest": "^4.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const baseUrl = getBaseUrl(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
timeout: 60000,
|
timeout: 80000,
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
},
|
},
|
||||||
@@ -60,7 +60,8 @@ export default defineConfig({
|
|||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "npm run dev -- --mode e2e",
|
// NOTE: using npm here for docker compat mainly
|
||||||
|
command: "npm run build -- --mode e2e && npm run preview -- --mode e2e",
|
||||||
url: getBaseUrl(
|
url: getBaseUrl(
|
||||||
process.env.SSL_ENABLED === "true",
|
process.env.SSL_ENABLED === "true",
|
||||||
process.env.FRONTEND_DOMAIN,
|
process.env.FRONTEND_DOMAIN,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
@@ -1,11 +1,5 @@
|
|||||||
import { lazy, Suspense, useEffect, useRef } from "react";
|
import { lazy, Suspense, useEffect, useRef } from "react";
|
||||||
import {
|
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
BrowserRouter,
|
|
||||||
Navigate,
|
|
||||||
Route,
|
|
||||||
Routes,
|
|
||||||
ScrollRestoration,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
|
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
|
||||||
import SplashScreen from "./components/SplashScreen";
|
import SplashScreen from "./components/SplashScreen";
|
||||||
import { ROUTES } from "./config/routes";
|
import { ROUTES } from "./config/routes";
|
||||||
@@ -37,7 +31,7 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<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')]">
|
<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-50 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 />} />
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function DrawerSection({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
data-testid={`drawer-section-${id}`}
|
||||||
className={`w-full p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-2000 ease-in-out outline-none focus-visible:ring-2 focus-visible:ring-primary/50 border border-base-content/10 text-left bg-linear-to-r from-transparent to-base-100/40`}
|
className={`w-full p-[24px_28px] cursor-pointer flex items-center gap-5 transition-all duration-2000 ease-in-out outline-none focus-visible:ring-2 focus-visible:ring-primary/50 border border-base-content/10 text-left bg-linear-to-r from-transparent to-base-100/40`}
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function LetterItem({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNavigate}
|
onClick={handleNavigate}
|
||||||
|
data-testid={`letter-item-${id}`}
|
||||||
className={`${isLocked ? "pointer-events-none" : ""} p-4 border-base-content/3 flex items-start gap-4 hover:bg-base-300 transition-all delay-75 duration-100 group text-left cursor-pointer w-9/12 mx-auto hover:scale-120 hover:h-24 hover:-translate-y-3 hover:pb-4 hover:border-x-5 hover:border-t-5 border-t-2 hover:-mb-2`}
|
className={`${isLocked ? "pointer-events-none" : ""} p-4 border-base-content/3 flex items-start gap-4 hover:bg-base-300 transition-all delay-75 duration-100 group text-left cursor-pointer w-9/12 mx-auto hover:scale-120 hover:h-24 hover:-translate-y-3 hover:pb-4 hover:border-x-5 hover:border-t-5 border-t-2 hover:-mb-2`}
|
||||||
>
|
>
|
||||||
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60 transition-none animate-[opacity_200ms_linear_forwards]">
|
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60 transition-none animate-[opacity_200ms_linear_forwards]">
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import { LockKeyIcon } from "@phosphor-icons/react";
|
import { HourglassSimpleMediumIcon } from "@phosphor-icons/react";
|
||||||
|
import { useAuth } from "../../hooks/useAuth";
|
||||||
import { Modal } from "../ui/Modal";
|
import { Modal } from "../ui/Modal";
|
||||||
|
|
||||||
interface PasskeyModalProps {
|
export function PasskeyModal() {
|
||||||
onUnlock: (password: string) => Promise<void>;
|
const { unlock } = useAuth();
|
||||||
}
|
|
||||||
|
|
||||||
export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={true}>
|
<Modal isOpen={true}>
|
||||||
<LockKeyIcon
|
<HourglassSimpleMediumIcon
|
||||||
size={48}
|
size={48}
|
||||||
className="text-primary mx-auto mb-8 animate-pulse"
|
className="text-primary mx-auto mb-8 animate-pulse"
|
||||||
|
weight="duotone"
|
||||||
/>
|
/>
|
||||||
<h3 className="font-bold text-lg font-display text-primary">
|
<h3 className="font-bold text-lg font-display text-primary">
|
||||||
Authentication Required
|
You've been away a while.
|
||||||
</h3>
|
</h3>
|
||||||
<p className="py-4 font-sans">
|
<p className="py-4 font-sans">
|
||||||
We need your passkey to open your letters
|
Your letters are still there. Just need the key once more.
|
||||||
</p>
|
</p>
|
||||||
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
|
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
|
||||||
<p className="text-xs text-neutral-content/30 font-mono italic">
|
<p className="text-xs text-neutral-content/30 font-mono italic">
|
||||||
Your passkey is used to decrypt your data locally.
|
Nothing was lost.
|
||||||
</p>
|
</p>
|
||||||
<div className="modal-action items-center gap-4">
|
<div className="modal-action items-center gap-4">
|
||||||
<form
|
<form
|
||||||
@@ -30,7 +30,7 @@ export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
|
|||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const password = formData.get("password") as string;
|
const password = formData.get("password") as string;
|
||||||
if (!password) return;
|
if (!password) return;
|
||||||
await onUnlock(password);
|
await unlock(password);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { getWelcomeLetterContent } from "../../config/welcomeLetter";
|
||||||
|
import { formatDate } from "../../utils/dateFormat";
|
||||||
|
import { type CanvasTools, ComposeCanvas } from "../editor/ComposeCanvas";
|
||||||
|
import { EnvelopeReveal } from "../reader/EnvelopeReveal";
|
||||||
|
|
||||||
|
export interface WelcomeLetterOverlayProps {
|
||||||
|
onComplete: () => void;
|
||||||
|
userName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WelcomeLetterOverlay({
|
||||||
|
onComplete,
|
||||||
|
userName,
|
||||||
|
}: WelcomeLetterOverlayProps) {
|
||||||
|
const [revealState, setRevealState] = useState<"SEALED" | "REVEALED">(
|
||||||
|
"SEALED",
|
||||||
|
);
|
||||||
|
const canvasRef = useRef<CanvasTools>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (revealState === "REVEALED" && canvasRef.current) {
|
||||||
|
const welcomeContent = getWelcomeLetterContent(userName);
|
||||||
|
canvasRef.current.loadData(welcomeContent);
|
||||||
|
}
|
||||||
|
}, [revealState, userName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-30 backdrop-blur-3xl flex flex-col items-center justify-center p-4 md:p-8 overflow-x-hidden">
|
||||||
|
<div className="fixed inset-0 bg-vig pointer-events-none z-0" />
|
||||||
|
|
||||||
|
<div className="w-full max-w-4xl z-10 flex flex-col items-center">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{revealState === "SEALED" && (
|
||||||
|
<motion.div
|
||||||
|
key="envelope"
|
||||||
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
|
animate={{ scale: 0.8, opacity: 1 }}
|
||||||
|
exit={{
|
||||||
|
scale: 1,
|
||||||
|
opacity: 0,
|
||||||
|
transition: { duration: 0.5, ease: "easeOut" },
|
||||||
|
}}
|
||||||
|
transition={{ duration: 4, delay: 1 }}
|
||||||
|
>
|
||||||
|
<EnvelopeReveal
|
||||||
|
recipient={userName}
|
||||||
|
date={formatDate(new Date())}
|
||||||
|
onRevealComplete={() => setRevealState("REVEALED")}
|
||||||
|
ignite={false}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<div
|
||||||
|
className={`w-full space-y-8 py-12 ${revealState === "REVEALED" ? "block" : "hidden"}`}
|
||||||
|
>
|
||||||
|
<div className="bg-paper shadow-warm rounded-sm overflow-hidden mx-auto max-w-180">
|
||||||
|
<div className="p-1 md:p-2 bg-base-content/5 opacity-10 pointer-events-none absolute inset-0 z-10" />
|
||||||
|
<ComposeCanvas ref={canvasRef} readOnly />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center mt-12">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="dismiss-welcome-letter-btn"
|
||||||
|
onClick={onComplete}
|
||||||
|
className="btn btn-accent opacity-80 px-12 shadow-lg"
|
||||||
|
>
|
||||||
|
I'll see you
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,12 @@ import * as fabric from "fabric";
|
|||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
import { useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
import { useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
||||||
|
|
||||||
|
import "@fontsource/kavivanar/index.css";
|
||||||
|
import "@fontsource/space-mono/index.css";
|
||||||
|
import "@fontsource/cutive-mono/index.css";
|
||||||
|
import "@fontsource/architects-daughter/index.css";
|
||||||
|
import "@fontsource/redacted-script/index.css";
|
||||||
|
|
||||||
const PAD = 36;
|
const PAD = 36;
|
||||||
const BASE_WIDTH = 680;
|
const BASE_WIDTH = 680;
|
||||||
const DEFAULT_LOGICAL_HEIGHT = 900;
|
const DEFAULT_LOGICAL_HEIGHT = 900;
|
||||||
@@ -184,9 +190,7 @@ export function ComposeCanvas({
|
|||||||
fontFamily: DEFAULT_FONT_FAMILY,
|
fontFamily: DEFAULT_FONT_FAMILY,
|
||||||
fill: DEFAULT_FONT_COLOR,
|
fill: DEFAULT_FONT_COLOR,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
// NOTE: splitByGrapheme is required for word wrap and re-low
|
splitByGrapheme: false,
|
||||||
// but fabric asks to disable this for clear font?? So we disable it for read view
|
|
||||||
splitByGrapheme: !readOnly,
|
|
||||||
lockMovementX: true,
|
lockMovementX: true,
|
||||||
lockMovementY: true,
|
lockMovementY: true,
|
||||||
lockScalingX: true,
|
lockScalingX: true,
|
||||||
@@ -220,6 +224,16 @@ export function ComposeCanvas({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const img of canvas.getObjects("Image")) {
|
||||||
|
img.set({
|
||||||
|
hasControls: !readOnly,
|
||||||
|
hasBorders: !readOnly,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: fabric refreshes fonts once the textbox is rendered after initial focus
|
||||||
|
await document.fonts.ready;
|
||||||
|
textbox.set("dirty", true);
|
||||||
syncViewport();
|
syncViewport();
|
||||||
|
|
||||||
// Hack: Fabric needs a small initial delay to mount before it will accept focus.
|
// Hack: Fabric needs a small initial delay to mount before it will accept focus.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function PostSealModal({
|
|||||||
type = "KEPT",
|
type = "KEPT",
|
||||||
}: PostSealModalProps) {
|
}: PostSealModalProps) {
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={!!sealedTargetId}>
|
<Modal isOpen={!!sealedTargetId} data-testid="post-seal-modal">
|
||||||
<LockIcon size={32} weight="duotone" className="text-primary mt-3" />
|
<LockIcon size={32} weight="duotone" className="text-primary mt-3" />
|
||||||
<h3 className="font-serif text-2xl">Your letter is sealed</h3>
|
<h3 className="font-serif text-2xl">Your letter is sealed</h3>
|
||||||
<p className="text-base-content/60">
|
<p className="text-base-content/60">
|
||||||
@@ -53,6 +53,7 @@ export function PostSealModal({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="keep-it-btn"
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
onClick={() => navigate(ROUTES.DRAWER)}
|
onClick={() => navigate(ROUTES.DRAWER)}
|
||||||
>
|
>
|
||||||
@@ -60,6 +61,7 @@ export function PostSealModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="view-letter-btn"
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
onClick={() => navigate(PATHS.read(sealedTargetId!))}
|
onClick={() => navigate(PATHS.read(sealedTargetId!))}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export function ToolBar({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="draft-btn"
|
||||||
className="btn btn-ghost btn-sm text-xxs group tracking-widester uppercase font-bold text-base-content/60 hover:text-base-content"
|
className="btn btn-ghost btn-sm text-xxs group tracking-widester uppercase font-bold text-base-content/60 hover:text-base-content"
|
||||||
title="Store in your private drawer"
|
title="Store in your private drawer"
|
||||||
onClick={() => onSave("DRAFT")}
|
onClick={() => onSave("DRAFT")}
|
||||||
@@ -155,6 +156,7 @@ export function ToolBar({
|
|||||||
{/*Seal */}
|
{/*Seal */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="seal-trigger-btn"
|
||||||
className={`btn btn-primary btn-sm rounded-full px-6 group ${sealBtnClicked ? "invisible" : "visible"}`}
|
className={`btn btn-primary btn-sm rounded-full px-6 group ${sealBtnClicked ? "invisible" : "visible"}`}
|
||||||
onClick={() => setSealBtnClicked(true)}
|
onClick={() => setSealBtnClicked(true)}
|
||||||
>
|
>
|
||||||
@@ -176,6 +178,7 @@ export function ToolBar({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="seal-confirm-btn"
|
||||||
className="btn btn-accent btn-sm rounded-full px-6 group"
|
className="btn btn-accent btn-sm rounded-full px-6 group"
|
||||||
onClick={() => onSave("SEALED")}
|
onClick={() => onSave("SEALED")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -34,8 +34,7 @@ export default function WelcomeModal({
|
|||||||
className="inline text-primary"
|
className="inline text-primary"
|
||||||
weight="fill"
|
weight="fill"
|
||||||
/>
|
/>
|
||||||
<div className="divider my-0"></div>
|
<span className="divider my-0 block"></span>
|
||||||
<br />
|
|
||||||
Everything you write here is sealed with your password,{" "}
|
Everything you write here is sealed with your password,{" "}
|
||||||
<span className="font-display text-success">cryptographically</span>
|
<span className="font-display text-success">cryptographically</span>
|
||||||
, before it leaves your hands.
|
, before it leaves your hands.
|
||||||
@@ -44,11 +43,11 @@ export default function WelcomeModal({
|
|||||||
|
|
||||||
<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">
|
||||||
<WarningIcon size={24} weight="fill" className="shrink-0 mt-0.5" />
|
<WarningIcon size={24} weight="fill" className="shrink-0 mt-0.5" />
|
||||||
<p className="text-sm font-medium text-primary-content">
|
<div 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 />
|
<br />
|
||||||
<span className="font-bold mt-2">
|
<span className="font-bold mt-2 block">
|
||||||
I highly, highly recommend storing this password in your{" "}
|
I highly, highly recommend storing this password in your{" "}
|
||||||
<a
|
<a
|
||||||
href="https://www.privacyguides.org/en/passwords/"
|
href="https://www.privacyguides.org/en/passwords/"
|
||||||
@@ -60,12 +59,13 @@ export default function WelcomeModal({
|
|||||||
</a>{" "}
|
</a>{" "}
|
||||||
or somewhere safe to remember it.
|
or somewhere safe to remember it.
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-action w-full">
|
<div className="modal-action w-full">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="welcome-dismiss-btn"
|
||||||
onClick={() => setShowWelcome(false)}
|
onClick={() => setShowWelcome(false)}
|
||||||
className="btn btn-primary w-full shadow-lg"
|
className="btn btn-primary w-full shadow-lg"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export function EnvelopeReveal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
|
data-testid="wax-seal"
|
||||||
className={
|
className={
|
||||||
"translate-y-24 delay-2000 absolute z-6 peer-has-checked:pointer-events-none peer-has-checked:opacity-0 peer-has-checked:delay-0 transition-opacity duration-1000 cursor-pointer"
|
"translate-y-24 delay-2000 absolute z-6 peer-has-checked:pointer-events-none peer-has-checked:opacity-0 peer-has-checked:delay-0 transition-opacity duration-1000 cursor-pointer"
|
||||||
}
|
}
|
||||||
@@ -91,6 +92,7 @@ export function EnvelopeReveal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="letter"
|
id="letter"
|
||||||
|
data-testid="envelope-letter"
|
||||||
className={`absolute mx-auto transition-all peer-has-checked:delay-800 peer-has-checked:duration-1000 duration-1000 mt-2 h-55 w-105 bg-paper peer-has-checked:-mt-12 hover:-mt-24 cursor-pointer ${revealLetter ? "duration-1000 peer-has-checked:duration-3000 w-screen max-w-4xl h-screen z-101 -translate-y-90" : "peer-has-checked:z-1"}`}
|
className={`absolute mx-auto transition-all peer-has-checked:delay-800 peer-has-checked:duration-1000 duration-1000 mt-2 h-55 w-105 bg-paper peer-has-checked:-mt-12 hover:-mt-24 cursor-pointer ${revealLetter ? "duration-1000 peer-has-checked:duration-3000 w-screen max-w-4xl h-screen z-101 -translate-y-90" : "peer-has-checked:z-1"}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
></button>
|
></button>
|
||||||
@@ -112,6 +114,7 @@ export function EnvelopeReveal({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
id="env-front"
|
id="env-front"
|
||||||
|
data-testid="envelope-front"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!isInteractive}
|
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" : ""}`}
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal isOpen={!!shareLink} onClose={() => setShareLink(null)}>
|
<Modal
|
||||||
|
isOpen={!!shareLink}
|
||||||
|
onClose={() => setShareLink(null)}
|
||||||
|
data-testid="share-letter-modal"
|
||||||
|
>
|
||||||
<div className="flex flex-col items-center justify-center text-center gap-6 py-4">
|
<div className="flex flex-col items-center justify-center text-center gap-6 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<PaperPlaneTiltIcon
|
<PaperPlaneTiltIcon
|
||||||
@@ -47,6 +51,7 @@ export function ShareModal({ shareLink, setShareLink }: ShareModalProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={copyToClipboard}
|
onClick={copyToClipboard}
|
||||||
|
data-testid="copy-link-btn"
|
||||||
className="btn btn-primary font-sans btn-sm rounded-tl-xl rounded-bl-xl rounded-tr-full rounded-br-full"
|
className="btn btn-primary font-sans btn-sm rounded-tl-xl rounded-bl-xl rounded-tr-full rounded-br-full"
|
||||||
>
|
>
|
||||||
Copy
|
Copy
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface FormFieldProps {
|
|||||||
registration: UseFormRegisterReturn;
|
registration: UseFormRegisterReturn;
|
||||||
error?: string;
|
error?: string;
|
||||||
handleFocus?: () => void;
|
handleFocus?: () => void;
|
||||||
|
"data-testid"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FormField({
|
export default function FormField({
|
||||||
@@ -16,6 +17,7 @@ export default function FormField({
|
|||||||
registration,
|
registration,
|
||||||
error,
|
error,
|
||||||
handleFocus,
|
handleFocus,
|
||||||
|
"data-testid": testId,
|
||||||
}: FormFieldProps) {
|
}: FormFieldProps) {
|
||||||
return (
|
return (
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
@@ -28,6 +30,7 @@ export default function FormField({
|
|||||||
<input
|
<input
|
||||||
{...registration}
|
{...registration}
|
||||||
id={registration.name}
|
id={registration.name}
|
||||||
|
data-testid={testId}
|
||||||
type={type}
|
type={type}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={`input input-bordered focus:input-primary ${
|
className={`input input-bordered focus:input-primary ${
|
||||||
|
|||||||
@@ -5,17 +5,27 @@ interface ModalProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
"data-testid"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({ isOpen, onClose, children }: ModalProps) {
|
export function Modal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
"data-testid": testId,
|
||||||
|
}: ModalProps) {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal modal-open modal-middle backdrop-blur-md 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')]">
|
<div
|
||||||
|
data-testid={testId}
|
||||||
|
className="modal modal-open modal-middle backdrop-blur-md 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')]"
|
||||||
|
>
|
||||||
<div className="modal-box relative bg-base-100/60 flex flex-col items-center text-center gap-6">
|
<div className="modal-box relative bg-base-100/60 flex flex-col items-center text-center gap-6">
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid="modal-close-btn"
|
||||||
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 z-20"
|
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 z-20"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { CanvasJSON } from "../components/editor/ComposeCanvas";
|
||||||
|
|
||||||
|
export function getWelcomeLetterContent(userName: string): CanvasJSON {
|
||||||
|
return {
|
||||||
|
objects: [
|
||||||
|
{
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 500,
|
||||||
|
fontFamily: "Kavivanar",
|
||||||
|
fontStyle: "normal",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
text: `\nDear ${userName}, \n\nYou made it this far, which means something already brought you here. \nA name, maybe. A feeling you haven't been able to shake. Something you typed and deleted too many times to count.\n\nMost people carry it quietly. They tell themselves it doesn't matter anymore, or that too much time has passed, or that the other person wouldn't understand anyway. And maybe they're right. \n\nBut the thing is — the unsaid thing doesn't really care about any of that. \nIt just stays.\n\nSo here you are.\n\nYou don't have to know what you want to say yet. \nYou don't have to have it figured out — who it's for, or why it still matters, or what you're hoping will happen after. \n\nA lot of letters written here start without any of that. They find their way.\n\nTake your time. \nNo one's watching. \n\nWhen you're ready, write a letter.\n\nSometimes the wrong train takes you to the right station.\n- S.F.`,
|
||||||
|
charSpacing: 0,
|
||||||
|
textAlign: "left",
|
||||||
|
styles: [],
|
||||||
|
pathStartOffset: 0,
|
||||||
|
pathSide: "left",
|
||||||
|
pathAlign: "baseline",
|
||||||
|
underline: false,
|
||||||
|
overline: false,
|
||||||
|
linethrough: false,
|
||||||
|
textBackgroundColor: "",
|
||||||
|
direction: "ltr",
|
||||||
|
textDecorationThickness: 66.667,
|
||||||
|
minWidth: 20,
|
||||||
|
splitByGrapheme: false,
|
||||||
|
type: "Textbox",
|
||||||
|
version: "7.2.0",
|
||||||
|
originX: "left",
|
||||||
|
originY: "top",
|
||||||
|
left: 36,
|
||||||
|
top: 36,
|
||||||
|
width: 608,
|
||||||
|
height: 813.6,
|
||||||
|
fill: "#111e67",
|
||||||
|
stroke: null,
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeDashArray: null,
|
||||||
|
strokeLineCap: "butt",
|
||||||
|
strokeDashOffset: 0,
|
||||||
|
strokeLineJoin: "miter",
|
||||||
|
strokeUniform: false,
|
||||||
|
strokeMiterLimit: 4,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
angle: 0,
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
opacity: 1,
|
||||||
|
shadow: null,
|
||||||
|
visible: true,
|
||||||
|
backgroundColor: "",
|
||||||
|
fillRule: "nonzero",
|
||||||
|
paintFirst: "fill",
|
||||||
|
globalCompositeOperation: "source-over",
|
||||||
|
skewX: 0,
|
||||||
|
skewY: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cropX: 0,
|
||||||
|
cropY: 0,
|
||||||
|
type: "Image",
|
||||||
|
version: "7.2.0",
|
||||||
|
originX: "left",
|
||||||
|
originY: "top",
|
||||||
|
left: 298.4065,
|
||||||
|
top: 660.2853,
|
||||||
|
width: 512,
|
||||||
|
height: 400,
|
||||||
|
fill: "rgb(0,0,0)",
|
||||||
|
stroke: null,
|
||||||
|
strokeWidth: 0,
|
||||||
|
strokeDashArray: null,
|
||||||
|
strokeLineCap: "butt",
|
||||||
|
strokeDashOffset: 0,
|
||||||
|
strokeLineJoin: "miter",
|
||||||
|
strokeUniform: false,
|
||||||
|
strokeMiterLimit: 4,
|
||||||
|
scaleX: 0.4753,
|
||||||
|
scaleY: 0.4753,
|
||||||
|
angle: 355.5436,
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
opacity: 1,
|
||||||
|
shadow: null,
|
||||||
|
visible: true,
|
||||||
|
backgroundColor: "",
|
||||||
|
fillRule: "nonzero",
|
||||||
|
paintFirst: "fill",
|
||||||
|
globalCompositeOperation: "source-over",
|
||||||
|
skewX: 0,
|
||||||
|
skewY: 0,
|
||||||
|
src: "/screenshots/train.png",
|
||||||
|
crossOrigin: null,
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
canvasWidth: 680,
|
||||||
|
canvasHeight: 900,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { mockUser } from "../../test/fixtures/user.fixture";
|
|||||||
import { server } from "../../test/mocks/server";
|
import { server } from "../../test/mocks/server";
|
||||||
import { useAuthStore } from "../store/useAuthStore";
|
import { useAuthStore } from "../store/useAuthStore";
|
||||||
import { useKeyStore } from "../store/useKeyStore";
|
import { useKeyStore } from "../store/useKeyStore";
|
||||||
|
import { CryptoUtils } from "../utils/crypto";
|
||||||
import {
|
import {
|
||||||
clearMasterKey,
|
clearMasterKey,
|
||||||
loadMasterKey,
|
loadMasterKey,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
import { useAuth } from "./useAuth";
|
import { useAuth } from "./useAuth";
|
||||||
|
|
||||||
vi.mock("../utils/keystore");
|
vi.mock("../utils/keystore");
|
||||||
|
vi.mock("../utils/crypto");
|
||||||
|
|
||||||
const VITE_API_URL = "http://piku-server";
|
const VITE_API_URL = "http://piku-server";
|
||||||
|
|
||||||
@@ -30,6 +32,11 @@ beforeEach(() => {
|
|||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
});
|
});
|
||||||
useKeyStore.setState({ masterKey: null });
|
useKeyStore.setState({ masterKey: null });
|
||||||
|
|
||||||
|
vi.mocked(CryptoUtils.deriveKeyBundle).mockResolvedValue({
|
||||||
|
masterKey: mockMasterKey,
|
||||||
|
authHash: "mock-hash",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isAuthenticated", () => {
|
describe("isAuthenticated", () => {
|
||||||
@@ -201,3 +208,68 @@ describe("initialize", () => {
|
|||||||
expect(useKeyStore.getState().masterKey).not.toBeNull();
|
expect(useKeyStore.getState().masterKey).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("unlock", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAuthStore.setState({
|
||||||
|
accessToken: "valid-token",
|
||||||
|
user: mockUser,
|
||||||
|
isInitializing: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should derive the master key from the user password, validate it via API, and persist it", async () => {
|
||||||
|
let loginCalled = false;
|
||||||
|
server.use(
|
||||||
|
http.post(`${VITE_API_URL}/api/auth/login/`, async () => {
|
||||||
|
loginCalled = true;
|
||||||
|
return HttpResponse.json({ access: "token", user: mockUser });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.unlock("password");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(CryptoUtils.deriveKeyBundle).toHaveBeenCalledWith(
|
||||||
|
"password",
|
||||||
|
mockUser.email,
|
||||||
|
);
|
||||||
|
expect(loginCalled).toBe(true);
|
||||||
|
expect(saveMasterKey).toHaveBeenCalledWith(mockMasterKey);
|
||||||
|
expect(useKeyStore.getState().masterKey).toEqual(mockMasterKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should logout if user is not present", async () => {
|
||||||
|
useAuthStore.setState({ user: null });
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.unlock("password");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(CryptoUtils.deriveKeyBundle).not.toHaveBeenCalled();
|
||||||
|
expect(saveMasterKey).not.toHaveBeenCalled();
|
||||||
|
expect(useAuthStore.getState().accessToken).toBeNull();
|
||||||
|
expect(clearMasterKey).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw an error and not persist the key if validation fails", async () => {
|
||||||
|
server.use(
|
||||||
|
http.post(
|
||||||
|
`${VITE_API_URL}/api/auth/login/`,
|
||||||
|
() => new HttpResponse(null, { status: 400 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await expect(result.current.unlock("wrong-password")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saveMasterKey).not.toHaveBeenCalled();
|
||||||
|
expect(useKeyStore.getState().masterKey).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ export const useAuth = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// try session refresh
|
|
||||||
const { data: refreshData } = await publicApi.post(endpoints.REFRESH);
|
const { data: refreshData } = await publicApi.post(endpoints.REFRESH);
|
||||||
const { data: userData } = await api.get(endpoints.ME, {
|
const { data: userData } = await api.get(endpoints.ME, {
|
||||||
headers: { Authorization: `Bearer ${refreshData.access}` },
|
headers: { Authorization: `Bearer ${refreshData.access}` },
|
||||||
@@ -71,16 +70,24 @@ export const useAuth = () => {
|
|||||||
}, [setMasterKey]);
|
}, [setMasterKey]);
|
||||||
|
|
||||||
const unlock = async (password: string) => {
|
const unlock = async (password: string) => {
|
||||||
if (!user) return;
|
if (!user) {
|
||||||
|
await logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle(
|
||||||
const { masterKey } = await CryptoUtils.deriveKeyBundle(
|
password,
|
||||||
password,
|
user.email,
|
||||||
user.email,
|
);
|
||||||
);
|
|
||||||
await saveMasterKey(masterKey);
|
// Validate password by calling login endpoint
|
||||||
setMasterKey(masterKey);
|
await api.post(endpoints.LOGIN, {
|
||||||
} catch {}
|
email: user.email,
|
||||||
|
password: authHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveMasterKey(masterKey);
|
||||||
|
setMasterKey(masterKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,95 +7,96 @@ import { endpoints, replacePathParams } from "../config/endpoints";
|
|||||||
import { ROUTES } from "../config/routes";
|
import { ROUTES } from "../config/routes";
|
||||||
|
|
||||||
export default function Activate() {
|
export default function Activate() {
|
||||||
const { uidb64, token } = useParams();
|
const { uidb64, token } = useParams();
|
||||||
const [status, setStatus] = useState<"loading" | "success" | "error">(
|
const [status, setStatus] = useState<"loading" | "success" | "error">(
|
||||||
"loading",
|
"loading",
|
||||||
);
|
);
|
||||||
const hasCalled = useRef(false);
|
const hasCalled = useRef(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!(uidb64 && token) || hasCalled.current) return;
|
if (!(uidb64 && token) || hasCalled.current) return;
|
||||||
hasCalled.current = true;
|
hasCalled.current = true;
|
||||||
|
|
||||||
const activateAccount = async () => {
|
const activateAccount = async () => {
|
||||||
try {
|
try {
|
||||||
const url = replacePathParams(endpoints.ACTIVATE, {
|
const url = replacePathParams(endpoints.ACTIVATE, {
|
||||||
uidb64,
|
uidb64,
|
||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
await publicApi.get(url);
|
await publicApi.get(url);
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
activateAccount();
|
|
||||||
}, [uidb64, token]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="glass-card w-full max-w-sm p-8 text-center fade-zoom">
|
|
||||||
{status === "loading" && (
|
|
||||||
<div className="flex flex-col items-center gap-4 py-8">
|
|
||||||
<span className="loading loading-spinner loading-lg text-primary" />
|
|
||||||
<p className="text-sm opacity-70">Activating your account...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "success" && (
|
|
||||||
<div className="flex flex-col items-center gap-6 duration-500">
|
|
||||||
<div className="bg-success/10 p-4 rounded-full">
|
|
||||||
<CheckCircleIcon
|
|
||||||
size={64}
|
|
||||||
weight="duotone"
|
|
||||||
className="text-success"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 className="font-display text-xl text-success">
|
|
||||||
Account Activated!
|
|
||||||
</h2>
|
|
||||||
<p className="opacity-70 leading-relaxed">
|
|
||||||
Welcome to <Logo scale={1} />
|
|
||||||
<br />
|
|
||||||
Your identity is now verified and ready for timeless letters.
|
|
||||||
</p>
|
|
||||||
<div className="divider opacity-10 my-0"></div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary w-full shadow-lg"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(ROUTES.LOGIN, {
|
|
||||||
state: { firstTime: true },
|
|
||||||
replace: true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
};
|
||||||
Start Writing
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "error" && (
|
activateAccount();
|
||||||
<div className="flex flex-col items-center gap-6 animate-in fade-in zoom-in duration-500">
|
}, [uidb64, token]);
|
||||||
<div className="bg-error/10 p-4 rounded-full">
|
|
||||||
<XCircleIcon size={64} weight="duotone" className="text-error" />
|
return (
|
||||||
</div>
|
<div className="glass-card w-full max-w-sm p-8 text-center fade-zoom">
|
||||||
<h2 className="font-display text-xl text-error">Activation Failed</h2>
|
{status === "loading" && (
|
||||||
<p className="opacity-70 leading-relaxed">
|
<div className="flex flex-col items-center gap-4 py-8">
|
||||||
The link might be expired or already used. Please try registering
|
<span className="loading loading-spinner loading-lg text-primary" />
|
||||||
again.
|
<p className="text-sm opacity-70">Activating your account...</p>
|
||||||
</p>
|
</div>
|
||||||
<div className="divider opacity-10 my-0"></div>
|
)}
|
||||||
<button
|
|
||||||
type="button"
|
{status === "success" && (
|
||||||
className="btn btn-ghost w-full"
|
<div className="flex flex-col items-center gap-6 duration-500">
|
||||||
onClick={() => navigate(ROUTES.ONBOARD)}
|
<div className="bg-success/10 p-4 rounded-full">
|
||||||
>
|
<CheckCircleIcon
|
||||||
Register Again
|
size={64}
|
||||||
</button>
|
weight="duotone"
|
||||||
|
className="text-success"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2 data-testid="activation-success-header" className="font-display text-xl text-success">
|
||||||
|
You're in.
|
||||||
|
</h2>
|
||||||
|
<p className="opacity-70 leading-relaxed">
|
||||||
|
Welcome to <Logo scale={1} />
|
||||||
|
<br />
|
||||||
|
Just one more step and you can start writing timeless letters.
|
||||||
|
</p>
|
||||||
|
<div className="divider opacity-10 my-0"></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="start-writing-btn"
|
||||||
|
className="btn btn-primary w-full shadow-lg"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(ROUTES.LOGIN, {
|
||||||
|
state: { firstTime: true },
|
||||||
|
replace: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
I'm ready
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "error" && (
|
||||||
|
<div className="flex flex-col items-center gap-6 animate-in fade-in zoom-in duration-500">
|
||||||
|
<div className="bg-error/10 p-4 rounded-full">
|
||||||
|
<XCircleIcon size={64} weight="duotone" className="text-error" />
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-xl text-error">Activation Failed</h2>
|
||||||
|
<p className="opacity-70 leading-relaxed">
|
||||||
|
The link might be expired or already used. Please try registering
|
||||||
|
again.
|
||||||
|
</p>
|
||||||
|
<div className="divider opacity-10 my-0"></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost w-full"
|
||||||
|
onClick={() => navigate(ROUTES.ONBOARD)}
|
||||||
|
>
|
||||||
|
Register Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,124 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { mockUser } from "../../test/fixtures/user.fixture";
|
import { mockUser } from "../../test/fixtures/user.fixture";
|
||||||
|
import type { WelcomeLetterOverlayProps } from "../components/drawer/WelcomeLetterOverlay";
|
||||||
import { useLetters } from "../hooks/useLetters";
|
import { useLetters } from "../hooks/useLetters";
|
||||||
import { useAuthStore } from "../store/useAuthStore";
|
import { useAuthStore } from "../store/useAuthStore";
|
||||||
import Drawer from "./Drawer";
|
import Drawer from "./Drawer";
|
||||||
|
|
||||||
vi.mock("../hooks/useLetters");
|
vi.mock("../hooks/useLetters");
|
||||||
|
vi.mock("../components/drawer/WelcomeLetterOverlay", () => ({
|
||||||
|
WelcomeLetterOverlay: ({ onComplete }: WelcomeLetterOverlayProps) => (
|
||||||
|
<div data-testid="welcome-letter-overlay">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="overlay-exit-button"
|
||||||
|
onClick={onComplete}
|
||||||
|
>
|
||||||
|
I'll see you
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("Drawer Page", () => {
|
describe("Drawer Page", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Setup authenticated state for the test
|
// Setup authenticated state for the test
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
accessToken: "fake-token",
|
accessToken: "fake-token",
|
||||||
isInitializing: false,
|
isInitializing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(useLetters).mockReturnValue({
|
||||||
|
drafts: [],
|
||||||
|
kept: [],
|
||||||
|
sent: [],
|
||||||
|
vault: [],
|
||||||
|
loading: false,
|
||||||
|
isAuthRequired: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mocked(useLetters).mockReturnValue({
|
it("renders the cabinet sections and empty state message", () => {
|
||||||
drafts: [],
|
render(
|
||||||
kept: [],
|
<MemoryRouter>
|
||||||
sent: [],
|
<Drawer />
|
||||||
vault: [],
|
</MemoryRouter>,
|
||||||
loading: false,
|
);
|
||||||
isAuthRequired: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the cabinet sections and empty state message", () => {
|
expect(screen.getByText(/Drafts/i)).toBeInTheDocument();
|
||||||
render(
|
expect(screen.getAllByText(/Kept/i).length).toBeGreaterThanOrEqual(1);
|
||||||
<MemoryRouter>
|
expect(screen.getByText(/Vault/i)).toBeInTheDocument();
|
||||||
<Drawer />
|
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument();
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Drafts/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText(/Kept/i).length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(screen.getByText(/Vault/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the loading state", () => {
|
|
||||||
vi.mocked(useLetters).mockReturnValue({
|
|
||||||
drafts: [],
|
|
||||||
kept: [],
|
|
||||||
sent: [],
|
|
||||||
vault: [],
|
|
||||||
loading: true,
|
|
||||||
isAuthRequired: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
render(
|
it("renders the loading state", () => {
|
||||||
<MemoryRouter>
|
vi.mocked(useLetters).mockReturnValue({
|
||||||
<Drawer />
|
drafts: [],
|
||||||
</MemoryRouter>,
|
kept: [],
|
||||||
);
|
sent: [],
|
||||||
|
vault: [],
|
||||||
|
loading: true,
|
||||||
|
isAuthRequired: false,
|
||||||
|
});
|
||||||
|
|
||||||
expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument();
|
render(
|
||||||
});
|
<MemoryRouter>
|
||||||
|
<Drawer />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
it("renders the authentication required modal when api requires auth", () => {
|
expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument();
|
||||||
vi.mocked(useLetters).mockReturnValue({
|
|
||||||
drafts: [],
|
|
||||||
kept: [],
|
|
||||||
sent: [],
|
|
||||||
vault: [],
|
|
||||||
loading: false,
|
|
||||||
isAuthRequired: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
render(
|
it("renders the authentication required modal when api requires auth", () => {
|
||||||
<MemoryRouter>
|
vi.mocked(useLetters).mockReturnValue({
|
||||||
<Drawer />
|
drafts: [],
|
||||||
</MemoryRouter>,
|
kept: [],
|
||||||
);
|
sent: [],
|
||||||
|
vault: [],
|
||||||
|
loading: false,
|
||||||
|
isAuthRequired: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(screen.getByText(/Authentication Required/i)).toBeInTheDocument();
|
render(
|
||||||
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
|
<MemoryRouter>
|
||||||
});
|
<Drawer />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/You've been away a while./i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the welcome letter when firstTime state is present", () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter
|
||||||
|
initialEntries={[{ pathname: "/drawer", state: { firstTime: true } }]}
|
||||||
|
>
|
||||||
|
<Drawer />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("welcome-letter-overlay")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the drawer content when the letter is closed", () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter
|
||||||
|
initialEntries={[{ pathname: "/drawer", state: { firstTime: true } }]}
|
||||||
|
>
|
||||||
|
<Drawer />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const completeButton = screen.getByTestId("overlay-exit-button");
|
||||||
|
fireEvent.click(completeButton);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("welcome-letter-overlay"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { FeatherIcon } from "@phosphor-icons/react";
|
import { FeatherIcon } from "@phosphor-icons/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { DrawerSection } from "../components/drawer/DrawerSection.tsx";
|
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 { WelcomeLetterOverlay } from "../components/drawer/WelcomeLetterOverlay.tsx";
|
||||||
import Logo from "../components/Logo";
|
import Logo from "../components/Logo";
|
||||||
import Saajan from "../components/ui/Saajan.tsx";
|
import Saajan from "../components/ui/Saajan.tsx";
|
||||||
import { PATHS } from "../config/routes";
|
import { PATHS } from "../config/routes";
|
||||||
@@ -15,10 +16,14 @@ import {
|
|||||||
} from "../utils/dateFormat.ts";
|
} from "../utils/dateFormat.ts";
|
||||||
|
|
||||||
export default function Drawer() {
|
export default function Drawer() {
|
||||||
const { user, logout, unlock } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
const [openSection, setOpenSection] = useState<string | null>(null);
|
const [openSection, setOpenSection] = useState<string | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [showWelcomeLetter, setShowWelcomeLetter] = useState(
|
||||||
|
!!location.state?.firstTime,
|
||||||
|
);
|
||||||
const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters();
|
const { drafts, kept, sent, vault, loading, isAuthRequired } = useLetters();
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
@@ -30,7 +35,17 @@ export default function Drawer() {
|
|||||||
<div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors">
|
<div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors">
|
||||||
<div className="fixed inset-0 bg-vig pointer-events-none z-0" />
|
<div className="fixed inset-0 bg-vig pointer-events-none z-0" />
|
||||||
|
|
||||||
{isAuthRequired && <PasskeyModal onUnlock={unlock} />}
|
{showWelcomeLetter && (
|
||||||
|
<WelcomeLetterOverlay
|
||||||
|
userName={user.full_name}
|
||||||
|
onComplete={() => {
|
||||||
|
setShowWelcomeLetter(false);
|
||||||
|
navigate(location.pathname, { replace: true, state: {} });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAuthRequired && <PasskeyModal />}
|
||||||
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500">
|
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||||
<Logo />
|
<Logo />
|
||||||
<div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2">
|
<div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2">
|
||||||
@@ -144,6 +159,7 @@ export default function Drawer() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="write-letter-btn"
|
id="write-letter-btn"
|
||||||
|
data-testid="write-letter-btn"
|
||||||
className="group mt-15 z-10 bg-transparent border border-dashed border-base-content/10 px-8 py-4 text-base-content/40 italic cursor-pointer transition-all hover:border-primary/40 hover:text-base-content/60 hover:bg-primary/5 hover:-translate-y-0.5 flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-primary/50 duration-500"
|
className="group mt-15 z-10 bg-transparent border border-dashed border-base-content/10 px-8 py-4 text-base-content/40 italic cursor-pointer transition-all hover:border-primary/40 hover:text-base-content/60 hover:bg-primary/5 hover:-translate-y-0.5 flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-primary/50 duration-500"
|
||||||
onClick={() => navigate(PATHS.write(""))}
|
onClick={() => navigate(PATHS.write(""))}
|
||||||
>
|
>
|
||||||
@@ -166,12 +182,14 @@ export default function Drawer() {
|
|||||||
<footer className="mt-25 font-sans text-[0.6rem] tracking-widester uppercase text-base-content/10 z-10">
|
<footer className="mt-25 font-sans text-[0.6rem] tracking-widester uppercase text-base-content/10 z-10">
|
||||||
For your unsaid.
|
For your unsaid.
|
||||||
</footer>
|
</footer>
|
||||||
<div className="absolute bottom-0 z-50 font-sans">
|
{!showWelcomeLetter && (
|
||||||
<Saajan
|
<div className="absolute bottom-0 z-50 font-sans">
|
||||||
message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`}
|
<Saajan
|
||||||
position="top"
|
message={`Good to see you again, ${user.full_name}.\nWhat's on your mind today?`}
|
||||||
/>
|
position="top"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,6 @@ import { CryptoUtils } from "../utils/crypto";
|
|||||||
import { formatRelativeDate } from "../utils/dateFormat";
|
import { formatRelativeDate } from "../utils/dateFormat";
|
||||||
import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic";
|
import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic";
|
||||||
|
|
||||||
import "@fontsource/kavivanar/index.css";
|
|
||||||
import "@fontsource/space-mono/index.css";
|
|
||||||
import "@fontsource/cutive-mono/index.css";
|
|
||||||
import "@fontsource/architects-daughter/index.css";
|
|
||||||
import "@fontsource/redacted-script/index.css";
|
|
||||||
|
|
||||||
type SaveOverlay = "IDLE" | "SAVING" | "SAVED" | "ERROR";
|
type SaveOverlay = "IDLE" | "SAVING" | "SAVED" | "ERROR";
|
||||||
|
|
||||||
const OVERLAY_FADE_MS = 250;
|
const OVERLAY_FADE_MS = 250;
|
||||||
@@ -268,7 +262,9 @@ export default function Editor() {
|
|||||||
await cryptoUtils.initialize();
|
await cryptoUtils.initialize();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const canvasData = canvasRef.current?.getData() || { objects: [] };
|
const canvasData = (await canvasRef.current?.getData()) || {
|
||||||
|
objects: [],
|
||||||
|
};
|
||||||
const canvasImages = canvasRef.current?.getImages() || [];
|
const canvasImages = canvasRef.current?.getImages() || [];
|
||||||
|
|
||||||
const { encryptedImageFiles, encryptedCanvasData } =
|
const { encryptedImageFiles, encryptedCanvasData } =
|
||||||
@@ -380,7 +376,10 @@ export default function Editor() {
|
|||||||
weight="bold"
|
weight="bold"
|
||||||
className="animate-spin text-primary"
|
className="animate-spin text-primary"
|
||||||
/>
|
/>
|
||||||
<p className="text-xxs uppercase tracking-widester font-bold text-base-content/40">
|
<p
|
||||||
|
data-testid="opening-draft-overlay"
|
||||||
|
className="text-xxs uppercase tracking-widester font-bold text-base-content/40"
|
||||||
|
>
|
||||||
Opening your draft...
|
Opening your draft...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -410,6 +409,7 @@ export default function Editor() {
|
|||||||
{saveOverlay === "SAVED" && (
|
{saveOverlay === "SAVED" && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
|
data-testid="save-success-toast"
|
||||||
className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${
|
className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${
|
||||||
showSaveOverlay
|
showSaveOverlay
|
||||||
? "opacity-100 scale-100 translate-y-0"
|
? "opacity-100 scale-100 translate-y-0"
|
||||||
@@ -463,6 +463,7 @@ export default function Editor() {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="recipient"
|
id="recipient"
|
||||||
|
data-testid="recipient-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={toPlaceholderList[placeholderIndex]}
|
placeholder={toPlaceholderList[placeholderIndex]}
|
||||||
value={recipient}
|
value={recipient}
|
||||||
|
|||||||
+310
-309
@@ -1,9 +1,9 @@
|
|||||||
import { InfoIcon } from "@phosphor-icons/react";
|
import { InfoIcon } from "@phosphor-icons/react";
|
||||||
|
import { ReactLenis } from "lenis/react";
|
||||||
import {
|
import {
|
||||||
motion,
|
motion,
|
||||||
useMotionValueEvent,
|
useMotionValueEvent,
|
||||||
useScroll,
|
useScroll,
|
||||||
useSpring,
|
|
||||||
useTransform,
|
useTransform,
|
||||||
} from "motion/react";
|
} from "motion/react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
@@ -16,14 +16,9 @@ import { formatDate } from "../utils/dateFormat.ts";
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const sectionContainer1 = useRef<HTMLDivElement>(null);
|
const sectionContainer1 = useRef<HTMLDivElement>(null);
|
||||||
const { scrollYProgress: section1ScrollProgress } = useScroll({
|
const { scrollYProgress } = useScroll({
|
||||||
target: sectionContainer1,
|
target: sectionContainer1,
|
||||||
});
|
});
|
||||||
const smoothProgress1 = useSpring(section1ScrollProgress, {
|
|
||||||
stiffness: 100,
|
|
||||||
damping: 30,
|
|
||||||
restDelta: 0.001,
|
|
||||||
});
|
|
||||||
const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true);
|
const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true);
|
||||||
const [flapOpen, setFlapOpen] = useState(false);
|
const [flapOpen, setFlapOpen] = useState(false);
|
||||||
const [recipient, setRecipient] = useState("someone dear");
|
const [recipient, setRecipient] = useState("someone dear");
|
||||||
@@ -31,7 +26,7 @@ export default function Home() {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useMotionValueEvent(section1ScrollProgress, "change", (latestScrollValue) => {
|
useMotionValueEvent(scrollYProgress, "change", (latestScrollValue) => {
|
||||||
if (latestScrollValue > 0.54) {
|
if (latestScrollValue > 0.54) {
|
||||||
setFlapOpen(false);
|
setFlapOpen(false);
|
||||||
} else {
|
} else {
|
||||||
@@ -55,342 +50,348 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<ReactLenis root options={{ lerp: 0.1, duration: 1.5, smoothWheel: true }}>
|
||||||
ref={sectionContainer1}
|
<section
|
||||||
className="relative w-full h-[850vh] bg-base-100 font-serif"
|
ref={sectionContainer1}
|
||||||
>
|
className="relative w-full h-[850vh] bg-base-100 font-serif"
|
||||||
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden">
|
>
|
||||||
{/* Intro */}
|
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden">
|
||||||
<motion.div
|
{/* Intro */}
|
||||||
className="absolute flex flex-col items-center justify-center pointer-events-none"
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(smoothProgress1, [0, 0.12, 1], [1, 0, 0]),
|
|
||||||
scale: useTransform(smoothProgress1, [0, 0.12], [1, 10]),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h1 className="text-neutral-content/40 text-4xl md:text-6xl text-center px-6">
|
|
||||||
You've been carrying something
|
|
||||||
</h1>
|
|
||||||
<h2 className="text-primary text-5xl md:text-7xl font-extralight mt-4 italic font-display animate-pulse">
|
|
||||||
unsaid
|
|
||||||
</h2>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="absolute text-center"
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(smoothProgress1, [0, 0.15, 0.2], [0, 1, 0]),
|
|
||||||
y: useTransform(smoothProgress1, [0, 0.15, 0.2], [40, 0, -40]),
|
|
||||||
scale: useTransform(smoothProgress1, [0, 0.15, 0.2], [0.8, 1, 3]),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="mt-6 text-4xl md:text-6xl text-base-content/60 italic">
|
|
||||||
and that's okay...
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
{/* pi. ku. */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute text-center px-6"
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(
|
|
||||||
smoothProgress1,
|
|
||||||
[0.18, 0.25, 0.3],
|
|
||||||
[0, 1, 0],
|
|
||||||
),
|
|
||||||
y: useTransform(smoothProgress1, [0.18, 0.25, 0.3], [20, 0, -20]),
|
|
||||||
}}
|
|
||||||
transition={{ delay: 4 }}
|
|
||||||
>
|
|
||||||
<Logo scale={2} />
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mt-6 text-4xl md:text-6xl text-base-content/60 "
|
className="absolute flex flex-col items-center justify-center pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
opacity: useTransform(
|
opacity: useTransform(scrollYProgress, [0, 0.12, 1], [1, 0, 0]),
|
||||||
smoothProgress1,
|
scale: useTransform(scrollYProgress, [0, 0.12], [1, 10]),
|
||||||
[0.22, 0.25, 0.35, 0.4],
|
|
||||||
[0, 1, 1, 0],
|
|
||||||
),
|
|
||||||
y: useTransform(
|
|
||||||
smoothProgress1,
|
|
||||||
[0.25, 0.3, 0.35, 0.4],
|
|
||||||
[20, 0, 0, -20],
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
is a{" "}
|
<h1 className="text-neutral-content/40 text-4xl md:text-6xl text-center px-6">
|
||||||
<span className="font-display text-primary font-extralight">
|
You've been carrying something
|
||||||
safe space
|
</h1>
|
||||||
</span>
|
<h2 className="text-primary text-5xl md:text-7xl font-extralight mt-4 italic font-display animate-pulse">
|
||||||
,<br />
|
unsaid
|
||||||
<motion.span
|
</h2>
|
||||||
className="opacity-0 text-3xl md:text-5xl"
|
|
||||||
transition={{ delay: 3 }}
|
|
||||||
whileInView={{ opacity: 1 }}
|
|
||||||
viewport={{ once: false, amount: 0.3 }}
|
|
||||||
>
|
|
||||||
where you can
|
|
||||||
</motion.span>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
|
<motion.div
|
||||||
<motion.h2
|
className="absolute text-center"
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(scrollYProgress, [0, 0.15, 0.2], [0, 1, 0]),
|
||||||
|
y: useTransform(scrollYProgress, [0, 0.15, 0.2], [40, 0, -40]),
|
||||||
|
scale: useTransform(scrollYProgress, [0, 0.15, 0.2], [0.8, 1, 3]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mt-6 text-4xl md:text-6xl text-base-content/60 italic">
|
||||||
|
and that's okay...
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
{/* pi. ku. */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute text-center px-6"
|
||||||
style={{
|
style={{
|
||||||
opacity: useTransform(
|
opacity: useTransform(
|
||||||
smoothProgress1,
|
scrollYProgress,
|
||||||
[0.3, 0.35, 0.4, 0.45],
|
[0.18, 0.25, 0.3],
|
||||||
[0, 1, 1, 0],
|
[0, 1, 0],
|
||||||
),
|
|
||||||
y: useTransform(
|
|
||||||
smoothProgress1,
|
|
||||||
[0.3, 0.35, 0.4, 0.45],
|
|
||||||
[40, 0, 0, -40],
|
|
||||||
),
|
),
|
||||||
|
y: useTransform(scrollYProgress, [0.18, 0.25, 0.3], [20, 0, -20]),
|
||||||
}}
|
}}
|
||||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
transition={{ delay: 4 }}
|
||||||
>
|
>
|
||||||
pen down your unsaid words into{" "}
|
<Logo scale={2} />
|
||||||
<span className="font-display text-primary font-extralight">
|
<motion.div
|
||||||
letters
|
className="mt-6 text-4xl md:text-6xl text-base-content/60 "
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</motion.h2>
|
|
||||||
{/* Seal */}
|
|
||||||
<motion.h2
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(
|
|
||||||
smoothProgress1,
|
|
||||||
[0.45, 0.5, 0.55, 0.6],
|
|
||||||
[0, 1, 1, 0],
|
|
||||||
),
|
|
||||||
y: useTransform(
|
|
||||||
smoothProgress1,
|
|
||||||
[0.45, 0.5, 0.55, 0.6],
|
|
||||||
[40, 0, 0, -40],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
|
||||||
>
|
|
||||||
seal it{" "}
|
|
||||||
<span className="text-secondary font-display italic font-extralight">
|
|
||||||
secure
|
|
||||||
</span>{" "}
|
|
||||||
and{" "}
|
|
||||||
<span className="text-secondary font-display font-extralight italic">
|
|
||||||
private
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</motion.h2>
|
|
||||||
{/* Send / vault */}
|
|
||||||
<motion.h2
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(
|
|
||||||
smoothProgress1,
|
|
||||||
[0.6, 0.63, 0.72, 0.75],
|
|
||||||
[0, 1, 1, 0],
|
|
||||||
),
|
|
||||||
y: useTransform(
|
|
||||||
smoothProgress1,
|
|
||||||
[0.6, 0.63, 0.72, 0.75],
|
|
||||||
[40, 0, 0, -40],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
|
||||||
>
|
|
||||||
send it to{" "}
|
|
||||||
<motion.span
|
|
||||||
className="font-display text-accent"
|
|
||||||
style={{
|
style={{
|
||||||
color: useTransform(
|
opacity: useTransform(
|
||||||
smoothProgress1,
|
scrollYProgress,
|
||||||
[0.67, 1],
|
[0.22, 0.25, 0.35, 0.4],
|
||||||
["var(--color-accent)", "var(--color-neutral)"],
|
[0, 1, 1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(
|
||||||
|
scrollYProgress,
|
||||||
|
[0.25, 0.3, 0.35, 0.4],
|
||||||
|
[20, 0, 0, -20],
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
someone dear
|
is a{" "}
|
||||||
</motion.span>
|
<span className="font-display text-primary font-extralight">
|
||||||
<motion.span
|
safe space
|
||||||
|
</span>
|
||||||
|
,<br />
|
||||||
|
<motion.span
|
||||||
|
className="opacity-0 text-3xl md:text-5xl"
|
||||||
|
transition={{ delay: 3 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: false, amount: 0.3 }}
|
||||||
|
>
|
||||||
|
where you can
|
||||||
|
</motion.span>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
|
||||||
|
<motion.h2
|
||||||
style={{
|
style={{
|
||||||
opacity: useTransform(smoothProgress1, [0.66, 0.7], [0, 1]),
|
opacity: useTransform(
|
||||||
|
scrollYProgress,
|
||||||
|
[0.3, 0.35, 0.4, 0.45],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(
|
||||||
|
scrollYProgress,
|
||||||
|
[0.3, 0.35, 0.4, 0.45],
|
||||||
|
[40, 0, 0, -40],
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
|
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||||
>
|
>
|
||||||
|
pen down your unsaid words into{" "}
|
||||||
|
<span className="font-display text-primary font-extralight">
|
||||||
|
letters
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</motion.h2>
|
||||||
|
{/* Seal */}
|
||||||
|
<motion.h2
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
scrollYProgress,
|
||||||
|
[0.45, 0.5, 0.55, 0.6],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(
|
||||||
|
scrollYProgress,
|
||||||
|
[0.45, 0.5, 0.55, 0.6],
|
||||||
|
[40, 0, 0, -40],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||||
|
>
|
||||||
|
seal it{" "}
|
||||||
|
<span className="text-secondary font-display italic font-extralight">
|
||||||
|
secure
|
||||||
|
</span>{" "}
|
||||||
|
and{" "}
|
||||||
|
<span className="text-secondary font-display font-extralight italic">
|
||||||
|
private
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</motion.h2>
|
||||||
|
{/* Send / vault */}
|
||||||
|
<motion.h2
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
scrollYProgress,
|
||||||
|
[0.6, 0.63, 0.72, 0.75],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
),
|
||||||
|
y: useTransform(
|
||||||
|
scrollYProgress,
|
||||||
|
[0.6, 0.63, 0.72, 0.75],
|
||||||
|
[40, 0, 0, -40],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||||
|
>
|
||||||
|
send it to{" "}
|
||||||
<motion.span
|
<motion.span
|
||||||
className="font-display text-accent"
|
className="font-display text-accent"
|
||||||
style={{
|
style={{
|
||||||
color: useTransform(
|
color: useTransform(
|
||||||
smoothProgress1,
|
scrollYProgress,
|
||||||
[0.67, 1],
|
[0.67, 1],
|
||||||
["var(--color-accent)", "var(--color-neutral)"],
|
["var(--color-accent)", "var(--color-neutral)"],
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{" "}
|
someone dear
|
||||||
or{" "}
|
|
||||||
</motion.span>
|
</motion.span>
|
||||||
<span className="font-display text-success">
|
<motion.span
|
||||||
yourself in the future
|
style={{
|
||||||
</span>
|
opacity: useTransform(scrollYProgress, [0.66, 0.7], [0, 1]),
|
||||||
.
|
}}
|
||||||
</motion.span>
|
>
|
||||||
</motion.h2>
|
<motion.span
|
||||||
{/* Burn */}
|
className="font-display text-accent"
|
||||||
<motion.h2
|
style={{
|
||||||
style={{
|
color: useTransform(
|
||||||
opacity: useTransform(
|
scrollYProgress,
|
||||||
smoothProgress1,
|
[0.67, 1],
|
||||||
[0.75, 0.8, 0.85, 0.9],
|
["var(--color-accent)", "var(--color-neutral)"],
|
||||||
[0, 1, 1, 0],
|
),
|
||||||
),
|
}}
|
||||||
y: useTransform(
|
>
|
||||||
smoothProgress1,
|
{" "}
|
||||||
[0.75, 0.8, 0.85, 0.9],
|
or{" "}
|
||||||
[40, 0, 0, -40],
|
</motion.span>
|
||||||
),
|
<span className="font-display text-success">
|
||||||
}}
|
yourself in the future
|
||||||
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
</span>
|
||||||
>
|
.
|
||||||
and even <span className="font-display text-error">burn it</span> to
|
</motion.span>
|
||||||
release the burden.
|
</motion.h2>
|
||||||
</motion.h2>
|
{/* Burn */}
|
||||||
{/* Outro */}
|
<motion.h2
|
||||||
<motion.h2
|
style={{
|
||||||
className={
|
opacity: useTransform(
|
||||||
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
scrollYProgress,
|
||||||
}
|
[0.75, 0.8, 0.85, 0.9],
|
||||||
style={{
|
[0, 1, 1, 0],
|
||||||
opacity: useTransform(smoothProgress1, [0.9, 1], [0, 1]),
|
),
|
||||||
y: useTransform(smoothProgress1, [0.9, 1], [80, 0]),
|
y: useTransform(
|
||||||
}}
|
scrollYProgress,
|
||||||
>
|
[0.75, 0.8, 0.85, 0.9],
|
||||||
You've been carrying it long enough.
|
[40, 0, 0, -40],
|
||||||
</motion.h2>
|
),
|
||||||
{/* CTA */}
|
}}
|
||||||
<motion.div
|
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||||
className={
|
|
||||||
"z-100 absolute -bottom-12 md:bottom-0 font-display flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(smoothProgress1, [0.98, 1], [0, 1]),
|
|
||||||
y: useTransform(smoothProgress1, [0.98, 1], [80, 0]),
|
|
||||||
display: useTransform(
|
|
||||||
smoothProgress1,
|
|
||||||
[0.96, 1],
|
|
||||||
["none", "flex"],
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className={
|
|
||||||
"md:opacity-50 hover:opacity-100 btn btn-ghost btn-wide md:btn-xl rounded-full font-extralight md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
|
||||||
}
|
|
||||||
type={"button"}
|
|
||||||
onClick={() => navigate(ROUTES.ABOUT, { replace: true })}
|
|
||||||
>
|
>
|
||||||
<InfoIcon className={"text-primary"} />
|
and even <span className="font-display text-error">burn it</span>{" "}
|
||||||
Tell me More
|
to release the burden.
|
||||||
</button>
|
</motion.h2>
|
||||||
<button
|
{/* Outro */}
|
||||||
|
<motion.h2
|
||||||
className={
|
className={
|
||||||
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight"
|
||||||
}
|
}
|
||||||
type={"button"}
|
style={{
|
||||||
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
|
opacity: useTransform(scrollYProgress, [0.9, 1], [0, 1]),
|
||||||
|
y: useTransform(scrollYProgress, [0.9, 1], [80, 0]),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
I'm ready
|
You've been carrying it long enough.
|
||||||
</button>
|
</motion.h2>
|
||||||
</motion.div>
|
{/* CTA */}
|
||||||
</div>
|
<motion.div
|
||||||
|
className={
|
||||||
|
"z-100 absolute -bottom-12 md:bottom-0 font-display flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(scrollYProgress, [0.98, 1], [0, 1]),
|
||||||
|
y: useTransform(scrollYProgress, [0.98, 1], [80, 0]),
|
||||||
|
display: useTransform(
|
||||||
|
scrollYProgress,
|
||||||
|
[0.96, 1],
|
||||||
|
["none", "flex"],
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
"md:opacity-50 hover:opacity-100 btn btn-ghost btn-wide md:btn-xl rounded-full font-extralight md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
||||||
|
}
|
||||||
|
type={"button"}
|
||||||
|
onClick={() => navigate(ROUTES.ABOUT, { replace: true })}
|
||||||
|
>
|
||||||
|
<InfoIcon className={"text-primary"} />
|
||||||
|
Tell me More
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
|
||||||
|
}
|
||||||
|
type={"button"}
|
||||||
|
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
|
||||||
|
>
|
||||||
|
I'm ready
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative h-1/4 w-full flex flex-col items-center justify-center pointer-events-none">
|
<div className="relative h-1/4 w-full flex flex-col items-center justify-center pointer-events-none">
|
||||||
<motion.div
|
<motion.div
|
||||||
className={"z-21 absolute"}
|
className={"z-21 absolute"}
|
||||||
style={{
|
style={{
|
||||||
opacity: useTransform(
|
opacity: useTransform(
|
||||||
smoothProgress1,
|
scrollYProgress,
|
||||||
[0.3, 0.4, 0.5, 0.52],
|
[0.3, 0.4, 0.5, 0.52],
|
||||||
[0, 1, 0.1, 0],
|
[0, 1, 0.1, 0],
|
||||||
),
|
),
|
||||||
y: useTransform(smoothProgress1, [0.3, 0.45, 0.5], [300, 0, 200]),
|
y: useTransform(
|
||||||
scale: useTransform(
|
scrollYProgress,
|
||||||
smoothProgress1,
|
[0.3, 0.45, 0.5],
|
||||||
[0.3, 0.4, 0.5],
|
[300, 0, 200],
|
||||||
[1, 1, 0.6],
|
),
|
||||||
),
|
scale: useTransform(
|
||||||
}}
|
scrollYProgress,
|
||||||
>
|
[0.3, 0.4, 0.5],
|
||||||
<div className="mockup-phone w-[75vw] border-primary">
|
[1, 1, 0.6],
|
||||||
<div className="mockup-phone-camera"></div>
|
),
|
||||||
<div className="mockup-phone-display">
|
}}
|
||||||
<img alt="letter" src="/screenshots/letter.webp" />
|
>
|
||||||
|
<div className="mockup-phone w-[75vw] border-primary">
|
||||||
|
<div className="mockup-phone-camera"></div>
|
||||||
|
<div className="mockup-phone-display">
|
||||||
|
<img alt="letter" src="/screenshots/letter.webp" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</motion.div>
|
{/* Envelope */}
|
||||||
{/* Envelope */}
|
<motion.div
|
||||||
<motion.div
|
className="absolute scale-50 md:scale-80 z-10"
|
||||||
className="absolute scale-50 md:scale-80 z-10"
|
style={{
|
||||||
style={{
|
opacity: useTransform(
|
||||||
opacity: useTransform(
|
scrollYProgress,
|
||||||
smoothProgress1,
|
[0.4, 0.45, 0.5, 0.7, 0.9, 1],
|
||||||
[0.4, 0.45, 0.5, 0.7, 0.9, 1],
|
[0, 0.6, 1, 1, 0.3, 0],
|
||||||
[0, 0.6, 1, 1, 0.3, 0],
|
),
|
||||||
),
|
y: useTransform(scrollYProgress, [0.45, 0.5, 1], [600, 200, 0]),
|
||||||
y: useTransform(smoothProgress1, [0.45, 0.5, 1], [600, 200, 0]),
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<EnvelopeReveal
|
||||||
<EnvelopeReveal
|
isInteractive={false}
|
||||||
isInteractive={false}
|
ignite={ignite}
|
||||||
ignite={ignite}
|
recipient={recipient}
|
||||||
recipient={recipient}
|
date={formatDate(new Date().toISOString())}
|
||||||
date={formatDate(new Date().toISOString())}
|
onRevealComplete={() => {}}
|
||||||
onRevealComplete={() => {}}
|
isFlip={isEnvelopeFlipped}
|
||||||
isFlip={isEnvelopeFlipped}
|
openFlap={flapOpen}
|
||||||
openFlap={flapOpen}
|
/>
|
||||||
|
</motion.div>
|
||||||
|
{/* Saajan */}
|
||||||
|
<motion.div
|
||||||
|
className="fixed bottom-0 z-10 font-sans -mb-6 scale-85 md:scale-100 md:mb-0"
|
||||||
|
style={{
|
||||||
|
opacity: useTransform(
|
||||||
|
scrollYProgress,
|
||||||
|
[0.98, 0.995, 1],
|
||||||
|
[0, 0.5, 1],
|
||||||
|
),
|
||||||
|
y: useTransform(scrollYProgress, [0.98, 1], [50, -10]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Saajan
|
||||||
|
message={
|
||||||
|
"I think we forget things\nif there is nobody to tell them."
|
||||||
|
}
|
||||||
|
position={"top"}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
{/* Orb */}
|
||||||
|
<motion.div
|
||||||
|
className="w-48 z-100 h-48 rounded-full blur-3xl opacity-20"
|
||||||
|
transition={{
|
||||||
|
backgroundColor: { ease: "easeIn", duration: 2 },
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
backgroundColor: useTransform(
|
||||||
|
scrollYProgress,
|
||||||
|
[0.45, 0.5, 0.7, 0.75, 1],
|
||||||
|
[
|
||||||
|
"var(--color-primary)",
|
||||||
|
"var(--color-secondary)",
|
||||||
|
"var(--color-accent)",
|
||||||
|
"var(--color-success)",
|
||||||
|
"var(--color-error)",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
scale: useTransform(scrollYProgress, [0, 1], [0.6, 2.5]),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
<div className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
|
||||||
{/* Saajan */}
|
</div>
|
||||||
<motion.div
|
|
||||||
className="fixed bottom-0 z-10 font-sans -mb-6 scale-85 md:scale-100 md:mb-0"
|
|
||||||
style={{
|
|
||||||
opacity: useTransform(
|
|
||||||
smoothProgress1,
|
|
||||||
[0.98, 0.995, 1],
|
|
||||||
[0, 0.5, 1],
|
|
||||||
),
|
|
||||||
y: useTransform(smoothProgress1, [0.98, 1], [50, -10]),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Saajan
|
|
||||||
message={
|
|
||||||
"I think we forget things\nif there is nobody to tell them."
|
|
||||||
}
|
|
||||||
position={"top"}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
{/* Orb */}
|
|
||||||
<motion.div
|
|
||||||
className="w-48 z-100 h-48 rounded-full blur-3xl opacity-20"
|
|
||||||
transition={{
|
|
||||||
backgroundColor: { ease: "easeIn", duration: 2 },
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
backgroundColor: useTransform(
|
|
||||||
smoothProgress1,
|
|
||||||
[0.45, 0.5, 0.7, 0.75, 1],
|
|
||||||
[
|
|
||||||
"var(--color-primary)",
|
|
||||||
"var(--color-secondary)",
|
|
||||||
"var(--color-accent)",
|
|
||||||
"var(--color-success)",
|
|
||||||
"var(--color-error)",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
scale: useTransform(smoothProgress1, [0, 1], [0.6, 2.5]),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
</ReactLenis>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useLocation, useNavigate } from "react-router-dom";
|
|||||||
import { z } from "zod";
|
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 WelcomeModal from "../components/login/WelcomeModal.tsx";
|
import WelcomeModal from "../components/login/WelcomeModal";
|
||||||
import FormField from "../components/ui/FormField";
|
import FormField from "../components/ui/FormField";
|
||||||
import Saajan from "../components/ui/Saajan";
|
import Saajan from "../components/ui/Saajan";
|
||||||
import { endpoints } from "../config/endpoints";
|
import { endpoints } from "../config/endpoints";
|
||||||
@@ -64,7 +64,7 @@ export default function Login() {
|
|||||||
|
|
||||||
await setAuthStore(authData.access, userData, masterKey);
|
await setAuthStore(authData.access, userData, masterKey);
|
||||||
|
|
||||||
navigate(nextRoute, { replace: true });
|
navigate(nextRoute, { replace: true, state: location.state });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let message =
|
let message =
|
||||||
"Sorry, we're experiencing technical issues.\nPlease try again later.";
|
"Sorry, we're experiencing technical issues.\nPlease try again later.";
|
||||||
@@ -97,6 +97,7 @@ export default function Login() {
|
|||||||
label="Email"
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="f.kafka@wrongtrain.com"
|
placeholder="f.kafka@wrongtrain.com"
|
||||||
|
data-testid="email-input"
|
||||||
registration={register("email")}
|
registration={register("email")}
|
||||||
error={errors.email?.message}
|
error={errors.email?.message}
|
||||||
handleFocus={() => setSaajanMessage("I remember you.")}
|
handleFocus={() => setSaajanMessage("I remember you.")}
|
||||||
@@ -106,6 +107,7 @@ export default function Login() {
|
|||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
|
data-testid="password-input"
|
||||||
registration={register("password")}
|
registration={register("password")}
|
||||||
error={errors.password?.message}
|
error={errors.password?.message}
|
||||||
handleFocus={() =>
|
handleFocus={() =>
|
||||||
@@ -119,6 +121,7 @@ export default function Login() {
|
|||||||
name="login"
|
name="login"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-label="Sign In"
|
aria-label="Sign In"
|
||||||
|
data-testid="login-submit-btn"
|
||||||
className="btn btn-primary w-full shadow-lg"
|
className="btn btn-primary w-full shadow-lg"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -217,7 +217,10 @@ export default function Reader() {
|
|||||||
<Logo />
|
<Logo />
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<span className="loading loading-ring loading-md text-primary/40"></span>
|
<span className="loading loading-ring loading-md text-primary/40"></span>
|
||||||
<p className="text-xs uppercase tracking-widest text-base-content/20 animate-pulse">
|
<p
|
||||||
|
data-testid="decryption-overlay"
|
||||||
|
className="text-xs uppercase tracking-widest text-base-content/20 animate-pulse"
|
||||||
|
>
|
||||||
Breaking the seal...
|
Breaking the seal...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,6 +309,7 @@ export default function Reader() {
|
|||||||
<div className="flex justify-center gap-2 mt-8 z-10 relative">
|
<div className="flex justify-center gap-2 mt-8 z-10 relative">
|
||||||
<button
|
<button
|
||||||
id="share-letter-btn"
|
id="share-letter-btn"
|
||||||
|
data-testid="share-letter-btn"
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-sm text-base-content/30 hover:text-base-content hover:bg-base-content/10 gap-1.5"
|
className="btn btn-ghost btn-sm text-base-content/30 hover:text-base-content hover:bg-base-content/10 gap-1.5"
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
@@ -317,6 +321,7 @@ export default function Reader() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id="burn-letter-btn"
|
id="burn-letter-btn"
|
||||||
|
data-testid="burn-letter-btn"
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-sm text-error/40 hover:text-error hover:bg-error/10 gap-1.5"
|
className="btn btn-ghost btn-sm text-error/40 hover:text-error hover:bg-error/10 gap-1.5"
|
||||||
onClick={() => setShowBurnModal(true)}
|
onClick={() => setShowBurnModal(true)}
|
||||||
|
|||||||
+144
-139
@@ -14,153 +14,158 @@ import { ROUTES } from "../config/routes";
|
|||||||
import { CryptoUtils } from "../utils/crypto";
|
import { CryptoUtils } from "../utils/crypto";
|
||||||
|
|
||||||
const registerSchema = z
|
const registerSchema = z
|
||||||
.object({
|
.object({
|
||||||
full_name: z.string().min(2, "Name must be at least 2 characters"),
|
full_name: z.string().min(2, "Name must be at least 2 characters"),
|
||||||
email: z.email("Please enter a valid email"),
|
email: z.email("Please enter a valid email"),
|
||||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
confirm_password: z.string(),
|
confirm_password: z.string(),
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirm_password, {
|
.refine((data) => data.password === data.confirm_password, {
|
||||||
message: "Passwords don't match",
|
message: "Passwords don't match",
|
||||||
path: ["confirm_password"],
|
path: ["confirm_password"],
|
||||||
});
|
});
|
||||||
|
|
||||||
type RegisterInputs = z.infer<typeof registerSchema>;
|
type RegisterInputs = z.infer<typeof registerSchema>;
|
||||||
|
|
||||||
export default function Register() {
|
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>(
|
const [saajanMessage, setSaajanMessage] = useState<string>(
|
||||||
"I didn't think I'd be here either.\nAnd yet, here we are.",
|
"I didn't think I'd be here either.\nAnd yet, here we are.",
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<RegisterInputs>({
|
} = useForm<RegisterInputs>({
|
||||||
resolver: zodResolver(registerSchema),
|
resolver: zodResolver(registerSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (data: RegisterInputs) => {
|
const onSubmit = async (data: RegisterInputs) => {
|
||||||
setSaajanMessage("Good. I'll remember that.");
|
setSaajanMessage("Good. I'll remember that.");
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setApiError(null);
|
setApiError(null);
|
||||||
try {
|
try {
|
||||||
// we generate the key bundle here to get the authHash (password) to be haSHed and stored in the db.
|
// we generate the key bundle here to get the authHash (password) to be haSHed and stored in the db.
|
||||||
const { authHash } = await CryptoUtils.deriveKeyBundle(
|
const { authHash } = await CryptoUtils.deriveKeyBundle(
|
||||||
data.password,
|
data.password,
|
||||||
data.email,
|
data.email,
|
||||||
);
|
);
|
||||||
|
|
||||||
await publicApi.post(endpoints.REGISTER, {
|
await publicApi.post(endpoints.REGISTER, {
|
||||||
full_name: data.full_name,
|
full_name: data.full_name,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
password: authHash,
|
password: authHash,
|
||||||
});
|
});
|
||||||
navigate(ROUTES.VERIFY_EMAIL, { replace: true });
|
navigate(ROUTES.VERIFY_EMAIL, { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let message = "Registration failed. Please try again.";
|
let message = "Registration failed. Please try again.";
|
||||||
if (axios.isAxiosError(err)) {
|
if (axios.isAxiosError(err)) {
|
||||||
message = err.response?.data?.message || message;
|
message = err.response?.data?.message || message;
|
||||||
}
|
}
|
||||||
setApiError(message);
|
setApiError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Saajan message={saajanMessage} position="right" />
|
<Saajan message={saajanMessage} position="right" />
|
||||||
<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">
|
||||||
<div className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight whitespace-nowrap">
|
<div className="card-title font-display text-2xl justify-center text-primary/80 tracking-tight whitespace-nowrap">
|
||||||
Create a <Logo /> Account
|
Create a <Logo /> Account
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{apiError && (
|
{apiError && (
|
||||||
<div className="alert alert-error text-xs py-2 rounded-md">
|
<div className="alert alert-error text-xs py-2 rounded-md">
|
||||||
<span>{apiError}</span>
|
<span>{apiError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Pen Name"
|
||||||
|
placeholder="Word Smith"
|
||||||
|
data-testid="pen-name-input"
|
||||||
|
registration={register("full_name")}
|
||||||
|
error={errors.full_name?.message}
|
||||||
|
handleFocus={() =>
|
||||||
|
setSaajanMessage("Hello friend. What should I call you?")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="f.kafka@wrongtrain.com"
|
||||||
|
data-testid="email-input"
|
||||||
|
registration={register("email")}
|
||||||
|
error={errors.email?.message}
|
||||||
|
handleFocus={() =>
|
||||||
|
setSaajanMessage(
|
||||||
|
"Where should I send your letters?\nNo empty lunchboxes, please.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
data-testid="password-input"
|
||||||
|
registration={register("password")}
|
||||||
|
error={errors.password?.message}
|
||||||
|
handleFocus={() =>
|
||||||
|
setSaajanMessage(
|
||||||
|
"Something only you know.\nI have one of those too.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Confirm Password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
data-testid="confirm-password-input"
|
||||||
|
registration={register("confirm_password")}
|
||||||
|
error={errors.confirm_password?.message}
|
||||||
|
handleFocus={() =>
|
||||||
|
setSaajanMessage(
|
||||||
|
"Just once? Trust me, \nsome things are worth repeating twice.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20">
|
||||||
|
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
Choose a password you won't forget. <br />
|
||||||
|
Just like life,{" "}
|
||||||
|
<span className="underline decoration-2">there is no reset</span>{" "}
|
||||||
|
here. If you lose it, your letters cannot be recovered.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-actions mt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
aria-label="Register"
|
||||||
|
data-testid="register-submit-btn"
|
||||||
|
className="btn btn-primary w-full shadow-lg"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="loading loading-spinner loading-sm" />
|
||||||
|
) : (
|
||||||
|
"Register"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
);
|
||||||
<FormField
|
|
||||||
label="Pen Name"
|
|
||||||
placeholder="Word Smith"
|
|
||||||
registration={register("full_name")}
|
|
||||||
error={errors.full_name?.message}
|
|
||||||
handleFocus={() =>
|
|
||||||
setSaajanMessage("Hello friend. What should I call you?")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Email"
|
|
||||||
type="email"
|
|
||||||
placeholder="f.kafka@wrongtrain.com"
|
|
||||||
registration={register("email")}
|
|
||||||
error={errors.email?.message}
|
|
||||||
handleFocus={() =>
|
|
||||||
setSaajanMessage(
|
|
||||||
"Where should I send your letters?\nNo empty lunchboxes, please.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Password"
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
registration={register("password")}
|
|
||||||
error={errors.password?.message}
|
|
||||||
handleFocus={() =>
|
|
||||||
setSaajanMessage(
|
|
||||||
"Something only you know.\nI have one of those too.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Confirm Password"
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
registration={register("confirm_password")}
|
|
||||||
error={errors.confirm_password?.message}
|
|
||||||
handleFocus={() =>
|
|
||||||
setSaajanMessage(
|
|
||||||
"Just once? Trust me, \nsome things are worth repeating twice.",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="alert alert-warning items-start text-left p-3 gap-2 rounded-md border-warning/20">
|
|
||||||
<InfoIcon size={20} weight="duotone" className="mt-0.5 shrink-0" />
|
|
||||||
<p className="text-sm font-semibold">
|
|
||||||
Choose a password you won't forget. <br />
|
|
||||||
Just like life,{" "}
|
|
||||||
<span className="underline decoration-2">there is no reset</span>{" "}
|
|
||||||
here. If you lose it, your letters cannot be recovered.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card-actions mt-4">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
host: env.FRONTEND_DOMAIN,
|
host: env.FRONTEND_DOMAIN,
|
||||||
https: isSslEnabled ? sslCerts : undefined,
|
https: isSslEnabled ? sslCerts : undefined,
|
||||||
},
|
},
|
||||||
|
preview: {
|
||||||
|
port: Number(env.FRONTEND_PORT),
|
||||||
|
host: env.FRONTEND_DOMAIN,
|
||||||
|
https: isSslEnabled ? sslCerts : undefined,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,18 @@ until $CONTAINER_BIN exec "$DB_NAME" pg_isready -U "${DB_USER:-test}" >/dev/null
|
|||||||
done
|
done
|
||||||
|
|
||||||
export PIKU_ENV_FILE="$ENV_FILE"
|
export PIKU_ENV_FILE="$ENV_FILE"
|
||||||
|
|
||||||
|
# NOTE: When running in Gitea Actions (within container), We must ponint DB and mail to the internal docker host instead.
|
||||||
|
if [ "$GITEA_ACTIONS" = "true" ]; then
|
||||||
|
sudo apt-get update && sudo apt-get install -y iproute2
|
||||||
|
# Sample: "default via <internal docker host IP> dev <network interface> proto dhcp src <IP> metric 100"
|
||||||
|
HOST_IP=$(ip route show default | awk '/default/ {print $3}')
|
||||||
|
echo "Running on Gitea. Internal Docker host... $HOST_IP"
|
||||||
|
|
||||||
|
export DB_HOST=$HOST_IP
|
||||||
|
export EMAIL_HOST=$HOST_IP
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Starting Backend..."
|
echo "Starting Backend..."
|
||||||
mkdir -p ./tmp/logs
|
mkdir -p ./tmp/logs
|
||||||
(
|
(
|
||||||
|
|||||||
Reference in New Issue
Block a user