Claude/standalone product repos xhftd #4090
48 changed files+7627−250
ModifiedCLAUDE.md+23−54View fileUnifiedSplit
@@ -528,60 +528,29 @@ After writing the code:
528528- Cloudflare deployment config (DNS setup script, wrangler.toml)
529529- Neon PostgreSQL setup SQL
530530- Production .env template
531
532### TIER 5 (Table Stakes Expansion) — 20/20 ✅ COMPLETE (2026-04-18)
533- [x] Read receipts / tracking pixel (open + click tracking)
534- [x] Email templates library (CRUD + variable rendering)
535- [x] Signature manager (multiple per account, auto-switch by context)
536- [x] Contact groups / distribution lists (CRUD + member management)
537- [x] Smart folders / saved searches (dynamic filters, auto-populate)
538- [x] Email scheduling queue dashboard (list/cancel scheduled sends)
539- [x] Thread muting (silence threads without unsubscribing)
540- [x] Bulk actions (archive/delete/read/star/label/move — up to 500 at once)
541- [x] Labels / tags (shared, nested hierarchy, apply/remove from emails)
542- [x] Push notifications (Web Push subscriptions + preferences + quiet hours)
543- [x] Link previews / URL unfurling (OG meta parsing, 7-day cache)
544- [x] Email scheduling analytics (opens/clicks by hour+day, best send times)
545- [x] Email A/B testing (multi-variant, auto-winner by metric)
546- [x] Auto-responder / vacation mode (AI-powered OOO with smart replies)
547- [x] Contact enrichment (company info, social profiles, AI-powered)
548- [x] Mail merge (personalized mass emails from CSV/contacts)
549- [x] Zapier/Make/n8n integration (outbound webhooks, HMAC-signed, 11 event types)
550- [x] AlecRae Notes (email-linked notes, pin, thread/contact scoping)
551- [x] AlecRae Files (attachment management, storage stats, presigned uploads)
552- [x] AlecRae Chat (secure team messaging, channels, DMs, thread-linked)
553
554### TIER 6 (AI-Powered Platform) — 9/9 ✅ COMPLETE (2026-04-18)
555- [x] Onboarding wizard (Gmail + Microsoft 365 guided setup)
556- [x] AlecRae Docs (documents, folders, versioning, AI assist, export)
557- [x] AlecRae Meet (video meeting rooms, recordings, transcription, summaries)
558- [x] AI Writing Intelligence (profiles, compose, rewrite, expand, stats)
559- [x] Calendar Events (smart calendar, availability, find-time, AI scheduling)
560- [x] Contacts Extended (CRM-lite — interactions, reminders, AI insights)
561- [x] Notification Intelligence (AI rules, batching, digest, evaluate)
562- [x] Focus Sessions (start/end, deferred emails, current session)
563- [x] Email Hygiene (habits analytics, subscription tracker, inbox cleanup, goals)
564
565### TIER 7 (Advanced Intelligence) — 6/6 ✅ COMPLETE (2026-04-18)
566- [x] Analytics Dashboard (periodic snapshots, goals tracking)
567- [x] Email Delegation (delegate handling to team, shared drafts, review workflow)
568- [x] Workflow Automation (triggers, actions, runs, templates)
569- [x] AI Categorization (email categories, smart labels, feedback loop)
570- [x] Search Intelligence (history, bookmarks, AI suggestions)
571- [x] Security Intelligence (threat detection, policies, audit log, phishing reports)
572
573### TIER 8 (Deep AI Intelligence) — 6/6 ✅ COMPLETE (2026-04-18)
574- [x] Sentiment Timeline (per-contact sentiment tracking, relationship health, risk alerts)
575- [x] Attachment Intelligence (AI file analysis, virus scanning, PII detection, smart organization)
576- [x] Scheduling Intelligence (AI meeting proposals, availability patterns, conflict detection)
577- [x] Context Intelligence (action item extraction, deadline tracking, promise monitoring)
578- [x] Productivity Analytics (time tracking, behavioral insights, team leaderboards)
579- [x] Knowledge Graph (entity extraction, relationship mapping, graph visualization)
580
581### Total: 36/36 original + 7 bonus + 20 expansion + 9 platform + 6 intelligence + 6 deep AI = 84 features ✅ ALL COMPLETE
582### API Routes: 90 route files, 290+ endpoints
583### DB Schemas: 61 schema files
584### Code: ~62K lines of TypeScript
531- Undo toast system (5s undo for archive/delete/snooze)
532- Batch email selection with bulk actions
533- Snooze picker with presets + custom date/time
534- Sent page with read receipt indicators
535- Drafts page with click-to-resume editing
536- Snoozed page with countdown timers
537- Contacts page with search, notes, avatars
538- Templates page with CRUD, variable tags, preview/render
539- Email Signature Manager (create/edit/delete, default selection)
540- Recipient Autocomplete (contact API search in compose)
541- Keyboard Shortcut Help Modal (press ? for reference)
542- Cmd+K Command Palette (Superhuman-style navigation)
543- Full Offline-First Stack (IndexedDB + sync engine + service worker)
544- PWA Support (manifest, install prompt, push notifications)
545- Cache-first inbox loading (sub-50ms from IndexedDB)
546- Sync Status Bar (offline/syncing/outbox/error states)
547- Desktop notifications for new emails
548- Favicon badge for unread count
549- Offline compose with outbox queue
550
551### Total: 36/36 from original plan + 27 bonus features ✅ ALL TIERS COMPLETE
552### API Routes: 30+ route files, 100+ endpoints
553### Code: ~45K lines of TypeScript
585554
586555---
587556
Modifiedapps/api/src/routes/account.ts+163−14View fileUnifiedSplit
@@ -3,7 +3,7 @@ import { eq } from "drizzle-orm";
33import { z } from "zod";
44import { requireScope } from "../middleware/auth.js";
55import { validateBody, getValidatedBody } from "../middleware/validator.js";
6import { getDatabase, accounts, users } from "@alecrae/db";
6import { getDatabase, accounts, users, passkeys } from "@alecrae/db";
77
88const account = new Hono();
99
@@ -58,29 +58,178 @@ account.get("/", requireScope("messages:read"), async (c) => {
5858 });
5959});
6060
61// PATCH /v1/account — Update profile (user's name, account name, billing email)
61// ─── Profile Update ──────────────────────────────────────────────────────────
62
63const UpdateProfileSchema = z.object({
64 name: z.string().min(1).max(128).optional(),
65 email: z.string().email().optional(),
66});
67
6268account.patch(
63 "/",
64 requireScope("account:manage"),
69 "/profile",
70 requireScope("messages:read"),
6571 validateBody(UpdateProfileSchema),
6672 async (c) => {
6773 const auth = c.get("auth");
68 const input = getValidatedBody<z.infer<typeof UpdateProfileSchema>>(c);
74 const body = getValidatedBody<z.infer<typeof UpdateProfileSchema>>(c);
6975 const db = getDatabase();
7076
71 if (input.name !== undefined && auth.userId) {
72 await db.update(users).set({ name: input.name }).where(eq(users.id, auth.userId));
77 if (!body.name && !body.email) {
78 return c.json(
79 { error: { type: "validation", message: "No fields to update", code: "empty_update" } },
80 400,
81 );
7382 }
7483
75 const accountUpdates: Record<string, unknown> = {};
76 if (input.accountName !== undefined) accountUpdates.name = input.accountName;
77 if (input.billingEmail !== undefined) accountUpdates.billingEmail = input.billingEmail;
78 if (Object.keys(accountUpdates).length > 0) {
79 await db.update(accounts).set(accountUpdates).where(eq(accounts.id, auth.accountId));
80 }
84 const updates: Record<string, unknown> = { updatedAt: new Date() };
85 if (body.name) updates["name"] = body.name;
86 if (body.email) updates["email"] = body.email;
87
88 await db.update(users).set(updates).where(eq(users.id, auth.userId));
89
90 const [updated] = await db
91 .select({ id: users.id, name: users.name, email: users.email, role: users.role })
92 .from(users)
93 .where(eq(users.id, auth.userId))
94 .limit(1);
95
96 return c.json({ data: updated });
97 },
98);
99
100// ─── Passkey Management ──────────────────────────────────────────────────────
101
102account.get("/passkeys", requireScope("messages:read"), async (c) => {
103 const auth = c.get("auth");
104 const db = getDatabase();
105
106 const rows = await db
107 .select({
108 id: passkeys.id,
109 credentialId: passkeys.credentialId,
110 friendlyName: passkeys.friendlyName,
111 deviceType: passkeys.deviceType,
112 createdAt: passkeys.createdAt,
113 lastUsedAt: passkeys.lastUsedAt,
114 })
115 .from(passkeys)
116 .where(eq(passkeys.userId, auth.userId));
117
118 return c.json({
119 data: rows.map((r) => ({
120 id: r.id,
121 credentialId: r.credentialId,
122 deviceName: r.friendlyName ?? r.deviceType ?? "Unknown device",
123 createdAt: r.createdAt?.toISOString() ?? null,
124 lastUsedAt: r.lastUsedAt?.toISOString() ?? null,
125 })),
126 });
127});
128
129account.delete("/passkeys/:id", requireScope("messages:read"), async (c) => {
130 const auth = c.get("auth");
131 const passkeyId = c.req.param("id");
132 const db = getDatabase();
133
134 const [existing] = await db
135 .select({ id: passkeys.id })
136 .from(passkeys)
137 .where(eq(passkeys.id, passkeyId))
138 .limit(1);
139
140 if (!existing) {
141 return c.json(
142 { error: { type: "not_found", message: "Passkey not found", code: "passkey_not_found" } },
143 404,
144 );
145 }
146
147 await db.delete(passkeys).where(eq(passkeys.id, passkeyId));
148
149 return c.json({ data: { deleted: true, id: passkeyId } });
150});
151
152// ─── Notification Preferences ────────────────────────────────────────────────
81153
82 return c.json({ data: { success: true } });
154const NotificationPrefsSchema = z.object({
155 emailNotifications: z.boolean().optional(),
156 aiDigest: z.boolean().optional(),
157 deliverabilityAlerts: z.boolean().optional(),
158});
159
160account.get("/notifications", requireScope("messages:read"), async (c) => {
161 const auth = c.get("auth");
162 const db = getDatabase();
163
164 const [user] = await db
165 .select({ permissions: users.permissions })
166 .from(users)
167 .where(eq(users.id, auth.userId))
168 .limit(1);
169
170 const prefs = (user?.permissions as Record<string, unknown>) ?? {};
171
172 return c.json({
173 data: {
174 emailNotifications: prefs["emailNotifications"] !== false,
175 aiDigest: prefs["aiDigest"] !== false,
176 deliverabilityAlerts: prefs["deliverabilityAlerts"] !== false,
177 },
178 });
179});
180
181account.put(
182 "/notifications",
183 requireScope("messages:read"),
184 validateBody(NotificationPrefsSchema),
185 async (c) => {
186 const auth = c.get("auth");
187 const body = getValidatedBody<z.infer<typeof NotificationPrefsSchema>>(c);
188 const db = getDatabase();
189
190 const [user] = await db
191 .select({ permissions: users.permissions })
192 .from(users)
193 .where(eq(users.id, auth.userId))
194 .limit(1);
195
196 const current = (user?.permissions as Record<string, unknown>) ?? {};
197 const merged = { ...current, ...body };
198
199 await db.update(users).set({ permissions: merged, updatedAt: new Date() }).where(eq(users.id, auth.userId));
200
201 return c.json({
202 data: {
203 emailNotifications: merged["emailNotifications"] !== false,
204 aiDigest: merged["aiDigest"] !== false,
205 deliverabilityAlerts: merged["deliverabilityAlerts"] !== false,
206 },
207 });
83208 },
84209);
85210
211// ─── Account Deletion ────────────────────────────────────────────────────────
212
213account.delete("/", requireScope("messages:read"), async (c) => {
214 const auth = c.get("auth");
215 const db = getDatabase();
216
217 const [user] = await db
218 .select({ role: users.role })
219 .from(users)
220 .where(eq(users.id, auth.userId))
221 .limit(1);
222
223 if (user?.role !== "owner") {
224 return c.json(
225 { error: { type: "forbidden", message: "Only account owners can delete accounts", code: "not_owner" } },
226 403,
227 );
228 }
229
230 await db.delete(accounts).where(eq(accounts.id, auth.accountId));
231
232 return c.json({ data: { deleted: true } });
233});
234
86235export { account };
Modifiedapps/api/src/routes/meeting-link.ts+57−28View fileUnifiedSplit
@@ -15,7 +15,7 @@ import {
1515 validateBody,
1616 getValidatedBody,
1717} from "../middleware/validator.js";
18import { getDatabase, emails } from "@alecrae/db";
18import { getDatabase, emails, meetingProviderConnections } from "@alecrae/db";
1919import { detectMeetingFromThread } from "@alecrae/ai-engine/meetings/transcript-linker";
2020import {
2121 fetchTranscript,
@@ -31,35 +31,41 @@ import type {
3131 LinkerEmail,
3232} from "@alecrae/ai-engine/meetings/types";
3333
34// ─── Provider connection storage (in-memory; production: encrypted DB) ───────
34// ─── Provider connection storage (DB-backed via meetingProviderConnections) ───
35//
36// Tokens are stored as-is in the `access_token_encrypted` column. In
37// production they MUST be encrypted with AES-256-GCM before being persisted
38// (the column name signals the intent). For now the route stores the raw
39// token so the flow is functional end-to-end; a dedicated encryption helper
40// should be wired in before launch.
3541
36interface ProviderCredentials {
37 readonly provider: "zoom" | "otter" | "fathom" | "granola" | "read.ai";
38 readonly accessToken: string;
39 readonly connectedAt: string;
40}
41
42const providerConnections = new Map<string, ProviderCredentials[]>();
42async function buildProvidersFor(accountId: string): Promise<TranscriptProvider[]> {
43 const db = getDatabase();
44 const rows = await db
45 .select()
46 .from(meetingProviderConnections)
47 .where(eq(meetingProviderConnections.accountId, accountId));
4348
44function buildProvidersFor(accountId: string): TranscriptProvider[] {
45 const creds = providerConnections.get(accountId) ?? [];
4649 const providers: TranscriptProvider[] = [];
47 for (const c of creds) {
48 switch (c.provider) {
50 for (const row of rows) {
51 // `accessTokenEncrypted` holds the raw token until the encryption layer
52 // is wired in — see TODO above.
53 const token = row.accessTokenEncrypted;
54 switch (row.provider) {
4955 case "zoom":
50 providers.push(new ZoomTranscriptProvider({ accessToken: c.accessToken }));
56 providers.push(new ZoomTranscriptProvider({ accessToken: token }));
5157 break;
5258 case "otter":
53 providers.push(new OtterTranscriptProvider({ apiToken: c.accessToken }));
59 providers.push(new OtterTranscriptProvider({ apiToken: token }));
5460 break;
5561 case "fathom":
56 providers.push(createFathomProvider(c.accessToken));
62 providers.push(createFathomProvider(token));
5763 break;
5864 case "granola":
59 providers.push(createGranolaProvider(c.accessToken));
65 providers.push(createGranolaProvider(token));
6066 break;
6167 case "read.ai":
62 providers.push(createReadAiProvider(c.accessToken));
68 providers.push(createReadAiProvider(token));
6369 break;
6470 }
6571 }
@@ -152,7 +158,7 @@ meetingLink.post(
152158 const input = getValidatedBody<z.infer<typeof FetchSchema>>(c);
153159 const auth = c.get("auth");
154160
155 const providers = buildProvidersFor(auth.accountId);
161 const providers = await buildProvidersFor(auth.accountId);
156162 if (providers.length === 0) {
157163 return c.json(
158164 {
@@ -253,7 +259,7 @@ meetingLink.get(
253259 return c.json({ data: { meeting: null, transcript: null } });
254260 }
255261
256 const providers = buildProvidersFor(auth.accountId);
262 const providers = await buildProvidersFor(auth.accountId);
257263 const transcript =
258264 providers.length > 0 ? await fetchTranscript(meeting, providers) : null;
259265
@@ -269,21 +275,44 @@ meetingLink.post(
269275 async (c) => {
270276 const input = getValidatedBody<z.infer<typeof ConnectProviderSchema>>(c);
271277 const auth = c.get("auth");
278 const db = getDatabase();
279
280 const connectedAt = new Date();
281 const rowId = `mpc_${auth.accountId}_${input.provider}_${Date.now()}`;
282
283 // Upsert: delete the existing row for this provider (if any), then insert
284 // a fresh one. A true ON CONFLICT upsert requires a unique constraint on
285 // (account_id, provider) — that migration can be added later.
286 await db
287 .delete(meetingProviderConnections)
288 .where(
289 and(
290 eq(meetingProviderConnections.accountId, auth.accountId),
291 eq(meetingProviderConnections.provider, input.provider),
292 ),
293 );
272294
273 const existing = providerConnections.get(auth.accountId) ?? [];
274 const filtered = existing.filter((e) => e.provider !== input.provider);
275 filtered.push({
295 await db.insert(meetingProviderConnections).values({
296 id: rowId,
297 accountId: auth.accountId,
276298 provider: input.provider,
277 accessToken: input.accessToken,
278 connectedAt: new Date().toISOString(),
299 // TODO: encrypt with AES-256-GCM before storing in production
300 accessTokenEncrypted: input.accessToken,
301 connectedAt,
302 updatedAt: connectedAt,
279303 });
280 providerConnections.set(auth.accountId, filtered);
304
305 // Count total connections for this account after the upsert
306 const allRows = await db
307 .select({ provider: meetingProviderConnections.provider })
308 .from(meetingProviderConnections)
309 .where(eq(meetingProviderConnections.accountId, auth.accountId));
281310
282311 return c.json({
283312 data: {
284313 provider: input.provider,
285 connectedAt: new Date().toISOString(),
286 totalProviders: filtered.length,
314 connectedAt: connectedAt.toISOString(),
315 totalProviders: allRows.length,
287316 },
288317 });
289318 },
Modifiedapps/api/src/routes/messages.ts+66−4View fileUnifiedSplit
@@ -949,8 +949,70 @@ messages.get(
949949 },
950950);
951951
952// POST /v1/send — Crontech-compatible unified send (mounted at /v1/send in server.ts)
953const unifiedSend = new Hono();
954unifiedSend.post("/", ...sendMiddleware, handleSend);
952// PATCH /v1/messages/:id — Update message (archive, star, status)
953messages.patch(
954 "/:id",
955 requireScope("messages:read"),
956 async (c) => {
957 const id = c.req.param("id");
958 const auth = c.get("auth");
959 const db = getDatabase();
960 const body = await c.req.json() as Record<string, unknown>;
961
962 const [existing] = await db
963 .select({ id: emails.id })
964 .from(emails)
965 .where(and(eq(emails.id, id), eq(emails.accountId, auth.accountId)))
966 .limit(1);
967
968 if (!existing) {
969 return c.json(
970 { error: { type: "not_found", message: `Message ${id} not found`, code: "message_not_found" } },
971 404,
972 );
973 }
974
975 const updates: Record<string, unknown> = { updatedAt: new Date() };
976
977 if (typeof body["status"] === "string") {
978 updates["status"] = body["status"];
979 }
980 if (typeof body["tags"] === "object" && Array.isArray(body["tags"])) {
981 updates["tags"] = body["tags"];
982 }
983
984 await db.update(emails).set(updates).where(eq(emails.id, id));
985
986 return c.json({ data: { id, updated: true } });
987 },
988);
989
990// DELETE /v1/messages/:id — Soft-delete a message
991messages.delete(
992 "/:id",
993 requireScope("messages:read"),
994 async (c) => {
995 const id = c.req.param("id");
996 const auth = c.get("auth");
997 const db = getDatabase();
998
999 const [existing] = await db
1000 .select({ id: emails.id })
1001 .from(emails)
1002 .where(and(eq(emails.id, id), eq(emails.accountId, auth.accountId)))
1003 .limit(1);
1004
1005 if (!existing) {
1006 return c.json(
1007 { error: { type: "not_found", message: `Message ${id} not found`, code: "message_not_found" } },
1008 404,
1009 );
1010 }
1011
1012 await db.update(emails).set({ status: "dropped", updatedAt: new Date() }).where(eq(emails.id, id));
1013
1014 return c.json({ data: { id, deleted: true } });
1015 },
1016);
9551017
956export { messages, unifiedSend };
1018export { messages };
Modifiedapps/web/app/(dashboard)/analytics/page.tsx+77−36View fileUnifiedSplit
@@ -8,7 +8,7 @@ import {
88 type ChartDataPoint,
99} from "@alecrae/ui";
1010import { motion } from "motion/react";
11import { analyticsApi, type OverviewStats } from "../../../lib/api";
11import { analyticsApi, heatmapApi, type OverviewStats } from "../../../lib/api";
1212import {
1313 staggerGrid,
1414 fadeInUp,
@@ -16,42 +16,83 @@ import {
1616 withReducedMotion,
1717} from "../../../lib/animations";
1818
19// Fallback data for when API is not connected
20const fallbackDeliverability: ChartDataPoint[] = [
21 { label: "Mon", value: 0 },
22 { label: "Tue", value: 0 },
23 { label: "Wed", value: 0 },
24 { label: "Thu", value: 0 },
25 { label: "Fri", value: 0 },
26 { label: "Sat", value: 0 },
27 { label: "Sun", value: 0 },
28];
19const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
20
21function getLast7DaysLabels(): string[] {
22 const labels: string[] = [];
23 const now = new Date();
24 for (let i = 6; i >= 0; i--) {
25 const d = new Date(now);
26 d.setDate(d.getDate() - i);
27 labels.push(DAY_LABELS[d.getDay() === 0 ? 6 : d.getDay() - 1] ?? "");
28 }
29 return labels;
30}
2931
3032export default function AnalyticsPage(): React.ReactNode {
3133 const reduced = useAlecRaeReducedMotion();
3234 const [stats, setStats] = useState<OverviewStats | null>(null);
35 const [delivChart, setDelivChart] = useState<ChartDataPoint[]>([]);
36 const [hourlyChart, setHourlyChart] = useState<ChartDataPoint[]>([]);
37 const [volumeChart, setVolumeChart] = useState<ChartDataPoint[]>([]);
3338 const [loading, setLoading] = useState(true);
3439
3540 useEffect(() => {
36 analyticsApi
37 .overview()
38 .then((res) => setStats(res.data))
39 .catch(() => {
40 // API not available — show zeroes
41 const dayLabels = getLast7DaysLabels();
42
43 Promise.all([
44 analyticsApi.overview().catch(() => null),
45 analyticsApi.deliverability({ granularity: "day" }).catch(() => null),
46 heatmapApi.hourly({ period: "7d" }).catch(() => null),
47 heatmapApi.heatmap({ period: "7d" }).catch(() => null),
48 ]).then(([overviewRes, delivRes, hourlyRes, heatmapRes]) => {
49 if (overviewRes) {
50 setStats(overviewRes.data);
51 } else {
4152 setStats({
42 sent: 0,
43 delivered: 0,
44 bounced: 0,
45 complained: 0,
46 opened: 0,
47 clicked: 0,
48 deliveryRate: 0,
49 bounceRate: 0,
50 openRate: 0,
51 clickRate: 0,
53 sent: 0, delivered: 0, bounced: 0, complained: 0,
54 opened: 0, clicked: 0, deliveryRate: 0, bounceRate: 0,
55 openRate: 0, clickRate: 0,
5256 });
53 })
54 .finally(() => setLoading(false));
57 }
58
59 if (delivRes && Array.isArray(delivRes.data) && delivRes.data.length > 0) {
60 setDelivChart(
61 delivRes.data.slice(-7).map((d: Record<string, unknown>, i: number) => ({
62 label: dayLabels[i] ?? "",
63 value: typeof d["deliveryRate"] === "number" ? Math.round(d["deliveryRate"] as number * 100) : 0,
64 })),
65 );
66 } else {
67 setDelivChart(dayLabels.map((l) => ({ label: l, value: 0 })));
68 }
69
70 if (hourlyRes && Array.isArray(hourlyRes.data) && hourlyRes.data.length > 0) {
71 setHourlyChart(
72 hourlyRes.data.map((h) => ({
73 label: `${h.hour}:00`,
74 value: h.sent + h.received,
75 })),
76 );
77 } else {
78 setHourlyChart(
79 Array.from({ length: 24 }, (_, i) => ({ label: `${i}:00`, value: 0 })),
80 );
81 }
82
83 if (heatmapRes && Array.isArray(heatmapRes.data) && heatmapRes.data.length > 0) {
84 setVolumeChart(
85 heatmapRes.data.slice(-7).map((d, i: number) => ({
86 label: dayLabels[i] ?? "",
87 value: d.sent + d.received,
88 })),
89 );
90 } else {
91 setVolumeChart(dayLabels.map((l) => ({ label: l, value: 0 })));
92 }
93
94 setLoading(false);
95 });
5596 }, []);
5697
5798 const deliveryRate = stats ? (stats.deliveryRate * 100).toFixed(1) : "0";
@@ -119,7 +160,7 @@ export default function AnalyticsPage(): React.ReactNode {
119160 <AnalyticsChart
120161 title="Deliverability Rate"
121162 description="Percentage of emails successfully delivered over the past week"
122 data={fallbackDeliverability}
163 data={delivChart.length > 0 ? delivChart : [{ label: "—", value: 0 }]}
123164 chartType="area"
124165 height={220}
125166 formatValue={(v) => `${v}%`}
@@ -127,19 +168,19 @@ export default function AnalyticsPage(): React.ReactNode {
127168 </motion.div>
128169 <motion.div variants={itemVariants}>
129170 <AnalyticsChart
130 title="Engagement Rate"
131 description="Open and click-through rates by week"
132 data={fallbackDeliverability}
171 title="Hourly Activity"
172 description="Email activity by hour of day"
173 data={hourlyChart.length > 0 ? hourlyChart : [{ label: "—", value: 0 }]}
133174 chartType="bar"
134175 height={220}
135 formatValue={(v) => `${v}%`}
176 formatValue={(v) => v.toLocaleString()}
136177 />
137178 </motion.div>
138179 <motion.div variants={itemVariants}>
139180 <AnalyticsChart
140181 title="Send Volume"
141 description="Total emails sent per period"
142 data={fallbackDeliverability}
182 description="Total emails sent and received per day"
183 data={volumeChart.length > 0 ? volumeChart : [{ label: "—", value: 0 }]}
143184 chartType="bar"
144185 height={220}
145186 formatValue={(v) => v.toLocaleString()}
@@ -149,7 +190,7 @@ export default function AnalyticsPage(): React.ReactNode {
149190 <AnalyticsChart
150191 title="Bounce Rate"
151192 description="Hard and soft bounces over the past week"
152 data={fallbackDeliverability}
193 data={delivChart.length > 0 ? delivChart.map((d) => ({ label: d.label, value: Math.max(0, 100 - d.value) })) : [{ label: "—", value: 0 }]}
153194 chartType="line"
154195 height={220}
155196 formatValue={(v) => `${v}%`}
Modifiedapps/web/app/(dashboard)/compose/page.tsx+35−25View fileUnifiedSplit
@@ -1,12 +1,13 @@
11"use client";
22
3import { useState, useEffect, useCallback } from "react";
3import { useState, useEffect, useCallback, useRef } from "react";
44import { useSearchParams } from "next/navigation";
55import { PageLayout, ComposeEditor, type ComposeData, type AISuggestion } from "@alecrae/ui";
66import { AnimatePresence, motion } from "motion/react";
7import { messagesApi, authApi, calendarApi } from "../../../lib/api";
7import { messagesApi, authApi, calendarApi, grammarApi } from "../../../lib/api";
88import { SendTimePanel } from "../../../components/SendTimePanel";
99import { AnimatedCompose } from "../../../components/AnimatedCompose";
10import { OfflineComposeBanner } from "../../../components/OfflineComposeBanner";
1011import {
1112 composeEnter,
1213 fadeInUp,
@@ -14,27 +15,6 @@ import {
1415 withReducedMotion,
1516} from "../../../lib/animations";
1617
17const sampleSuggestions: AISuggestion[] = [
18 {
19 id: "s1",
20 type: "tone",
21 label: "More professional",
22 preview: "Consider a more formal tone for this client communication...",
23 },
24 {
25 id: "s2",
26 type: "autocomplete",
27 label: "Complete paragraph",
28 preview: "...and we look forward to discussing the partnership details in our upcoming meeting.",
29 },
30 {
31 id: "s3",
32 type: "grammar",
33 label: "Fix punctuation",
34 preview: 'Add a comma after "However" in the second paragraph.',
35 },
36];
37
3818import { Suspense } from "react";
3919
4020export const dynamic = "force-dynamic";
@@ -55,6 +35,34 @@ function ComposePage(): React.ReactNode {
5535 const [userEmail, setUserEmail] = useState("");
5636 const [recipientForPrediction, setRecipientForPrediction] = useState("");
5737 const [scheduledAt, setScheduledAt] = useState<string | null>(null);
38 const [suggestions, setSuggestions] = useState<AISuggestion[]>([]);
39 const grammarTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
40 const lastCheckedRef = useRef("");
41
42 const checkGrammar = useCallback((text: string) => {
43 const plainText = text.replace(/<[^>]*>/g, "").trim();
44 if (!plainText || plainText.length < 20 || plainText === lastCheckedRef.current) return;
45
46 if (grammarTimerRef.current) clearTimeout(grammarTimerRef.current);
47
48 grammarTimerRef.current = setTimeout(async () => {
49 lastCheckedRef.current = plainText;
50 try {
51 const res = await grammarApi.check({ text: plainText });
52 const newSuggestions: AISuggestion[] = res.data.issues.slice(0, 5).map((issue, i) => ({
53 id: `g${i}`,
54 type: "grammar" as const,
55 label: issue.message,
56 preview: issue.replacements.length > 0
57 ? `Suggestion: ${issue.replacements[0]}`
58 : issue.message,
59 }));
60 setSuggestions(newSuggestions);
61 } catch {
62 // Grammar API unavailable — no suggestions
63 }
64 }, 1500);
65 }, []);
5866
5967 // Get compose mode from URL params (reply, forward, or new)
6068 const mode = searchParams.get("mode") as "reply" | "replyAll" | "forward" | null;
@@ -159,6 +167,7 @@ function ComposePage(): React.ReactNode {
159167
160168 return (
161169 <PageLayout title="Compose" fullWidth>
170 <OfflineComposeBanner />
162171 <AnimatedCompose show={true}>
163172 <AnimatePresence>
164173 {status && (
@@ -223,8 +232,8 @@ function ComposePage(): React.ReactNode {
223232 cc={mode === "replyAll" ? replyCc : ""}
224233 subject={initialSubject}
225234 body={initialBody}
226 suggestions={sampleSuggestions}
227 showAIPanel={true}
235 suggestions={suggestions}
236 showAIPanel={suggestions.length > 0}
228237 onSend={handleSend}
229238 onSaveDraft={() => {
230239 setStatus("Draft saved locally");
@@ -235,6 +244,7 @@ function ComposePage(): React.ReactNode {
235244 }}
236245 onApplySuggestion={() => { /* no-op */ }}
237246 onRequestCalendarSlots={handleRequestCalendarSlots}
247 onChange={checkGrammar}
238248 className="flex-1"
239249 />
240250 </motion.div>
Addedapps/web/app/(dashboard)/contacts/page.tsx+297−0View fileUnifiedSplit
@@ -0,0 +1,297 @@
1"use client";
2
3import { useState, useEffect, useCallback, useRef } from "react";
4import { Box, Text, Button, Input, PageLayout } from "@alecrae/ui";
5import { AnimatePresence, motion } from "motion/react";
6import { PressableScale } from "../../../components/PressableScale";
7import {
8 fadeInUp,
9 SPRING_BOUNCY,
10 useAlecRaeReducedMotion,
11} from "../../../lib/animations";
12
13const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
14
15interface Contact {
16 id: string;
17 name: string;
18 email: string;
19 avatarUrl: string | null;
20 company: string | null;
21 notes: string | null;
22 tags: string[];
23 emailCount: number;
24 lastContactedAt: string | null;
25}
26
27async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
28 const token = typeof window !== "undefined" ? localStorage.getItem("alecrae_api_key") ?? "" : "";
29 const res = await fetch(`${API_BASE}${path}`, {
30 ...options,
31 headers: {
32 "Content-Type": "application/json",
33 ...(token ? { Authorization: `Bearer ${token}` } : {}),
34 ...options.headers,
35 },
36 });
37 if (!res.ok) {
38 const err = await res.json().catch(() => null);
39 throw new Error((err as { error?: { message?: string } })?.error?.message ?? `Request failed: ${res.status}`);
40 }
41 return res.json() as Promise<T>;
42}
43
44function ContactAvatar({ name, avatarUrl }: { name: string; avatarUrl: string | null }): React.ReactNode {
45 const initials = name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) || "?";
46 const colors = ["bg-brand-100 text-brand-700", "bg-green-100 text-green-700", "bg-purple-100 text-purple-700", "bg-orange-100 text-orange-700", "bg-pink-100 text-pink-700"];
47 const colorIdx = name.charCodeAt(0) % colors.length;
48
49 if (avatarUrl) {
50 return (
51 <img src={avatarUrl} alt={name} className="w-10 h-10 rounded-full object-cover" />
52 );
53 }
54
55 return (
56 <div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold ${colors[colorIdx]}`}>
57 {initials}
58 </div>
59 );
60}
61
62export default function ContactsPage(): React.ReactNode {
63 const reduced = useAlecRaeReducedMotion();
64 const [contacts, setContacts] = useState<Contact[]>([]);
65 const [loading, setLoading] = useState(true);
66 const [error, setError] = useState<string | null>(null);
67 const [search, setSearch] = useState("");
68 const [selectedId, setSelectedId] = useState<string | null>(null);
69 const [editNotes, setEditNotes] = useState("");
70 const [savingNotes, setSavingNotes] = useState(false);
71 const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
72
73 const fetchContacts = useCallback(async (query?: string) => {
74 try {
75 setLoading(true);
76 setError(null);
77 const path = query?.trim()
78 ? `/v1/contacts/search?q=${encodeURIComponent(query)}&limit=50`
79 : "/v1/contacts?limit=50";
80 const res = await apiFetch<{ data: Contact[] }>(path);
81 setContacts(res.data);
82 } catch (err) {
83 setError(err instanceof Error ? err.message : "Failed to load contacts");
84 } finally {
85 setLoading(false);
86 }
87 }, []);
88
89 useEffect(() => {
90 fetchContacts();
91 }, [fetchContacts]);
92
93 const handleSearch = (value: string): void => {
94 setSearch(value);
95 if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
96 searchTimerRef.current = setTimeout(() => {
97 fetchContacts(value);
98 }, 300);
99 };
100
101 const selected = contacts.find((c) => c.id === selectedId);
102
103 const handleSelectContact = (contact: Contact): void => {
104 setSelectedId(contact.id);
105 setEditNotes(contact.notes ?? "");
106 };
107
108 const handleSaveNotes = async (): Promise<void> => {
109 if (!selectedId) return;
110 setSavingNotes(true);
111 try {
112 await apiFetch(`/v1/contacts/${selectedId}`, {
113 method: "PATCH",
114 body: JSON.stringify({ notes: editNotes }),
115 });
116 setContacts((prev) =>
117 prev.map((c) => c.id === selectedId ? { ...c, notes: editNotes } : c),
118 );
119 } catch {
120 // Silently fail — notes are local-first
121 } finally {
122 setSavingNotes(false);
123 }
124 };
125
126 return (
127 <PageLayout title="Contacts" fullWidth>
128 <Box className="flex flex-1 h-full">
129 <Box className="w-96 border-r border-border overflow-y-auto flex-shrink-0">
130 <Box className="p-3 border-b border-border">
131 <Input
132 variant="search"
133 placeholder="Search contacts..."
134 inputSize="sm"
135 value={search}
136 onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleSearch(e.target.value)}
137 />
138 </Box>
139 <Box className="px-4 py-2 border-b border-border bg-surface-secondary">
140 <Text variant="body-sm" muted>
141 {loading ? "Loading..." : `${contacts.length} contacts`}
142 </Text>
143 </Box>
144
145 {loading ? (
146 <Box className="p-8 text-center">
147 <Text variant="body-sm" muted>Loading contacts...</Text>
148 </Box>
149 ) : error ? (
150 <Box className="p-6 text-center">
151 <Text variant="body-sm" muted>{error}</Text>
152 <PressableScale as="button" tapScale={0.95} className="mt-3">
153 <Button variant="secondary" size="sm" onClick={() => fetchContacts()}>Retry</Button>
154 </PressableScale>
155 </Box>
156 ) : contacts.length === 0 ? (
157 <motion.div
158 className="flex flex-col items-center justify-center p-8"
159 variants={fadeInUp}
160 initial="initial"
161 animate="animate"
162 >
163 <Text variant="body-md" muted>
164 {search ? "No contacts found" : "No contacts yet"}
165 </Text>
166 <Text variant="body-sm" muted className="mt-1">
167 Contacts are automatically created from your email activity
168 </Text>
169 </motion.div>
170 ) : (
171 <motion.div
172 initial={reduced ? false : { opacity: 0 }}
173 animate={{ opacity: 1 }}
174 transition={{ duration: 0.15 }}
175 >
176 {contacts.map((contact) => (
177 <button
178 key={contact.id}
179 type="button"
180 onClick={() => handleSelectContact(contact)}
181 className={`w-full text-left flex items-center gap-3 px-4 py-3 border-b border-border transition-colors ${
182 selectedId === contact.id
183 ? "bg-brand-50 border-l-2 border-l-brand-500"
184 : "hover:bg-surface-secondary"
185 }`}
186 >
187 <ContactAvatar name={contact.name} avatarUrl={contact.avatarUrl} />
188 <Box className="flex-1 min-w-0">
189 <Text variant="body-sm" className="font-medium text-content truncate">
190 {contact.name}
191 </Text>
192 <Text variant="caption" muted className="truncate">
193 {contact.email}
194 </Text>
195 </Box>
196 {contact.emailCount > 0 && (
197 <Text variant="caption" muted className="flex-shrink-0">
198 {contact.emailCount} emails
199 </Text>
200 )}
201 </button>
202 ))}
203 </motion.div>
204 )}
205 </Box>
206
207 <Box className="flex-1 min-w-0 overflow-y-auto">
208 <AnimatePresence mode="wait">
209 {selected ? (
210 <motion.div
211 key={selected.id}
212 className="p-8 max-w-2xl"
213 initial={reduced ? false : { opacity: 0, y: 8 }}
214 animate={{ opacity: 1, y: 0 }}
215 exit={{ opacity: 0, y: -8 }}
216 transition={SPRING_BOUNCY}
217 >
218 <Box className="flex items-center gap-4 mb-6">
219 <ContactAvatar name={selected.name} avatarUrl={selected.avatarUrl} />
220 <Box>
221 <Text variant="heading-lg" className="text-content">
222 {selected.name}
223 </Text>
224 <Text variant="body-sm" muted>{selected.email}</Text>
225 {selected.company && (
226 <Text variant="caption" muted className="mt-0.5">{selected.company}</Text>
227 )}
228 </Box>
229 </Box>
230
231 <Box className="grid grid-cols-3 gap-4 mb-6">
232 <Box className="p-4 rounded-lg bg-surface-secondary border border-border text-center">
233 <Text variant="heading-md" className="text-brand-600">{selected.emailCount}</Text>
234 <Text variant="caption" muted>Emails</Text>
235 </Box>
236 <Box className="p-4 rounded-lg bg-surface-secondary border border-border text-center">
237 <Text variant="heading-md" className="text-content">
238 {selected.lastContactedAt ? new Date(selected.lastContactedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }) : "-"}
239 </Text>
240 <Text variant="caption" muted>Last contact</Text>
241 </Box>
242 <Box className="p-4 rounded-lg bg-surface-secondary border border-border text-center">
243 <Text variant="heading-md" className="text-content">{selected.tags.length}</Text>
244 <Text variant="caption" muted>Tags</Text>
245 </Box>
246 </Box>
247
248 {selected.tags.length > 0 && (
249 <Box className="flex flex-wrap gap-1.5 mb-4">
250 {selected.tags.map((tag) => (
251 <span key={tag} className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-brand-50 text-brand-700">
252 {tag}
253 </span>
254 ))}
255 </Box>
256 )}
257
258 <Box className="mb-6">
259 <Text variant="body-sm" className="font-medium text-content mb-2">Notes</Text>
260 <textarea
261 value={editNotes}
262 onChange={(e) => setEditNotes(e.target.value)}
263 placeholder="Add notes about this contact..."
264 rows={4}
265 className="w-full resize-none rounded-lg border border-border bg-surface p-3 text-body-md text-content placeholder:text-content-tertiary focus:outline-none focus:ring-2 focus:ring-border-focus"
266 />
267 <Box className="flex justify-end mt-2">
268 <PressableScale as="button" tapScale={0.95}>
269 <Button
270 variant="secondary"
271 size="sm"
272 onClick={() => void handleSaveNotes()}
273 disabled={savingNotes}
274 >
275 {savingNotes ? "Saving..." : "Save notes"}
276 </Button>
277 </PressableScale>
278 </Box>
279 </Box>
280 </motion.div>
281 ) : (
282 <motion.div
283 key="empty"
284 className="flex items-center justify-center h-full"
285 variants={fadeInUp}
286 initial="initial"
287 animate="animate"
288 >
289 <Text variant="body-md" muted>Select a contact to view details</Text>
290 </motion.div>
291 )}
292 </AnimatePresence>
293 </Box>
294 </Box>
295 </PageLayout>
296 );
297}
Addedapps/web/app/(dashboard)/drafts/page.tsx+173−0View fileUnifiedSplit
@@ -0,0 +1,173 @@
1"use client";
2
3import { useState, useEffect, useCallback } from "react";
4import { useRouter } from "next/navigation";
5import { Box, Text, Button, PageLayout } from "@alecrae/ui";
6import { AnimatePresence, motion } from "motion/react";
7import { messagesApi, type Message } from "../../../lib/api";
8import { PressableScale } from "../../../components/PressableScale";
9import { EmailListSkeleton } from "../../../components/AnimatedSkeleton";
10import {
11 fadeInUp,
12 useAlecRaeReducedMotion,
13} from "../../../lib/animations";
14
15interface DraftItem {
16 id: string;
17 to: string;
18 subject: string;
19 preview: string;
20 updatedAt: string;
21}
22
23function formatTimestamp(iso: string): string {
24 const date = new Date(iso);
25 const now = new Date();
26 const diffMs = now.getTime() - date.getTime();
27 const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
28
29 if (diffDays === 0) return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
30 if (diffDays === 1) return "Yesterday";
31 if (diffDays < 7) return date.toLocaleDateString(undefined, { weekday: "short" });
32 return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
33}
34
35export default function DraftsPage(): React.ReactNode {
36 const router = useRouter();
37 const reduced = useAlecRaeReducedMotion();
38 const [drafts, setDrafts] = useState<DraftItem[]>([]);
39 const [loading, setLoading] = useState(true);
40 const [error, setError] = useState<string | null>(null);
41
42 const fetchDrafts = useCallback(async () => {
43 try {
44 setLoading(true);
45 setError(null);
46 const res = await messagesApi.list({ limit: 50, status: "queued" });
47 const items: DraftItem[] = res.data.map((msg: Message) => ({
48 id: msg.id,
49 to: msg.to.map((r) => r.name ?? r.email).join(", ") || "No recipient",
50 subject: msg.subject || "(no subject)",
51 preview: msg.preview || "",
52 updatedAt: msg.updatedAt,
53 }));
54 setDrafts(items);
55 } catch (err) {
56 setError(err instanceof Error ? err.message : "Failed to load drafts");
57 } finally {
58 setLoading(false);
59 }
60 }, []);
61
62 useEffect(() => {
63 fetchDrafts();
64 }, [fetchDrafts]);
65
66 const handleOpenDraft = (draft: DraftItem): void => {
67 const params = new URLSearchParams({
68 to: draft.to !== "No recipient" ? draft.to : "",
69 subject: draft.subject !== "(no subject)" ? draft.subject : "",
70 });
71 router.push(`/compose?${params.toString()}`);
72 };
73
74 const handleDelete = async (id: string): Promise<void> => {
75 setDrafts((prev) => prev.filter((d) => d.id !== id));
76 try {
77 await messagesApi.delete(id);
78 } catch {
79 fetchDrafts();
80 }
81 };
82
83 return (
84 <PageLayout title="Drafts" fullWidth>
85 <Box className="flex flex-1 h-full">
86 <Box className="w-full max-w-3xl mx-auto">
87 <Box className="px-4 py-2 border-b border-border bg-surface-secondary">
88 <Text variant="body-sm" muted>
89 {loading ? "Loading..." : `${drafts.length} drafts`}
90 </Text>
91 </Box>
92
93 {loading ? (
94 <EmailListSkeleton count={5} />
95 ) : error ? (
96 <Box className="p-6 text-center">
97 <Text variant="body-sm" muted>{error}</Text>
98 <PressableScale as="button" tapScale={0.95} className="mt-3">
99 <Button variant="secondary" size="sm" onClick={fetchDrafts}>Retry</Button>
100 </PressableScale>
101 </Box>
102 ) : drafts.length === 0 ? (
103 <motion.div
104 className="flex flex-col items-center justify-center p-12"
105 variants={fadeInUp}
106 initial="initial"
107 animate="animate"
108 >
109 <Text variant="heading-md" muted>No drafts</Text>
110 <Text variant="body-sm" muted className="mt-2">
111 Start composing an email and save it as a draft
112 </Text>
113 <PressableScale as="button" tapScale={0.95} className="mt-4">
114 <Button variant="primary" size="md" onClick={() => router.push("/compose")}>
115 Compose
116 </Button>
117 </PressableScale>
118 </motion.div>
119 ) : (
120 <AnimatePresence mode="wait">
121 <motion.div
122 initial={reduced ? false : { opacity: 0 }}
123 animate={{ opacity: 1 }}
124 transition={{ duration: 0.15 }}
125 >
126 {drafts.map((draft) => (
127 <motion.div
128 key={draft.id}
129 className="flex items-center px-4 py-3 border-b border-border hover:bg-surface-secondary transition-colors cursor-pointer group"
130 whileHover={{ x: 2 }}
131 onClick={() => handleOpenDraft(draft)}
132 >
133 <Box className="flex-1 min-w-0">
134 <Box className="flex items-center gap-2 mb-0.5">
135 <Text variant="body-sm" className="font-medium text-content truncate">
136 {draft.to}
137 </Text>
138 <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-700">
139 Draft
140 </span>
141 </Box>
142 <Text variant="body-sm" className="text-content truncate">
143 {draft.subject}
144 </Text>
145 <Text variant="caption" muted className="truncate mt-0.5">
146 {draft.preview}
147 </Text>
148 </Box>
149 <Box className="flex items-center gap-3 flex-shrink-0 ml-4">
150 <Text variant="caption" muted>
151 {formatTimestamp(draft.updatedAt)}
152 </Text>
153 <button
154 type="button"
155 onClick={(e) => { e.stopPropagation(); void handleDelete(draft.id); }}
156 className="opacity-0 group-hover:opacity-100 text-content-tertiary hover:text-red-600 transition-all p-1"
157 aria-label="Delete draft"
158 >
159 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
160 <path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" strokeLinecap="round" strokeLinejoin="round" />
161 </svg>
162 </button>
163 </Box>
164 </motion.div>
165 ))}
166 </motion.div>
167 </AnimatePresence>
168 )}
169 </Box>
170 </Box>
171 </PageLayout>
172 );
173}
Modifiedapps/web/app/(dashboard)/inbox/page.tsx+415−26View fileUnifiedSplit
@@ -1,7 +1,8 @@
11"use client";
22
3import { useState, useEffect, useCallback, useRef } from "react";
3import { useState, useEffect, useCallback, useRef, useMemo } from "react";
44import { useRouter } from "next/navigation";
5import { createDefaultShortcuts, registerShortcuts } from "../../../lib/keyboard-shortcuts";
56import {
67 Box,
78 Text,
@@ -14,11 +15,18 @@ import {
1415 type EmailMessage,
1516} from "@alecrae/ui";
1617import { AnimatePresence, motion } from "motion/react";
17import { messagesApi, type Message, type MessageDetail } from "../../../lib/api";
18import { messagesApi, snoozeApi, authApi, type Message, type MessageDetail } from "../../../lib/api";
1819import { NewsletterSummaryPreview } from "../../../components/NewsletterSummaryPreview";
1920import { EmailExplainerPanel } from "../../../components/EmailExplainerPanel";
21import { QuickReply } from "../../../components/QuickReply";
22import { UndoToastManager, type UndoAction } from "../../../components/UndoToast";
23import { BatchActionBar } from "../../../components/BatchActionBar";
24import { SnoozePicker } from "../../../components/SnoozePicker";
25import { SyncStatusBar } from "../../../components/SyncStatusBar";
2026import { EmailListSkeleton } from "../../../components/AnimatedSkeleton";
2127import { PressableScale } from "../../../components/PressableScale";
28import { useSyncEngine } from "../../../lib/sync-engine";
29import { getCachedEmails, cacheEmails, type CachedEmail } from "../../../lib/offline-store";
2230import {
2331 fadeInUp,
2432 threadExpand,
@@ -151,13 +159,303 @@ export default function InboxPage(): React.ReactNode {
151159 const [newsletterMap, setNewsletterMap] = useState<Map<string, boolean>>(new Map());
152160 // S7: Email explainer panel
153161 const [explainerOpen, setExplainerOpen] = useState(false);
162 // Whether to show full email content (toggled from newsletter summary)
163 const [showFullEmail, setShowFullEmail] = useState(true);
164 const [quickReplyOpen, setQuickReplyOpen] = useState(false);
165 const [userEmail, setUserEmail] = useState("");
166 const [undoActions, setUndoActions] = useState<UndoAction[]>([]);
167 const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
168 const [snoozePickerOpen, setSnoozePickerOpen] = useState(false);
169 const snoozeTargetRef = useRef<string | null>(null);
170
171 const sync = useSyncEngine();
172
173 const filteredEmails = useMemo(() => emailItems.filter((e) => {
174 if (filter === "unread") return !e.read;
175 if (filter === "starred") return e.starred;
176 return true;
177 }), [emailItems, filter]);
178
179 const handleStar = useCallback((email: EmailListItem) => {
180 const newStarred = !email.starred;
181 setEmailItems((prev) =>
182 prev.map((e) => (e.id === email.id ? { ...e, starred: newStarred } : e)),
183 );
184 messagesApi.star(email.id, newStarred).catch(() => {
185 setEmailItems((prev) =>
186 prev.map((e) => (e.id === email.id ? { ...e, starred: !newStarred } : e)),
187 );
188 });
189 }, []);
190
191 const addUndoAction = useCallback((id: string, label: string, onUndo: () => void) => {
192 setUndoActions((prev) => [...prev, { id, label, onUndo, duration: 5000 }]);
193 }, []);
194
195 const removeUndoAction = useCallback((id: string) => {
196 setUndoActions((prev) => prev.filter((a) => a.id !== id));
197 }, []);
198
199 const archiveWithUndo = useCallback((id: string) => {
200 const item = emailItems.find((e) => e.id === id);
201 if (!item) return;
202 setEmailItems((prev) => prev.filter((e) => e.id !== id));
203 if (selectedEmailId === id) {
204 setSelectedEmailId(undefined);
205 setSelectedEmail(null);
206 }
207 setSelectedIds((prev) => { const next = new Set(prev); next.delete(id); return next; });
208 const undoId = `archive-${id}-${Date.now()}`;
209 addUndoAction(undoId, "Conversation archived", () => {
210 setEmailItems((prev) => {
211 if (prev.some((e) => e.id === item.id)) return prev;
212 return [...prev, item].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
213 });
214 });
215 messagesApi.archive(id).catch(() => {});
216 }, [emailItems, selectedEmailId, addUndoAction]);
217
218 const deleteWithUndo = useCallback((id: string) => {
219 const item = emailItems.find((e) => e.id === id);
220 if (!item) return;
221 setEmailItems((prev) => prev.filter((e) => e.id !== id));
222 if (selectedEmailId === id) {
223 setSelectedEmailId(undefined);
224 setSelectedEmail(null);
225 }
226 setSelectedIds((prev) => { const next = new Set(prev); next.delete(id); return next; });
227 const undoId = `delete-${id}-${Date.now()}`;
228 addUndoAction(undoId, "Conversation deleted", () => {
229 setEmailItems((prev) => {
230 if (prev.some((e) => e.id === item.id)) return prev;
231 return [...prev, item].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
232 });
233 });
234 messagesApi.delete(id).catch(() => {});
235 }, [emailItems, selectedEmailId, addUndoAction]);
236
237 const snoozeWithUndo = useCallback((id: string, until: Date) => {
238 const item = emailItems.find((e) => e.id === id);
239 if (!item) return;
240 setEmailItems((prev) => prev.filter((e) => e.id !== id));
241 if (selectedEmailId === id) {
242 setSelectedEmailId(undefined);
243 setSelectedEmail(null);
244 }
245 const formatted = until.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
246 const undoId = `snooze-${id}-${Date.now()}`;
247 addUndoAction(undoId, `Snoozed until ${formatted}`, () => {
248 setEmailItems((prev) => {
249 if (prev.some((e) => e.id === item.id)) return prev;
250 return [...prev, item].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
251 });
252 snoozeApi.unsnooze(id).catch(() => {});
253 });
254 snoozeApi.snooze(id, until.toISOString()).catch(() => {});
255 }, [emailItems, selectedEmailId, addUndoAction]);
256
257 const toggleSelect = useCallback((id: string) => {
258 setSelectedIds((prev) => {
259 const next = new Set(prev);
260 if (next.has(id)) next.delete(id);
261 else next.add(id);
262 return next;
263 });
264 }, []);
265
266 const batchArchive = useCallback(() => {
267 const ids = Array.from(selectedIds);
268 const items = emailItems.filter((e) => selectedIds.has(e.id));
269 setEmailItems((prev) => prev.filter((e) => !selectedIds.has(e.id)));
270 if (selectedEmailId && selectedIds.has(selectedEmailId)) {
271 setSelectedEmailId(undefined);
272 setSelectedEmail(null);
273 }
274 setSelectedIds(new Set());
275 const undoId = `batch-archive-${Date.now()}`;
276 addUndoAction(undoId, `${ids.length} conversations archived`, () => {
277 setEmailItems((prev) => {
278 const existing = new Set(prev.map((e) => e.id));
279 const restored = items.filter((i) => !existing.has(i.id));
280 return [...prev, ...restored].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
281 });
282 });
283 for (const id of ids) messagesApi.archive(id).catch(() => {});
284 }, [selectedIds, emailItems, selectedEmailId, addUndoAction]);
285
286 const batchDelete = useCallback(() => {
287 const ids = Array.from(selectedIds);
288 const items = emailItems.filter((e) => selectedIds.has(e.id));
289 setEmailItems((prev) => prev.filter((e) => !selectedIds.has(e.id)));
290 if (selectedEmailId && selectedIds.has(selectedEmailId)) {
291 setSelectedEmailId(undefined);
292 setSelectedEmail(null);
293 }
294 setSelectedIds(new Set());
295 const undoId = `batch-delete-${Date.now()}`;
296 addUndoAction(undoId, `${ids.length} conversations deleted`, () => {
297 setEmailItems((prev) => {
298 const existing = new Set(prev.map((e) => e.id));
299 const restored = items.filter((i) => !existing.has(i.id));
300 return [...prev, ...restored].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
301 });
302 });
303 for (const id of ids) messagesApi.delete(id).catch(() => {});
304 }, [selectedIds, emailItems, selectedEmailId, addUndoAction]);
305
306 const batchMarkRead = useCallback(() => {
307 setEmailItems((prev) => prev.map((e) => selectedIds.has(e.id) ? { ...e, read: true } : e));
308 setSelectedIds(new Set());
309 }, [selectedIds]);
310
311 const batchMarkUnread = useCallback(() => {
312 setEmailItems((prev) => prev.map((e) => selectedIds.has(e.id) ? { ...e, read: false } : e));
313 setSelectedIds(new Set());
314 }, [selectedIds]);
315
316 const batchStar = useCallback(() => {
317 const ids = Array.from(selectedIds);
318 setEmailItems((prev) => prev.map((e) => selectedIds.has(e.id) ? { ...e, starred: true } : e));
319 setSelectedIds(new Set());
320 for (const id of ids) messagesApi.star(id, true).catch(() => {});
321 }, [selectedIds]);
322
323 const selectedIndexRef = useRef(0);
324
325 useEffect(() => {
326 const idx = filteredEmails.findIndex((e) => e.id === selectedEmailId);
327 if (idx >= 0) selectedIndexRef.current = idx;
328 }, [selectedEmailId, filteredEmails]);
329
330 useEffect(() => {
331 const shortcuts = createDefaultShortcuts({
332 openCommandPalette: () => {},
333 compose: () => router.push("/compose"),
334 search: () => {
335 const input = document.querySelector<HTMLInputElement>('input[type="search"], input[placeholder*="Search"]');
336 input?.focus();
337 },
338 goToInbox: () => router.push("/inbox"),
339 goToSent: () => {},
340 goToDrafts: () => {},
341 goToSettings: () => router.push("/settings"),
342 nextEmail: () => {
343 const next = Math.min(selectedIndexRef.current + 1, filteredEmails.length - 1);
344 const item = filteredEmails[next];
345 if (item) { setSelectedEmailId(item.id); selectedIndexRef.current = next; }
346 },
347 prevEmail: () => {
348 const prev = Math.max(selectedIndexRef.current - 1, 0);
349 const item = filteredEmails[prev];
350 if (item) { setSelectedEmailId(item.id); selectedIndexRef.current = prev; }
351 },
352 openEmail: () => {},
353 archiveEmail: () => {
354 if (selectedEmailId) {
355 const next = filteredEmails[selectedIndexRef.current + 1] ?? filteredEmails[selectedIndexRef.current - 1];
356 archiveWithUndo(selectedEmailId);
357 setSelectedEmailId(next?.id);
358 }
359 },
360 deleteEmail: () => {
361 if (selectedEmailId) {
362 const next = filteredEmails[selectedIndexRef.current + 1] ?? filteredEmails[selectedIndexRef.current - 1];
363 deleteWithUndo(selectedEmailId);
364 setSelectedEmailId(next?.id);
365 }
366 },
367 starEmail: () => {
368 if (selectedEmailId) {
369 const item = emailItems.find((e) => e.id === selectedEmailId);
370 if (item) handleStar(item);
371 }
372 },
373 markRead: () => {
374 if (selectedEmailId) {
375 setEmailItems((prev) => prev.map((e) => e.id === selectedEmailId ? { ...e, read: true } : e));
376 }
377 },
378 markUnread: () => {
379 if (selectedEmailId) {
380 setEmailItems((prev) => prev.map((e) => e.id === selectedEmailId ? { ...e, read: false } : e));
381 }
382 },
383 replyEmail: () => {
384 if (selectedEmail) {
385 const params = new URLSearchParams({ mode: "reply", to: selectedEmail.sender.email, subject: selectedEmail.subject, body: selectedEmail.bodyParts.map((p) => "content" in p ? p.content : "").join("\n\n") });
386 router.push(`/compose?${params.toString()}`);
387 }
388 },
389 replyAllEmail: () => {
390 if (selectedEmail) {
391 const params = new URLSearchParams({ mode: "replyAll", to: selectedEmail.sender.email, cc: (selectedEmail.recipients ?? []).map((r) => r.email).join(","), subject: selectedEmail.subject, body: selectedEmail.bodyParts.map((p) => "content" in p ? p.content : "").join("\n\n") });
392 router.push(`/compose?${params.toString()}`);
393 }
394 },
395 forwardEmail: () => {
396 if (selectedEmail) {
397 const params = new URLSearchParams({ mode: "forward", subject: selectedEmail.subject, body: selectedEmail.bodyParts.map((p) => "content" in p ? p.content : "").join("\n\n") });
398 router.push(`/compose?${params.toString()}`);
399 }
400 },
401 snoozeEmail: () => {
402 if (selectedEmailId) {
403 snoozeTargetRef.current = selectedEmailId;
404 setSnoozePickerOpen(true);
405 }
406 },
407 undoAction: () => {
408 const last = undoActions[undoActions.length - 1];
409 if (last) {
410 last.onUndo();
411 removeUndoAction(last.id);
412 }
413 },
414 aiCompose: () => router.push("/compose"),
415 aiReply: () => {},
416 aiSummarize: () => {},
417 toggleDarkMode: () => {},
418 toggleFocusMode: () => {},
419 });
420
421 return registerShortcuts(shortcuts, () =>
422 selectedEmail ? "thread" : "inbox",
423 );
424 }, [router, selectedEmailId, selectedEmail, emailItems, filteredEmails, handleStar, archiveWithUndo, deleteWithUndo, undoActions, removeUndoAction]);
425
154426 const fetchEmails = useCallback(async () => {
155427 try {
156428 setLoading(true);
157429 setError(null);
430
431 // Cache-first: try IndexedDB for instant load
432 try {
433 const cached = await getCachedEmails({ limit: 50, filter: "all" });
434 if (cached.length > 0) {
435 const cachedItems: EmailListItem[] = cached.map((c) => ({
436 id: c.id,
437 sender: { name: c.from.name ?? c.from.email, email: c.from.email },
438 subject: c.subject || "(no subject)",
439 preview: c.preview || "",
440 timestamp: formatTimestamp(c.createdAt),
441 read: c.read,
442 starred: c.starred,
443 priority: "normal" as const,
444 hasAttachments: c.hasAttachments,
445 }));
446 setEmailItems(cachedItems);
447 if (cachedItems.length > 0 && !selectedEmailId) {
448 setSelectedEmailId(cachedItems[0]!.id);
449 }
450 setLoading(false);
451 }
452 } catch {
453 // Cache miss — proceed to network
454 }
455
456 // Network: fetch fresh data and update cache
158457 const res = await messagesApi.list({ limit: 50 });
159458 const items = res.data.map(toEmailListItem);
160 // S6: Build newsletter classification map
161459 const nlMap = new Map<string, boolean>();
162460 for (const msg of res.data) {
163461 nlMap.set(msg.id, isLikelyNewsletter(msg));
@@ -168,6 +466,27 @@ export default function InboxPage(): React.ReactNode {
168466 if (first && !selectedEmailId) {
169467 setSelectedEmailId(first.id);
170468 }
469
470 // Update cache in background
471 const toCache: CachedEmail[] = res.data.map((msg) => ({
472 id: msg.id,
473 messageId: msg.messageId,
474 from: msg.from,
475 to: msg.to,
476 cc: msg.cc,
477 subject: msg.subject,
478 preview: msg.preview,
479 status: msg.status,
480 tags: msg.tags,
481 hasAttachments: msg.hasAttachments,
482 starred: msg.tags.includes("starred"),
483 read: msg.status === "delivered" || msg.status === "sent",
484 createdAt: msg.createdAt,
485 updatedAt: msg.updatedAt,
486 sentAt: msg.sentAt,
487 cachedAt: Date.now(),
488 }));
489 cacheEmails(toCache).catch(() => {});
171490 } catch (err) {
172491 setError(err instanceof Error ? err.message : "Failed to load emails");
173492 } finally {
@@ -189,11 +508,13 @@ export default function InboxPage(): React.ReactNode {
189508
190509 useEffect(() => {
191510 fetchEmails();
511 authApi.me().then((res) => setUserEmail(res.data.email)).catch(() => {});
192512 }, [fetchEmails]);
193513
194514 useEffect(() => {
195515 if (selectedEmailId) {
196516 fetchDetail(selectedEmailId);
517 setQuickReplyOpen(false);
197518 }
198519 }, [selectedEmailId, fetchDetail]);
199520
@@ -204,18 +525,6 @@ export default function InboxPage(): React.ReactNode {
204525 );
205526 };
206527
207 const handleStar = (email: EmailListItem) => {
208 setEmailItems((prev) =>
209 prev.map((e) => (e.id === email.id ? { ...e, starred: !e.starred } : e)),
210 );
211 };
212
213 const filteredEmails = emailItems.filter((e) => {
214 if (filter === "unread") return !e.read;
215 if (filter === "starred") return e.starred;
216 return true;
217 });
218
219528 const handleSearch = useCallback(
220529 (query: string) => {
221530 setSearchQuery(query);
@@ -328,9 +637,45 @@ export default function InboxPage(): React.ReactNode {
328637
329638 return (
330639 <PageLayout header={searchHeader} fullWidth>
640 <SyncStatusBar
641 isOnline={sync.isOnline}
642 isSyncing={sync.isSyncing}
643 pendingOutbox={sync.pendingOutbox}
644 lastSyncAt={sync.lastSyncAt}
645 error={sync.error}
646 onSyncNow={() => void sync.syncNow()}
647 />
331648 <Box className="flex flex-1 h-full">
332649 <Box className="w-96 border-r border-border overflow-y-auto flex-shrink-0">
333 <Box className="px-4 py-2 border-b border-border bg-surface-secondary">
650 <AnimatePresence>
651 {selectedIds.size > 0 && (
652 <BatchActionBar
653 selectedCount={selectedIds.size}
654 totalCount={filteredEmails.length}
655 onSelectAll={() => setSelectedIds(new Set(filteredEmails.map((e) => e.id)))}
656 onDeselectAll={() => setSelectedIds(new Set())}
657 onArchive={batchArchive}
658 onDelete={batchDelete}
659 onMarkRead={batchMarkRead}
660 onMarkUnread={batchMarkUnread}
661 onStar={batchStar}
662 />
663 )}
664 </AnimatePresence>
665 <Box className="px-4 py-2 border-b border-border bg-surface-secondary flex items-center gap-2">
666 <input
667 type="checkbox"
668 checked={selectedIds.size > 0 && selectedIds.size === filteredEmails.length}
669 onChange={(e) => {
670 if (e.target.checked) {
671 setSelectedIds(new Set(filteredEmails.map((em) => em.id)));
672 } else {
673 setSelectedIds(new Set());
674 }
675 }}
676 className="w-3.5 h-3.5 rounded border-border text-brand-600 focus:ring-brand-500"
677 aria-label="Select all emails"
678 />
334679 <Text variant="body-sm" muted>
335680 {searching
336681 ? "Searching..."
@@ -457,21 +802,42 @@ export default function InboxPage(): React.ReactNode {
457802 router.push(`/compose?${params.toString()}`);
458803 }}
459804 onArchive={() => {
460 if (selectedEmailId) {
461 setEmailItems((prev) => prev.filter((e) => e.id !== selectedEmailId));
462 setSelectedEmailId(undefined);
463 setSelectedEmail(null);
464 }
805 if (selectedEmailId) archiveWithUndo(selectedEmailId);
465806 }}
466807 onDelete={() => {
467 if (selectedEmailId) {
468 setEmailItems((prev) => prev.filter((e) => e.id !== selectedEmailId));
469 setSelectedEmailId(undefined);
470 setSelectedEmail(null);
471 }
808 if (selectedEmailId) deleteWithUndo(selectedEmailId);
472809 }}
473810 />
474811 </Box>
812
813 {/* Quick Reply */}
814 {selectedEmail && !quickReplyOpen && (
815 <Box className="border-t border-border p-3 bg-surface-secondary">
816 <button
817 type="button"
818 onClick={() => setQuickReplyOpen(true)}
819 className="w-full text-left px-4 py-2.5 rounded-lg border border-border bg-surface text-content-tertiary text-body-sm hover:border-border-strong hover:text-content-secondary transition-all"
820 >
821 Reply to {selectedEmail.sender.name || selectedEmail.sender.email}...
822 </button>
823 </Box>
824 )}
825
826 <AnimatePresence>
827 {quickReplyOpen && selectedEmail && userEmail && (
828 <QuickReply
829 emailId={selectedEmailId ?? ""}
830 toEmail={selectedEmail.sender.email}
831 toName={selectedEmail.sender.name}
832 subject={selectedEmail.subject}
833 userEmail={userEmail}
834 onSent={() => {
835 setQuickReplyOpen(false);
836 }}
837 onClose={() => setQuickReplyOpen(false)}
838 />
839 )}
840 </AnimatePresence>
475841 </motion.div>
476842 )}
477843 </AnimatePresence>
@@ -486,6 +852,29 @@ export default function InboxPage(): React.ReactNode {
486852 )}
487853 </Box>
488854 </Box>
855
856 {/* Undo toast manager */}
857 <UndoToastManager
858 actions={undoActions}
859 onExpire={removeUndoAction}
860 onDismiss={removeUndoAction}
861 />
862
863 {/* Snooze picker */}
864 <SnoozePicker
865 open={snoozePickerOpen}
866 onSnooze={(until) => {
867 if (snoozeTargetRef.current) {
868 snoozeWithUndo(snoozeTargetRef.current, until);
869 }
870 setSnoozePickerOpen(false);
871 snoozeTargetRef.current = null;
872 }}
873 onClose={() => {
874 setSnoozePickerOpen(false);
875 snoozeTargetRef.current = null;
876 }}
877 />
489878 </PageLayout>
490879 );
491880}
Modifiedapps/web/app/(dashboard)/layout.tsx+25−0View fileUnifiedSplit
@@ -10,12 +10,26 @@ import { FocusModeOverlay, type FocusModeOverlayEmail } from "../../components/F
1010import { FocusModeToggle } from "../../components/FocusModeToggle";
1111import { useFocusMode } from "../../lib/focus-mode";
1212import { authApi } from "../../lib/api";
13import { KeyboardShortcutHelp } from "../../components/KeyboardShortcutHelp";
14import { CommandPalette } from "../../components/CommandPalette";
15import { OfflineBadge } from "../../components/SyncStatusBar";
16import { InstallPrompt } from "../../components/InstallPrompt";
1317
1418const navigationSections: AnimatedSidebarSection[] = [
1519 {
1620 items: [
1721 { id: "inbox", label: "Inbox", href: "/inbox" },
1822 { id: "compose", label: "Compose", href: "/compose" },
23 { id: "sent", label: "Sent", href: "/sent" },
24 { id: "drafts", label: "Drafts", href: "/drafts" },
25 { id: "snoozed", label: "Snoozed", href: "/snoozed" },
26 ],
27 },
28 {
29 title: "Tools",
30 items: [
31 { id: "templates", label: "Templates", href: "/templates" },
32 { id: "contacts", label: "Contacts", href: "/contacts" },
1933 ],
2034 },
2135 {
@@ -154,6 +168,8 @@ export default function DashboardLayout({
154168 />
155169 <Box as="main" className="flex-1 flex flex-col min-h-0 overflow-hidden">
156170 <Box className="flex items-center justify-end gap-2 px-4 py-2 border-b border-border bg-surface-secondary/50">
171 <OfflineBadge />
172 <Box className="flex-1" />
157173 <FocusModeToggle />
158174 </Box>
159175 <AnimatedPage pageKey={pathname ?? "dashboard"} mode="slide" className="flex flex-col flex-1 min-h-0">
@@ -161,6 +177,15 @@ export default function DashboardLayout({
161177 </AnimatedPage>
162178 </Box>
163179 <FocusModeOverlay emails={focusModeEmails} />
180
181 {/* Keyboard shortcut help — toggle with ? */}
182 <KeyboardShortcutHelp />
183
184 {/* Command palette — Cmd+K */}
185 <CommandPalette />
186
187 {/* PWA install prompt */}
188 <InstallPrompt />
164189 </Box>
165190 );
166191}
Addedapps/web/app/(dashboard)/sent/page.tsx+237−0View fileUnifiedSplit
@@ -0,0 +1,237 @@
1"use client";
2
3import { useState, useEffect, useCallback } from "react";
4import { Box, Text, Button, PageLayout } from "@alecrae/ui";
5import { AnimatePresence, motion } from "motion/react";
6import { messagesApi, type Message } from "../../../lib/api";
7import { PressableScale } from "../../../components/PressableScale";
8import { EmailListSkeleton } from "../../../components/AnimatedSkeleton";
9import {
10 fadeInUp,
11 SPRING_BOUNCY,
12 useAlecRaeReducedMotion,
13} from "../../../lib/animations";
14
15interface SentEmailItem {
16 id: string;
17 to: string;
18 subject: string;
19 preview: string;
20 sentAt: string;
21 status: string;
22 opened: boolean;
23 openedAt: string | null;
24}
25
26function formatTimestamp(iso: string): string {
27 const date = new Date(iso);
28 const now = new Date();
29 const diffMs = now.getTime() - date.getTime();
30 const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
31
32 if (diffDays === 0) {
33 return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
34 }
35 if (diffDays === 1) return "Yesterday";
36 if (diffDays < 7) {
37 return date.toLocaleDateString(undefined, { weekday: "short" });
38 }
39 return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
40}
41
42function StatusBadge({ status }: { status: string }): React.ReactNode {
43 const styles: Record<string, string> = {
44 delivered: "bg-green-100 text-green-700",
45 sent: "bg-blue-100 text-blue-700",
46 queued: "bg-yellow-100 text-yellow-700",
47 bounced: "bg-red-100 text-red-700",
48 failed: "bg-red-100 text-red-700",
49 dropped: "bg-gray-100 text-gray-500",
50 };
51
52 return (
53 <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${styles[status] ?? "bg-gray-100 text-gray-600"}`}>
54 {status}
55 </span>
56 );
57}
58
59function ReadReceiptIndicator({ opened, openedAt }: { opened: boolean; openedAt: string | null }): React.ReactNode {
60 if (!opened) {
61 return (
62 <span className="flex items-center gap-1 text-xs text-content-tertiary" title="Not opened yet">
63 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
64 <path d="M22 11.08V12a10 10 0 11-5.93-9.14" strokeLinecap="round" strokeLinejoin="round" />
65 </svg>
66 Not opened
67 </span>
68 );
69 }
70
71 return (
72 <span className="flex items-center gap-1 text-xs text-green-600 font-medium" title={openedAt ? `Opened ${new Date(openedAt).toLocaleString()}` : "Opened"}>
73 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
74 <path d="M22 11.08V12a10 10 0 11-5.93-9.14" strokeLinecap="round" strokeLinejoin="round" />
75 <path d="M22 4L12 14.01l-3-3" strokeLinecap="round" strokeLinejoin="round" />
76 </svg>
77 Opened {openedAt ? formatTimestamp(openedAt) : ""}
78 </span>
79 );
80}
81
82export default function SentPage(): React.ReactNode {
83 const reduced = useAlecRaeReducedMotion();
84 const [emails, setEmails] = useState<SentEmailItem[]>([]);
85 const [loading, setLoading] = useState(true);
86 const [error, setError] = useState<string | null>(null);
87 const [selectedId, setSelectedId] = useState<string | null>(null);
88
89 const fetchSent = useCallback(async () => {
90 try {
91 setLoading(true);
92 setError(null);
93 const res = await messagesApi.list({ limit: 50, status: "sent" });
94 const items: SentEmailItem[] = res.data.map((msg: Message) => ({
95 id: msg.id,
96 to: msg.to.map((r) => r.name ?? r.email).join(", "),
97 subject: msg.subject || "(no subject)",
98 preview: msg.preview || "",
99 sentAt: msg.sentAt ?? msg.createdAt,
100 status: msg.status,
101 opened: msg.tags.includes("opened"),
102 openedAt: null,
103 }));
104 setEmails(items);
105 } catch (err) {
106 setError(err instanceof Error ? err.message : "Failed to load sent emails");
107 } finally {
108 setLoading(false);
109 }
110 }, []);
111
112 useEffect(() => {
113 fetchSent();
114 }, [fetchSent]);
115
116 const selected = emails.find((e) => e.id === selectedId);
117
118 return (
119 <PageLayout title="Sent" fullWidth>
120 <Box className="flex flex-1 h-full">
121 <Box className="w-96 border-r border-border overflow-y-auto flex-shrink-0">
122 <Box className="px-4 py-2 border-b border-border bg-surface-secondary">
123 <Text variant="body-sm" muted>
124 {loading ? "Loading..." : `${emails.length} sent emails`}
125 </Text>
126 </Box>
127
128 {loading ? (
129 <EmailListSkeleton count={8} />
130 ) : error ? (
131 <Box className="p-6 text-center">
132 <Text variant="body-sm" muted>{error}</Text>
133 <PressableScale as="button" tapScale={0.95} className="mt-3">
134 <Button variant="secondary" size="sm" onClick={fetchSent}>Retry</Button>
135 </PressableScale>
136 </Box>
137 ) : emails.length === 0 ? (
138 <motion.div
139 className="flex flex-col items-center justify-center p-8"
140 variants={fadeInUp}
141 initial="initial"
142 animate="animate"
143 >
144 <Text variant="body-md" muted>No sent emails yet</Text>
145 <Text variant="body-sm" muted className="mt-1">
146 Compose an email to get started
147 </Text>
148 </motion.div>
149 ) : (
150 <AnimatePresence mode="wait">
151 <motion.div
152 initial={reduced ? false : { opacity: 0 }}
153 animate={{ opacity: 1 }}
154 transition={{ duration: 0.15 }}
155 >
156 {emails.map((email) => (
157 <button
158 key={email.id}
159 type="button"
160 onClick={() => setSelectedId(email.id)}
161 className={`w-full text-left px-4 py-3 border-b border-border transition-colors ${
162 selectedId === email.id
163 ? "bg-brand-50 border-l-2 border-l-brand-500"
164 : "hover:bg-surface-secondary"
165 }`}
166 >
167 <Box className="flex items-center justify-between mb-1">
168 <Text variant="body-sm" className="font-medium text-content truncate flex-1 mr-2">
169 {email.to}
170 </Text>
171 <Text variant="caption" muted className="flex-shrink-0">
172 {formatTimestamp(email.sentAt)}
173 </Text>
174 </Box>
175 <Text variant="body-sm" className="text-content truncate">
176 {email.subject}
177 </Text>
178 <Box className="flex items-center gap-2 mt-1.5">
179 <StatusBadge status={email.status} />
180 <ReadReceiptIndicator opened={email.opened} openedAt={email.openedAt} />
181 </Box>
182 </button>
183 ))}
184 </motion.div>
185 </AnimatePresence>
186 )}
187 </Box>
188
189 <Box className="flex-1 flex items-center justify-center min-w-0">
190 <AnimatePresence mode="wait">
191 {selected ? (
192 <motion.div
193 key={selected.id}
194 className="w-full max-w-2xl p-8"
195 initial={reduced ? false : { opacity: 0, y: 8 }}
196 animate={{ opacity: 1, y: 0 }}
197 exit={{ opacity: 0, y: -8 }}
198 transition={SPRING_BOUNCY}
199 >
200 <Box className="mb-6">
201 <Text variant="heading-lg" className="text-content mb-2">
202 {selected.subject}
203 </Text>
204 <Box className="flex items-center gap-3 mb-4">
205 <Text variant="body-sm" muted>To: {selected.to}</Text>
206 <StatusBadge status={selected.status} />
207 </Box>
208 <Box className="flex items-center gap-4 p-4 rounded-lg bg-surface-secondary border border-border">
209 <ReadReceiptIndicator opened={selected.opened} openedAt={selected.openedAt} />
210 <Text variant="caption" muted>
211 Sent {new Date(selected.sentAt).toLocaleString()}
212 </Text>
213 </Box>
214 </Box>
215 <Box className="prose prose-sm max-w-none">
216 <Text variant="body-md" className="text-content-secondary whitespace-pre-wrap">
217 {selected.preview}
218 </Text>
219 </Box>
220 </motion.div>
221 ) : (
222 <motion.div
223 key="empty"
224 variants={fadeInUp}
225 initial="initial"
226 animate="animate"
227 exit="exit"
228 >
229 <Text variant="body-md" muted>Select a sent email to view details</Text>
230 </motion.div>
231 )}
232 </AnimatePresence>
233 </Box>
234 </Box>
235 </PageLayout>
236 );
237}
Modifiedapps/web/app/(dashboard)/settings/page.tsx+174−54View fileUnifiedSplit
@@ -1,6 +1,6 @@
11"use client";
22
3import { useState, useEffect } from "react";
3import { useState, useEffect, useCallback } from "react";
44import {
55 Box,
66 Text,
@@ -13,9 +13,10 @@ import {
1313 PageLayout,
1414} from "@alecrae/ui";
1515import { motion } from "motion/react";
16import { authApi, accountApi } from "../../../lib/api";
16import { authApi, accountApi, type PasskeyInfo, type NotificationPrefs } from "../../../lib/api";
1717import { PressableScale } from "../../../components/PressableScale";
1818import { AnimatedPresence } from "../../../components/AnimatedPresence";
19import { SignatureManager } from "../../../components/SignatureManager";
1920import {
2021 staggerSlow,
2122 fadeInUp,
@@ -63,7 +64,7 @@ export default function SettingsPage(): React.ReactNode {
6364 animate="animate"
6465 >
6566 <motion.div variants={itemVariants}>
66 <ProfileSection user={user} loading={loading} />
67 <ProfileSection user={user} loading={loading} onUpdate={setUser} />
6768 </motion.div>
6869 <motion.div variants={itemVariants}>
6970 <AccountOverview account={account} loading={loading} />
@@ -74,6 +75,19 @@ export default function SettingsPage(): React.ReactNode {
7475 <motion.div variants={itemVariants}>
7576 <NotificationSection />
7677 </motion.div>
78 <motion.div variants={itemVariants}>
79 <Card>
80 <CardHeader>
81 <Text variant="heading-md">Email Signatures</Text>
82 <Text variant="body-sm" muted>
83 Create and manage email signatures. Your default signature is auto-appended to new emails.
84 </Text>
85 </CardHeader>
86 <CardContent>
87 <SignatureManager />
88 </CardContent>
89 </Card>
90 </motion.div>
7791 <motion.div variants={itemVariants}>
7892 <DangerZone />
7993 </motion.div>
@@ -82,11 +96,20 @@ export default function SettingsPage(): React.ReactNode {
8296 );
8397}
8498
85function ProfileSection({ user, loading }: { user: UserData | null; loading: boolean }) {
99function ProfileSection({
100 user,
101 loading,
102 onUpdate,
103}: {
104 user: UserData | null;
105 loading: boolean;
106 onUpdate: (u: UserData) => void;
107}) {
86108 const [name, setName] = useState("");
87109 const [email, setEmail] = useState("");
88110 const [saving, setSaving] = useState(false);
89 const [saved, setSaved] = useState(false);
111 const [status, setStatus] = useState<"idle" | "saved" | "error">("idle");
112 const [errorMsg, setErrorMsg] = useState("");
90113
91114 useEffect(() => {
92115 if (user) {
@@ -99,14 +122,15 @@ function ProfileSection({ user, loading }: { user: UserData | null; loading: boo
99122
100123 const handleSave = async () => {
101124 setSaving(true);
102 setSaved(false);
103 setSaveError(null);
125 setStatus("idle");
104126 try {
105 await accountApi.update({ name });
106 setSaved(true);
107 setTimeout(() => setSaved(false), 2000);
127 const res = await accountApi.updateProfile({ name, email });
128 onUpdate({ name: res.data.name, email: res.data.email });
129 setStatus("saved");
130 setTimeout(() => setStatus("idle"), 2000);
108131 } catch (err) {
109 setSaveError(err instanceof Error ? err.message : "Could not save changes");
132 setStatus("error");
133 setErrorMsg(err instanceof Error ? err.message : "Failed to save");
110134 } finally {
111135 setSaving(false);
112136 }
@@ -138,14 +162,14 @@ function ProfileSection({ user, loading }: { user: UserData | null; loading: boo
138162 label="Full Name"
139163 variant="text"
140164 value={name}
141 onChange={(e) => setName(e.target.value)}
165 onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
142166 disabled={loading}
143167 />
144168 <Input
145169 label="Email"
146170 variant="email"
147171 value={email}
148 onChange={(e) => setEmail(e.target.value)}
172 onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
149173 disabled={loading}
150174 />
151175 </Box>
@@ -158,11 +182,11 @@ function ProfileSection({ user, loading }: { user: UserData | null; loading: boo
158182 Saved
159183 </Text>
160184 </AnimatedPresence>
161 {saveError && (
185 <AnimatedPresence show={status === "error"} presenceKey="error-indicator">
162186 <Text variant="body-sm" className="text-status-error">
163 {saveError}
187 {errorMsg}
164188 </Text>
165 )}
189 </AnimatedPresence>
166190 <PressableScale as="button" tapScale={0.95}>
167191 <Button variant="primary" size="sm" onClick={handleSave} disabled={saving || loading}>
168192 {saving ? "Saving..." : "Save Changes"}
@@ -205,6 +229,41 @@ function AccountOverview({ account, loading }: { account: AccountData | null; lo
205229AccountOverview.displayName = "AccountOverview";
206230
207231function SecuritySection() {
232 const [passkeysData, setPasskeysData] = useState<PasskeyInfo[]>([]);
233 const [loadingPasskeys, setLoadingPasskeys] = useState(true);
234 const [showPasskeys, setShowPasskeys] = useState(false);
235 const [deletingId, setDeletingId] = useState<string | null>(null);
236
237 const loadPasskeys = useCallback(async () => {
238 try {
239 const res = await accountApi.listPasskeys();
240 setPasskeysData(res.data);
241 } catch {
242 setPasskeysData([]);
243 } finally {
244 setLoadingPasskeys(false);
245 }
246 }, []);
247
248 const handleDeletePasskey = async (id: string) => {
249 setDeletingId(id);
250 try {
251 await accountApi.deletePasskey(id);
252 setPasskeysData((prev) => prev.filter((p) => p.id !== id));
253 } catch {
254 // silently fail — user can retry
255 } finally {
256 setDeletingId(null);
257 }
258 };
259
260 const handleManagePasskeys = () => {
261 if (!showPasskeys) {
262 loadPasskeys();
263 }
264 setShowPasskeys(!showPasskeys);
265 };
266
208267 return (
209268 <Card>
210269 <CardHeader>
@@ -212,25 +271,52 @@ function SecuritySection() {
212271 </CardHeader>
213272 <CardContent>
214273 <Box className="space-y-4">
215 <Box className="flex items-center justify-between">
216 <Box>
217 <Text variant="body-md" className="font-medium">
218 Passkeys
219 </Text>
220 <Text variant="body-sm" muted>
221 Use biometric or hardware key authentication for secure, passwordless login.
222 </Text>
274 <Box>
275 <Box className="flex items-center justify-between">
276 <Box>
277 <Text variant="body-md" className="font-medium">Passkeys</Text>
278 <Text variant="body-sm" muted>
279 Use biometric or hardware key authentication for secure, passwordless login.
280 </Text>
281 </Box>
282 <Button variant="secondary" size="sm" onClick={handleManagePasskeys}>
283 {showPasskeys ? "Hide" : "Manage Passkeys"}
284 </Button>
223285 </Box>
224 <Button variant="secondary" size="sm">
225 Manage Passkeys
226 </Button>
286 {showPasskeys && (
287 <Box className="mt-4 space-y-2">
288 {loadingPasskeys ? (
289 <Text variant="body-sm" muted>Loading passkeys...</Text>
290 ) : passkeysData.length === 0 ? (
291 <Text variant="body-sm" muted>No passkeys registered yet.</Text>
292 ) : (
293 passkeysData.map((pk) => (
294 <Box key={pk.id} className="flex items-center justify-between p-3 rounded-lg bg-surface-tertiary">
295 <Box>
296 <Text variant="body-sm" className="font-medium">{pk.deviceName}</Text>
297 <Text variant="caption" muted>
298 Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : "—"}
299 {pk.lastUsedAt ? ` · Last used ${new Date(pk.lastUsedAt).toLocaleDateString()}` : ""}
300 </Text>
301 </Box>
302 <Button
303 variant="destructive"
304 size="sm"
305 onClick={() => handleDeletePasskey(pk.id)}
306 disabled={deletingId === pk.id}
307 >
308 {deletingId === pk.id ? "Removing..." : "Remove"}
309 </Button>
310 </Box>
311 ))
312 )}
313 </Box>
314 )}
227315 </Box>
228316 <Box as="hr" className="border-border" />
229317 <Box className="flex items-center justify-between">
230318 <Box>
231 <Text variant="body-md" className="font-medium">
232 Two-Factor Authentication
233 </Text>
319 <Text variant="body-md" className="font-medium">Two-Factor Authentication</Text>
234320 <Text variant="body-sm" muted>
235321 Add an extra layer of security with TOTP-based 2FA.
236322 </Text>
@@ -242,9 +328,7 @@ function SecuritySection() {
242328 <Box as="hr" className="border-border" />
243329 <Box className="flex items-center justify-between">
244330 <Box>
245 <Text variant="body-md" className="font-medium">
246 Active Sessions
247 </Text>
331 <Text variant="body-md" className="font-medium">Active Sessions</Text>
248332 <Text variant="body-sm" muted>
249333 Review and manage devices where you are currently signed in.
250334 </Text>
@@ -262,9 +346,29 @@ function SecuritySection() {
262346SecuritySection.displayName = "SecuritySection";
263347
264348function NotificationSection() {
265 const [emailNotifs, setEmailNotifs] = useState(true);
266 const [aiDigest, setAiDigest] = useState(true);
267 const [deliverabilityAlerts, setDeliverabilityAlerts] = useState(true);
349 const [prefs, setPrefs] = useState<NotificationPrefs>({
350 emailNotifications: true,
351 aiDigest: true,
352 deliverabilityAlerts: true,
353 });
354 const [loaded, setLoaded] = useState(false);
355
356 useEffect(() => {
357 accountApi.getNotificationPrefs().then((res) => {
358 setPrefs(res.data);
359 setLoaded(true);
360 }).catch(() => setLoaded(true));
361 }, []);
362
363 const toggle = async (key: keyof NotificationPrefs) => {
364 const updated = { ...prefs, [key]: !prefs[key] };
365 setPrefs(updated);
366 try {
367 await accountApi.updateNotificationPrefs({ [key]: updated[key] });
368 } catch {
369 setPrefs(prefs);
370 }
371 };
268372
269373 return (
270374 <Card>
@@ -275,53 +379,50 @@ function NotificationSection() {
275379 <Box className="space-y-4">
276380 <Box className="flex items-center justify-between">
277381 <Box>
278 <Text variant="body-md" className="font-medium">
279 Email Notifications
280 </Text>
382 <Text variant="body-md" className="font-medium">Email Notifications</Text>
281383 <Text variant="body-sm" muted>
282384 Receive notifications about important account events.
283385 </Text>
284386 </Box>
285387 <Button
286 variant={emailNotifs ? "secondary" : "ghost"}
388 variant={prefs.emailNotifications ? "secondary" : "ghost"}
287389 size="sm"
288 onClick={() => setEmailNotifs(!emailNotifs)}
390 onClick={() => toggle("emailNotifications")}
391 disabled={!loaded}
289392 >
290 {emailNotifs ? "Enabled" : "Disabled"}
393 {prefs.emailNotifications ? "Enabled" : "Disabled"}
291394 </Button>
292395 </Box>
293396 <Box className="flex items-center justify-between">
294397 <Box>
295 <Text variant="body-md" className="font-medium">
296 AI Digest
297 </Text>
398 <Text variant="body-md" className="font-medium">AI Digest</Text>
298399 <Text variant="body-sm" muted>
299400 Get a daily AI-generated summary of your inbox activity.
300401 </Text>
301402 </Box>
302403 <Button
303 variant={aiDigest ? "secondary" : "ghost"}
404 variant={prefs.aiDigest ? "secondary" : "ghost"}
304405 size="sm"
305 onClick={() => setAiDigest(!aiDigest)}
406 onClick={() => toggle("aiDigest")}
407 disabled={!loaded}
306408 >
307 {aiDigest ? "Enabled" : "Disabled"}
409 {prefs.aiDigest ? "Enabled" : "Disabled"}
308410 </Button>
309411 </Box>
310412 <Box className="flex items-center justify-between">
311413 <Box>
312 <Text variant="body-md" className="font-medium">
313 Deliverability Alerts
314 </Text>
414 <Text variant="body-md" className="font-medium">Deliverability Alerts</Text>
315415 <Text variant="body-sm" muted>
316416 Be notified when domain reputation or deliverability drops.
317417 </Text>
318418 </Box>
319419 <Button
320 variant={deliverabilityAlerts ? "secondary" : "ghost"}
420 variant={prefs.deliverabilityAlerts ? "secondary" : "ghost"}
321421 size="sm"
322 onClick={() => setDeliverabilityAlerts(!deliverabilityAlerts)}
422 onClick={() => toggle("deliverabilityAlerts")}
423 disabled={!loaded}
323424 >
324 {deliverabilityAlerts ? "Enabled" : "Disabled"}
425 {prefs.deliverabilityAlerts ? "Enabled" : "Disabled"}
325426 </Button>
326427 </Box>
327428 </Box>
@@ -333,6 +434,25 @@ function NotificationSection() {
333434NotificationSection.displayName = "NotificationSection";
334435
335436function DangerZone() {
437 const [confirming, setConfirming] = useState(false);
438 const [deleting, setDeleting] = useState(false);
439
440 const handleDelete = async () => {
441 if (!confirming) {
442 setConfirming(true);
443 return;
444 }
445 setDeleting(true);
446 try {
447 await accountApi.deleteAccount();
448 authApi.logout();
449 window.location.href = "/";
450 } catch {
451 setDeleting(false);
452 setConfirming(false);
453 }
454 };
455
336456 return (
337457 <Card className="border-status-error/30">
338458 <CardHeader>
Addedapps/web/app/(dashboard)/snoozed/page.tsx+143−0View fileUnifiedSplit
@@ -0,0 +1,143 @@
1"use client";
2
3import { useState, useEffect, useCallback } from "react";
4import { Box, Text, Button, PageLayout } from "@alecrae/ui";
5import { AnimatePresence, motion } from "motion/react";
6import { snoozeApi } from "../../../lib/api";
7import { PressableScale } from "../../../components/PressableScale";
8import {
9 fadeInUp,
10 SPRING_BOUNCY,
11 useAlecRaeReducedMotion,
12} from "../../../lib/animations";
13
14interface SnoozedItem {
15 id: string;
16 emailId: string;
17 subject: string;
18 snoozedUntil: string;
19}
20
21function formatSnoozeTime(iso: string): string {
22 const date = new Date(iso);
23 const now = new Date();
24 const diffMs = date.getTime() - now.getTime();
25 const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
26 const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
27
28 if (diffMs < 0) return "Overdue";
29 if (diffHours < 1) return "Less than 1 hour";
30 if (diffHours < 24) return `In ${diffHours} hour${diffHours === 1 ? "" : "s"}`;
31 if (diffDays === 1) return "Tomorrow";
32 if (diffDays < 7) {
33 return date.toLocaleDateString(undefined, { weekday: "long", hour: "numeric", minute: "2-digit" });
34 }
35 return date.toLocaleDateString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
36}
37
38export default function SnoozedPage(): React.ReactNode {
39 const reduced = useAlecRaeReducedMotion();
40 const [items, setItems] = useState<SnoozedItem[]>([]);
41 const [loading, setLoading] = useState(true);
42 const [error, setError] = useState<string | null>(null);
43
44 const fetchSnoozed = useCallback(async () => {
45 try {
46 setLoading(true);
47 setError(null);
48 const res = await snoozeApi.list();
49 setItems(res.data);
50 } catch (err) {
51 setError(err instanceof Error ? err.message : "Failed to load snoozed emails");
52 } finally {
53 setLoading(false);
54 }
55 }, []);
56
57 useEffect(() => {
58 fetchSnoozed();
59 }, [fetchSnoozed]);
60
61 const handleUnsnooze = async (emailId: string): Promise<void> => {
62 setItems((prev) => prev.filter((i) => i.emailId !== emailId));
63 try {
64 await snoozeApi.unsnooze(emailId);
65 } catch {
66 fetchSnoozed();
67 }
68 };
69
70 return (
71 <PageLayout title="Snoozed" fullWidth>
72 <Box className="w-full max-w-3xl mx-auto">
73 <Box className="px-4 py-2 border-b border-border bg-surface-secondary">
74 <Text variant="body-sm" muted>
75 {loading ? "Loading..." : `${items.length} snoozed email${items.length === 1 ? "" : "s"}`}
76 </Text>
77 </Box>
78
79 {loading ? (
80 <Box className="p-8 text-center">
81 <Text variant="body-sm" muted>Loading snoozed emails...</Text>
82 </Box>
83 ) : error ? (
84 <Box className="p-6 text-center">
85 <Text variant="body-sm" muted>{error}</Text>
86 <PressableScale as="button" tapScale={0.95} className="mt-3">
87 <Button variant="secondary" size="sm" onClick={fetchSnoozed}>Retry</Button>
88 </PressableScale>
89 </Box>
90 ) : items.length === 0 ? (
91 <motion.div
92 className="flex flex-col items-center justify-center p-12"
93 variants={fadeInUp}
94 initial="initial"
95 animate="animate"
96 >
97 <Text variant="heading-md" muted>No snoozed emails</Text>
98 <Text variant="body-sm" muted className="mt-2">
99 Snooze emails from your inbox to see them here. Press S on any email to snooze.
100 </Text>
101 </motion.div>
102 ) : (
103 <AnimatePresence>
104 {items.map((item) => (
105 <motion.div
106 key={item.id}
107 layout
108 initial={reduced ? false : { opacity: 0, y: 8 }}
109 animate={{ opacity: 1, y: 0 }}
110 exit={{ opacity: 0, x: -50 }}
111 transition={SPRING_BOUNCY}
112 className="flex items-center px-4 py-4 border-b border-border hover:bg-surface-secondary transition-colors"
113 >
114 <Box className="w-10 h-10 rounded-full bg-brand-50 flex items-center justify-center flex-shrink-0 mr-4">
115 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-brand-500" aria-hidden="true">
116 <circle cx="12" cy="12" r="10" />
117 <path d="M12 6v6l4 2" strokeLinecap="round" strokeLinejoin="round" />
118 </svg>
119 </Box>
120 <Box className="flex-1 min-w-0">
121 <Text variant="body-sm" className="font-medium text-content truncate">
122 {item.subject}
123 </Text>
124 <Text variant="caption" className="text-brand-600 font-medium mt-0.5">
125 {formatSnoozeTime(item.snoozedUntil)}
126 </Text>
127 </Box>
128 <button
129 type="button"
130 onClick={() => void handleUnsnooze(item.emailId)}
131 className="flex-shrink-0 px-3 py-1.5 text-xs font-medium text-brand-600 hover:text-brand-700 hover:bg-brand-50 rounded-lg transition-colors"
132 aria-label="Unsnooze email"
133 >
134 Unsnooze
135 </button>
136 </motion.div>
137 ))}
138 </AnimatePresence>
139 )}
140 </Box>
141 </PageLayout>
142 );
143}
Addedapps/web/app/(dashboard)/templates/page.tsx+866−0View fileUnifiedSplit
@@ -0,0 +1,866 @@
1"use client";
2
3import { useState, useEffect, useCallback, useMemo } from "react";
4import {
5 Box,
6 Text,
7 Button,
8 Input,
9 Card,
10 CardContent,
11 CardHeader,
12 CardFooter,
13 PageLayout,
14} from "@alecrae/ui";
15import { AnimatePresence, motion } from "motion/react";
16import {
17 templatesApi,
18 type Template,
19 type TemplateRenderResult,
20} from "../../../lib/api";
21import {
22 fadeInUp,
23 scaleIn,
24 staggerSlow,
25 SPRING_BOUNCY,
26 useAlecRaeReducedMotion,
27 withReducedMotion,
28} from "../../../lib/animations";
29
30// ─── Helpers ──────────────────────────────────────────────────────────────────
31
32/** Extract {{variable}} placeholders from a string. */
33function extractVariables(text: string): string[] {
34 const matches = text.match(/\{\{(\w+)\}\}/g);
35 if (!matches) return [];
36 const unique = new Set(matches.map((m) => m.replace(/\{\{|\}\}/g, "")));
37 return Array.from(unique);
38}
39
40/** Get all variables from a template's subject + body fields. */
41function getTemplateVariables(template: {
42 subject: string;
43 htmlBody?: string | null;
44 textBody?: string | null;
45}): string[] {
46 const combined = [
47 template.subject,
48 template.htmlBody ?? "",
49 template.textBody ?? "",
50 ].join(" ");
51 return extractVariables(combined);
52}
53
54function formatDate(iso: string): string {
55 return new Date(iso).toLocaleDateString("en-US", {
56 month: "short",
57 day: "numeric",
58 year: "numeric",
59 });
60}
61
62// ─── Types ────────────────────────────────────────────────────────────────────
63
64interface CreateFormData {
65 name: string;
66 subject: string;
67 htmlBody: string;
68 textBody: string;
69}
70
71// ─── Main Page ────────────────────────────────────────────────────────────────
72
73export default function TemplatesPage(): React.ReactNode {
74 const reduced = useAlecRaeReducedMotion();
75 const itemVariants = withReducedMotion(fadeInUp, reduced);
76 const modalVariants = withReducedMotion(scaleIn, reduced);
77
78 const [templates, setTemplates] = useState<Template[]>([]);
79 const [loading, setLoading] = useState(true);
80 const [error, setError] = useState<string | null>(null);
81 const [showCreateForm, setShowCreateForm] = useState(false);
82 const [expandedId, setExpandedId] = useState<string | null>(null);
83 const [previewId, setPreviewId] = useState<string | null>(null);
84 const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
85
86 const loadTemplates = useCallback(async () => {
87 try {
88 setLoading(true);
89 const res = await templatesApi.list({ limit: 50 });
90 setTemplates(res.data);
91 setError(null);
92 } catch (err) {
93 setError(err instanceof Error ? err.message : "Failed to load templates");
94 } finally {
95 setLoading(false);
96 }
97 }, []);
98
99 useEffect(() => {
100 loadTemplates();
101 }, [loadTemplates]);
102
103 const handleDelete = async (id: string) => {
104 try {
105 await templatesApi.delete(id);
106 setTemplates((prev) => prev.filter((t) => t.id !== id));
107 setDeleteConfirmId(null);
108 setExpandedId(null);
109 } catch (err) {
110 setError(err instanceof Error ? err.message : "Failed to delete template");
111 }
112 };
113
114 const handleCreated = (template: Template) => {
115 setTemplates((prev) => [template, ...prev]);
116 setShowCreateForm(false);
117 };
118
119 const handleUpdated = (updated: Template) => {
120 setTemplates((prev) =>
121 prev.map((t) => (t.id === updated.id ? updated : t)),
122 );
123 };
124
125 const actions = (
126 <Button
127 variant="primary"
128 size="sm"
129 onClick={() => setShowCreateForm(true)}
130 >
131 New Template
132 </Button>
133 );
134
135 return (
136 <PageLayout
137 title="Templates"
138 description="Create and manage reusable email templates with dynamic variables."
139 actions={actions}
140 >
141 {error && (
142 <Box className="mb-4 rounded-md border border-red-200 bg-red-50 p-3">
143 <Text variant="body-sm" className="text-red-800">
144 {error}
145 </Text>
146 </Box>
147 )}
148
149 <AnimatePresence mode="wait">
150 {showCreateForm && (
151 <motion.div
152 key="create-form"
153 variants={modalVariants}
154 initial="initial"
155 animate="animate"
156 exit="exit"
157 >
158 <CreateTemplateForm
159 onClose={() => setShowCreateForm(false)}
160 onCreated={handleCreated}
161 />
162 </motion.div>
163 )}
164 </AnimatePresence>
165
166 {loading ? (
167 <Box className="space-y-4">
168 {[1, 2, 3].map((i) => (
169 <Box
170 key={i}
171 className="h-20 animate-pulse rounded-lg bg-surface-secondary"
172 />
173 ))}
174 </Box>
175 ) : templates.length === 0 ? (
176 <Card>
177 <CardContent>
178 <Box className="py-8 text-center">
179 <Text variant="heading-sm" muted className="mb-2">
180 No templates yet
181 </Text>
182 <Text variant="body-sm" muted>
183 Create your first email template to get started. Use variables
184 like {"{{name}}"} for personalization.
185 </Text>
186 </Box>
187 </CardContent>
188 </Card>
189 ) : (
190 <motion.div
191 className="space-y-3"
192 variants={staggerSlow}
193 initial="initial"
194 animate="animate"
195 >
196 <AnimatePresence>
197 {templates.map((template) => (
198 <motion.div
199 key={template.id}
200 variants={itemVariants}
201 layout
202 transition={SPRING_BOUNCY}
203 >
204 <TemplateCard
205 template={template}
206 isExpanded={expandedId === template.id}
207 onToggleExpand={() =>
208 setExpandedId(
209 expandedId === template.id ? null : template.id,
210 )
211 }
212 onUpdated={handleUpdated}
213 onPreview={() => setPreviewId(template.id)}
214 deleteConfirm={deleteConfirmId === template.id}
215 onDeleteRequest={() => setDeleteConfirmId(template.id)}
216 onDeleteCancel={() => setDeleteConfirmId(null)}
217 onDeleteConfirm={() => handleDelete(template.id)}
218 />
219 </motion.div>
220 ))}
221 </AnimatePresence>
222 </motion.div>
223 )}
224
225 <AnimatePresence>
226 {previewId && (
227 <TemplatePreviewPanel
228 templateId={previewId}
229 template={templates.find((t) => t.id === previewId) ?? null}
230 onClose={() => setPreviewId(null)}
231 />
232 )}
233 </AnimatePresence>
234 </PageLayout>
235 );
236}
237
238// ─── Create Template Form ─────────────────────────────────────────────────────
239
240function CreateTemplateForm({
241 onClose,
242 onCreated,
243}: {
244 onClose: () => void;
245 onCreated: (template: Template) => void;
246}): React.ReactNode {
247 const [form, setForm] = useState<CreateFormData>({
248 name: "",
249 subject: "",
250 htmlBody: "",
251 textBody: "",
252 });
253 const [saving, setSaving] = useState(false);
254 const [formError, setFormError] = useState<string | null>(null);
255
256 const variables = useMemo(
257 () => getTemplateVariables(form),
258 [form],
259 );
260
261 const handleSubmit = async () => {
262 if (!form.name.trim() || !form.subject.trim()) {
263 setFormError("Name and subject are required.");
264 return;
265 }
266
267 setSaving(true);
268 setFormError(null);
269
270 try {
271 const res = await templatesApi.create({
272 name: form.name.trim(),
273 subject: form.subject.trim(),
274 htmlBody: form.htmlBody.trim() || undefined,
275 textBody: form.textBody.trim() || undefined,
276 });
277 onCreated(res.data);
278 } catch (err) {
279 setFormError(
280 err instanceof Error ? err.message : "Failed to create template",
281 );
282 } finally {
283 setSaving(false);
284 }
285 };
286
287 return (
288 <Card className="mb-6 border-border">
289 <CardHeader>
290 <Text variant="heading-sm">Create New Template</Text>
291 </CardHeader>
292 <CardContent>
293 {formError && (
294 <Box className="mb-3 rounded border border-red-200 bg-red-50 p-2">
295 <Text variant="body-sm" className="text-red-800">
296 {formError}
297 </Text>
298 </Box>
299 )}
300 <Box className="space-y-4">
301 <Input
302 label="Template Name"
303 variant="text"
304 placeholder="e.g. Welcome Email"
305 value={form.name}
306 onChange={(e) => setForm({ ...form, name: e.target.value })}
307 />
308 <Input
309 label="Subject Line"
310 variant="text"
311 placeholder="e.g. Welcome to {{company}}, {{name}}!"
312 value={form.subject}
313 onChange={(e) => setForm({ ...form, subject: e.target.value })}
314 />
315 <Box>
316 <Text variant="body-sm" className="mb-1 font-medium text-content">
317 HTML Body
318 </Text>
319 <textarea
320 className="w-full rounded-md border border-border bg-surface p-3 font-mono text-sm text-content placeholder:text-content-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
321 rows={6}
322 placeholder={"<p>Hi {{name}},</p>\n<p>Welcome to {{company}}!</p>"}
323 value={form.htmlBody}
324 onChange={(e) => setForm({ ...form, htmlBody: e.target.value })}
325 />
326 </Box>
327 <Box>
328 <Text variant="body-sm" className="mb-1 font-medium text-content">
329 Plain Text Body
330 </Text>
331 <textarea
332 className="w-full rounded-md border border-border bg-surface p-3 font-mono text-sm text-content placeholder:text-content-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
333 rows={4}
334 placeholder={"Hi {{name}},\nWelcome to {{company}}!"}
335 value={form.textBody}
336 onChange={(e) => setForm({ ...form, textBody: e.target.value })}
337 />
338 </Box>
339
340 {variables.length > 0 && (
341 <Box>
342 <Text variant="body-sm" muted className="mb-2">
343 Detected variables:
344 </Text>
345 <Box className="flex flex-wrap gap-2">
346 {variables.map((v) => (
347 <Box
348 key={v}
349 className="rounded-full border border-accent/30 bg-accent/10 px-2.5 py-0.5"
350 >
351 <Text variant="body-sm" className="text-accent font-medium">
352 {`{{${v}}}`}
353 </Text>
354 </Box>
355 ))}
356 </Box>
357 </Box>
358 )}
359 </Box>
360 </CardContent>
361 <CardFooter>
362 <Box className="flex items-center gap-3">
363 <Button
364 variant="primary"
365 size="sm"
366 onClick={handleSubmit}
367 disabled={saving || !form.name.trim() || !form.subject.trim()}
368 >
369 {saving ? "Creating..." : "Create Template"}
370 </Button>
371 <Button variant="ghost" size="sm" onClick={onClose}>
372 Cancel
373 </Button>
374 </Box>
375 </CardFooter>
376 </Card>
377 );
378}
379
380CreateTemplateForm.displayName = "CreateTemplateForm";
381
382// ─── Template Card ────────────────────────────────────────────────────────────
383
384function TemplateCard({
385 template,
386 isExpanded,
387 onToggleExpand,
388 onUpdated,
389 onPreview,
390 deleteConfirm,
391 onDeleteRequest,
392 onDeleteCancel,
393 onDeleteConfirm,
394}: {
395 template: Template;
396 isExpanded: boolean;
397 onToggleExpand: () => void;
398 onUpdated: (updated: Template) => void;
399 onPreview: () => void;
400 deleteConfirm: boolean;
401 onDeleteRequest: () => void;
402 onDeleteCancel: () => void;
403 onDeleteConfirm: () => void;
404}): React.ReactNode {
405 const variables = useMemo(() => getTemplateVariables(template), [template]);
406
407 return (
408 <Card className="border-border transition-colors hover:border-accent/40">
409 <CardContent className="p-0">
410 <Box
411 className="flex cursor-pointer items-center justify-between p-4"
412 onClick={onToggleExpand}
413 >
414 <Box className="min-w-0 flex-1">
415 <Box className="flex items-center gap-3">
416 <Text variant="body-md" className="font-semibold text-content truncate">
417 {template.name}
418 </Text>
419 {variables.length > 0 && (
420 <Box className="hidden shrink-0 rounded-full bg-surface-secondary px-2 py-0.5 sm:block">
421 <Text variant="body-xs" muted>
422 {variables.length} variable{variables.length !== 1 ? "s" : ""}
423 </Text>
424 </Box>
425 )}
426 </Box>
427 <Text variant="body-sm" muted className="mt-0.5 truncate">
428 {template.subject}
429 </Text>
430 </Box>
431 <Box className="flex shrink-0 items-center gap-2 pl-4">
432 <Text variant="body-xs" muted className="hidden md:block">
433 {formatDate(template.updatedAt)}
434 </Text>
435 <Box
436 className={`h-5 w-5 text-content-muted transition-transform ${
437 isExpanded ? "rotate-180" : ""
438 }`}
439 >
440 <svg
441 xmlns="http://www.w3.org/2000/svg"
442 viewBox="0 0 20 20"
443 fill="currentColor"
444 className="h-5 w-5"
445 >
446 <path
447 fillRule="evenodd"
448 d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
449 clipRule="evenodd"
450 />
451 </svg>
452 </Box>
453 </Box>
454 </Box>
455
456 <AnimatePresence>
457 {isExpanded && (
458 <motion.div
459 initial={{ height: 0, opacity: 0 }}
460 animate={{
461 height: "auto",
462 opacity: 1,
463 transition: {
464 height: SPRING_BOUNCY,
465 opacity: { duration: 0.2, delay: 0.05 },
466 },
467 }}
468 exit={{
469 height: 0,
470 opacity: 0,
471 transition: {
472 height: { duration: 0.2 },
473 opacity: { duration: 0.1 },
474 },
475 }}
476 className="overflow-hidden"
477 >
478 <TemplateEditSection
479 template={template}
480 variables={variables}
481 onUpdated={onUpdated}
482 onPreview={onPreview}
483 deleteConfirm={deleteConfirm}
484 onDeleteRequest={onDeleteRequest}
485 onDeleteCancel={onDeleteCancel}
486 onDeleteConfirm={onDeleteConfirm}
487 />
488 </motion.div>
489 )}
490 </AnimatePresence>
491 </CardContent>
492 </Card>
493 );
494}
495
496TemplateCard.displayName = "TemplateCard";
497
498// ─── Template Edit Section ────────────────────────────────────────────────────
499
500function TemplateEditSection({
501 template,
502 variables,
503 onUpdated,
504 onPreview,
505 deleteConfirm,
506 onDeleteRequest,
507 onDeleteCancel,
508 onDeleteConfirm,
509}: {
510 template: Template;
511 variables: string[];
512 onUpdated: (updated: Template) => void;
513 onPreview: () => void;
514 deleteConfirm: boolean;
515 onDeleteRequest: () => void;
516 onDeleteCancel: () => void;
517 onDeleteConfirm: () => void;
518}): React.ReactNode {
519 const [editName, setEditName] = useState(template.name);
520 const [editSubject, setEditSubject] = useState(template.subject);
521 const [editHtml, setEditHtml] = useState(template.htmlBody ?? "");
522 const [editText, setEditText] = useState(template.textBody ?? "");
523 const [saving, setSaving] = useState(false);
524 const [editError, setEditError] = useState<string | null>(null);
525
526 const isDirty =
527 editName !== template.name ||
528 editSubject !== template.subject ||
529 editHtml !== (template.htmlBody ?? "") ||
530 editText !== (template.textBody ?? "");
531
532 const currentVariables = useMemo(
533 () =>
534 getTemplateVariables({
535 subject: editSubject,
536 htmlBody: editHtml,
537 textBody: editText,
538 }),
539 [editSubject, editHtml, editText],
540 );
541
542 const handleSave = async () => {
543 if (!editName.trim() || !editSubject.trim()) {
544 setEditError("Name and subject are required.");
545 return;
546 }
547
548 setSaving(true);
549 setEditError(null);
550
551 try {
552 const res = await templatesApi.update(template.id, {
553 name: editName.trim(),
554 subject: editSubject.trim(),
555 htmlBody: editHtml.trim() || undefined,
556 textBody: editText.trim() || undefined,
557 });
558 onUpdated(res.data);
559 } catch (err) {
560 setEditError(
561 err instanceof Error ? err.message : "Failed to update template",
562 );
563 } finally {
564 setSaving(false);
565 }
566 };
567
568 return (
569 <Box className="border-t border-border px-4 pb-4 pt-3">
570 {editError && (
571 <Box className="mb-3 rounded border border-red-200 bg-red-50 p-2">
572 <Text variant="body-sm" className="text-red-800">
573 {editError}
574 </Text>
575 </Box>
576 )}
577
578 <Box className="space-y-3">
579 <Box className="grid grid-cols-1 gap-3 md:grid-cols-2">
580 <Input
581 label="Name"
582 variant="text"
583 value={editName}
584 onChange={(e) => setEditName(e.target.value)}
585 />
586 <Input
587 label="Subject"
588 variant="text"
589 value={editSubject}
590 onChange={(e) => setEditSubject(e.target.value)}
591 />
592 </Box>
593
594 <Box>
595 <Text variant="body-sm" className="mb-1 font-medium text-content">
596 HTML Body
597 </Text>
598 <textarea
599 className="w-full rounded-md border border-border bg-surface p-3 font-mono text-sm text-content placeholder:text-content-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
600 rows={5}
601 value={editHtml}
602 onChange={(e) => setEditHtml(e.target.value)}
603 />
604 </Box>
605
606 <Box>
607 <Text variant="body-sm" className="mb-1 font-medium text-content">
608 Plain Text Body
609 </Text>
610 <textarea
611 className="w-full rounded-md border border-border bg-surface p-3 font-mono text-sm text-content placeholder:text-content-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
612 rows={3}
613 value={editText}
614 onChange={(e) => setEditText(e.target.value)}
615 />
616 </Box>
617
618 {currentVariables.length > 0 && (
619 <Box>
620 <Text variant="body-xs" muted className="mb-1.5">
621 Variables in template:
622 </Text>
623 <Box className="flex flex-wrap gap-1.5">
624 {currentVariables.map((v) => (
625 <Box
626 key={v}
627 className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5"
628 >
629 <Text variant="body-xs" className="text-accent font-medium">
630 {`{{${v}}}`}
631 </Text>
632 </Box>
633 ))}
634 </Box>
635 </Box>
636 )}
637
638 <Box className="flex items-center justify-between pt-2">
639 <Box className="flex items-center gap-2">
640 <Button
641 variant="primary"
642 size="sm"
643 onClick={handleSave}
644 disabled={saving || !isDirty}
645 >
646 {saving ? "Saving..." : "Save Changes"}
647 </Button>
648 <Button variant="secondary" size="sm" onClick={onPreview}>
649 Preview
650 </Button>
651 </Box>
652
653 <Box>
654 {deleteConfirm ? (
655 <Box className="flex items-center gap-2">
656 <Text variant="body-sm" className="text-red-600">
657 Confirm delete?
658 </Text>
659 <Button
660 variant="ghost"
661 size="sm"
662 onClick={onDeleteConfirm}
663 className="text-red-600 hover:bg-red-50"
664 >
665 Yes, Delete
666 </Button>
667 <Button variant="ghost" size="sm" onClick={onDeleteCancel}>
668 Cancel
669 </Button>
670 </Box>
671 ) : (
672 <Button
673 variant="ghost"
674 size="sm"
675 onClick={onDeleteRequest}
676 className="text-red-600 hover:bg-red-50"
677 >
678 Delete
679 </Button>
680 )}
681 </Box>
682 </Box>
683 </Box>
684 </Box>
685 );
686}
687
688TemplateEditSection.displayName = "TemplateEditSection";
689
690// ─── Template Preview Panel ───────────────────────────────────────────────────
691
692function TemplatePreviewPanel({
693 templateId,
694 template,
695 onClose,
696}: {
697 templateId: string;
698 template: Template | null;
699 onClose: () => void;
700}): React.ReactNode {
701 const reduced = useAlecRaeReducedMotion();
702 const panelVariants = withReducedMotion(fadeInUp, reduced);
703
704 const variables = useMemo(
705 () => (template ? getTemplateVariables(template) : []),
706 [template],
707 );
708
709 const [values, setValues] = useState<Record<string, string>>({});
710 const [rendered, setRendered] = useState<TemplateRenderResult | null>(null);
711 const [rendering, setRendering] = useState(false);
712 const [renderError, setRenderError] = useState<string | null>(null);
713
714 useEffect(() => {
715 const initial: Record<string, string> = {};
716 for (const v of variables) {
717 initial[v] = "";
718 }
719 setValues(initial);
720 setRendered(null);
721 }, [variables]);
722
723 const handleRender = async () => {
724 setRendering(true);
725 setRenderError(null);
726
727 try {
728 const res = await templatesApi.render(templateId, values);
729 setRendered(res.data);
730 } catch (err) {
731 setRenderError(
732 err instanceof Error ? err.message : "Failed to render template",
733 );
734 } finally {
735 setRendering(false);
736 }
737 };
738
739 return (
740 <motion.div
741 variants={panelVariants}
742 initial="initial"
743 animate="animate"
744 exit="exit"
745 className="fixed inset-0 z-50 flex items-center justify-center p-4"
746 >
747 <Box
748 className="absolute inset-0 bg-black/40"
749 onClick={onClose}
750 />
751 <Box className="relative z-10 w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-xl border border-border bg-surface shadow-2xl">
752 <Box className="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-surface px-6 py-4">
753 <Text variant="heading-sm">
754 Preview: {template?.name ?? "Template"}
755 </Text>
756 <Button variant="ghost" size="sm" onClick={onClose}>
757 Close
758 </Button>
759 </Box>
760
761 <Box className="p-6 space-y-5">
762 {variables.length > 0 ? (
763 <Box className="space-y-3">
764 <Text variant="body-sm" className="font-medium text-content">
765 Fill in variable values:
766 </Text>
767 <Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
768 {variables.map((v) => (
769 <Input
770 key={v}
771 label={v}
772 variant="text"
773 placeholder={`Value for {{${v}}}`}
774 value={values[v] ?? ""}
775 onChange={(e) =>
776 setValues({ ...values, [v]: e.target.value })
777 }
778 />
779 ))}
780 </Box>
781 <Button
782 variant="primary"
783 size="sm"
784 onClick={handleRender}
785 disabled={rendering}
786 >
787 {rendering ? "Rendering..." : "Render Preview"}
788 </Button>
789 </Box>
790 ) : (
791 <Box>
792 <Text variant="body-sm" muted>
793 This template has no variables. Click render to see the output.
794 </Text>
795 <Box className="mt-3">
796 <Button
797 variant="primary"
798 size="sm"
799 onClick={handleRender}
800 disabled={rendering}
801 >
802 {rendering ? "Rendering..." : "Render Preview"}
803 </Button>
804 </Box>
805 </Box>
806 )}
807
808 {renderError && (
809 <Box className="rounded border border-red-200 bg-red-50 p-3">
810 <Text variant="body-sm" className="text-red-800">
811 {renderError}
812 </Text>
813 </Box>
814 )}
815
816 {rendered && (
817 <Box className="space-y-4">
818 <Box>
819 <Text variant="body-xs" muted className="mb-1">
820 Rendered Subject
821 </Text>
822 <Box className="rounded-md border border-border bg-surface-secondary p-3">
823 <Text variant="body-md" className="font-medium text-content">
824 {rendered.subject}
825 </Text>
826 </Box>
827 </Box>
828
829 {rendered.htmlBody && (
830 <Box>
831 <Text variant="body-xs" muted className="mb-1">
832 Rendered HTML
833 </Text>
834 <Box className="max-h-64 overflow-y-auto rounded-md border border-border bg-white p-4">
835 <div
836 className="prose prose-sm max-w-none text-content"
837 dangerouslySetInnerHTML={{ __html: rendered.htmlBody }}
838 />
839 </Box>
840 </Box>
841 )}
842
843 {rendered.textBody && (
844 <Box>
845 <Text variant="body-xs" muted className="mb-1">
846 Rendered Plain Text
847 </Text>
848 <Box className="rounded-md border border-border bg-surface-secondary p-3">
849 <Text
850 variant="body-sm"
851 className="whitespace-pre-wrap font-mono text-content"
852 >
853 {rendered.textBody}
854 </Text>
855 </Box>
856 </Box>
857 )}
858 </Box>
859 )}
860 </Box>
861 </Box>
862 </motion.div>
863 );
864}
865
866TemplatePreviewPanel.displayName = "TemplatePreviewPanel";
Modifiedapps/web/app/globals.css+28−0View fileUnifiedSplit
@@ -1,3 +1,31 @@
11@tailwind base;
22@tailwind components;
33@tailwind utilities;
4
5html {
6 scroll-behavior: smooth;
7}
8
9@layer utilities {
10 .text-balance {
11 text-wrap: balance;
12 }
13 .gradient-text {
14 background-clip: text;
15 -webkit-background-clip: text;
16 -webkit-text-fill-color: transparent;
17 }
18}
19
20@keyframes float {
21 0%, 100% { transform: translateY(0px); }
22 50% { transform: translateY(-20px); }
23}
24@keyframes shimmer {
25 0% { background-position: -200% 0; }
26 100% { background-position: 200% 0; }
27}
28@keyframes glow-pulse {
29 0%, 100% { opacity: 0.15; }
30 50% { opacity: 0.3; }
31}
Addedapps/web/components/BatchActionBar.tsx+114−0View fileUnifiedSplit
@@ -0,0 +1,114 @@
1"use client";
2
3import { motion } from "motion/react";
4import { SPRING_BOUNCY, useAlecRaeReducedMotion } from "../lib/animations";
5
6export interface BatchActionBarProps {
7 selectedCount: number;
8 totalCount: number;
9 onSelectAll: () => void;
10 onDeselectAll: () => void;
11 onArchive: () => void;
12 onDelete: () => void;
13 onMarkRead: () => void;
14 onMarkUnread: () => void;
15 onStar: () => void;
16}
17
18export function BatchActionBar({
19 selectedCount,
20 totalCount,
21 onSelectAll,
22 onDeselectAll,
23 onArchive,
24 onDelete,
25 onMarkRead,
26 onMarkUnread,
27 onStar,
28}: BatchActionBarProps): React.ReactNode {
29 const reduced = useAlecRaeReducedMotion();
30
31 if (selectedCount === 0) return null;
32
33 return (
34 <motion.div
35 initial={reduced ? { opacity: 0 } : { opacity: 0, y: -8 }}
36 animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0 }}
37 exit={reduced ? { opacity: 0 } : { opacity: 0, y: -8 }}
38 transition={SPRING_BOUNCY}
39 className="flex items-center gap-2 px-4 py-2 bg-brand-50 border-b border-brand-200"
40 role="toolbar"
41 aria-label="Batch email actions"
42 >
43 <span className="text-sm font-medium text-brand-700">
44 {selectedCount} selected
45 </span>
46
47 {selectedCount < totalCount ? (
48 <button
49 type="button"
50 onClick={onSelectAll}
51 className="text-xs text-brand-600 hover:text-brand-800 font-medium transition-colors"
52 >
53 Select all {totalCount}
54 </button>
55 ) : (
56 <button
57 type="button"
58 onClick={onDeselectAll}
59 className="text-xs text-brand-600 hover:text-brand-800 font-medium transition-colors"
60 >
61 Deselect all
62 </button>
63 )}
64
65 <div className="w-px h-4 bg-brand-200 mx-1" />
66
67 <ActionButton label="Archive" onClick={onArchive} icon="M5 8l4 4 4-4" />
68 <ActionButton label="Delete" onClick={onDelete} icon="M6 6l8 8M6 14l8-8" danger />
69 <ActionButton label="Read" onClick={onMarkRead} icon="M3 8l4 4 8-8" />
70 <ActionButton label="Unread" onClick={onMarkUnread} icon="M12 4a8 8 0 100 16 8 8 0 000-16z" />
71 <ActionButton label="Star" onClick={onStar} icon="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 5.82 22 7 14.14l-5-4.87 6.91-1.01z" />
72 </motion.div>
73 );
74}
75
76function ActionButton({
77 label,
78 onClick,
79 icon,
80 danger = false,
81}: {
82 label: string;
83 onClick: () => void;
84 icon: string;
85 danger?: boolean;
86}): React.ReactNode {
87 return (
88 <button
89 type="button"
90 onClick={onClick}
91 className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors ${
92 danger
93 ? "text-red-600 hover:bg-red-50 hover:text-red-700"
94 : "text-content-secondary hover:bg-surface hover:text-content"
95 }`}
96 aria-label={label}
97 >
98 <svg
99 width="14"
100 height="14"
101 viewBox="0 0 24 24"
102 fill="none"
103 stroke="currentColor"
104 strokeWidth="2"
105 strokeLinecap="round"
106 strokeLinejoin="round"
107 aria-hidden="true"
108 >
109 <path d={icon} />
110 </svg>
111 {label}
112 </button>
113 );
114}
Addedapps/web/components/CommandPalette.tsx+257−0View fileUnifiedSplit
@@ -0,0 +1,257 @@
1"use client";
2
3import { useState, useEffect, useRef, useCallback, useMemo } from "react";
4import { useRouter } from "next/navigation";
5import { AnimatePresence, motion } from "motion/react";
6import { SPRING_BOUNCY, useAlecRaeReducedMotion } from "../lib/animations";
7
8interface CommandItem {
9 id: string;
10 label: string;
11 description?: string;
12 category: string;
13 shortcut?: string;
14 action: () => void;
15}
16
17const CATEGORIES: Record<string, string> = {
18 navigation: "Go to",
19 actions: "Actions",
20 compose: "Compose",
21 ai: "AI",
22 search: "Search",
23};
24
25function getDefaultCommands(router: ReturnType<typeof useRouter>): CommandItem[] {
26 return [
27 { id: "inbox", label: "Inbox", category: "navigation", shortcut: "G I", action: () => router.push("/inbox") },
28 { id: "compose", label: "Compose New Email", category: "compose", shortcut: "C", action: () => router.push("/compose") },
29 { id: "sent", label: "Sent Mail", category: "navigation", shortcut: "G S", action: () => router.push("/sent") },
30 { id: "drafts", label: "Drafts", category: "navigation", shortcut: "G D", action: () => router.push("/drafts") },
31 { id: "snoozed", label: "Snoozed", category: "navigation", action: () => router.push("/snoozed") },
32 { id: "contacts", label: "Contacts", category: "navigation", action: () => router.push("/contacts") },
33 { id: "templates", label: "Templates", category: "navigation", action: () => router.push("/templates") },
34 { id: "analytics", label: "Analytics", category: "navigation", action: () => router.push("/analytics") },
35 { id: "domains", label: "Domains", category: "navigation", action: () => router.push("/domains") },
36 { id: "settings", label: "Settings", category: "navigation", shortcut: "G ,", action: () => router.push("/settings") },
37 { id: "ai-compose", label: "AI Compose", description: "Let AI write an email for you", category: "ai", shortcut: "Cmd+Shift+C", action: () => router.push("/compose") },
38 { id: "search", label: "Search Emails", description: "Full-text search across all emails", category: "search", shortcut: "/", action: () => { router.push("/inbox"); setTimeout(() => document.querySelector<HTMLInputElement>('input[type="search"], input[placeholder*="Search"]')?.focus(), 100); } },
39 { id: "dark-mode", label: "Toggle Dark Mode", category: "actions", shortcut: "Cmd+Shift+D", action: () => document.documentElement.classList.toggle("dark") },
40 ];
41}
42
43export function CommandPalette(): React.ReactNode {
44 const router = useRouter();
45 const reduced = useAlecRaeReducedMotion();
46 const [open, setOpen] = useState(false);
47 const [query, setQuery] = useState("");
48 const [activeIndex, setActiveIndex] = useState(0);
49 const inputRef = useRef<HTMLInputElement>(null);
50 const listRef = useRef<HTMLDivElement>(null);
51
52 const commands = useMemo(() => getDefaultCommands(router), [router]);
53
54 const filtered = useMemo(() => {
55 if (!query.trim()) return commands;
56 const lower = query.toLowerCase();
57 return commands.filter(
58 (cmd) =>
59 cmd.label.toLowerCase().includes(lower) ||
60 cmd.category.toLowerCase().includes(lower) ||
61 cmd.description?.toLowerCase().includes(lower),
62 );
63 }, [commands, query]);
64
65 const grouped = useMemo(() => {
66 const groups: Record<string, CommandItem[]> = {};
67 for (const item of filtered) {
68 if (!groups[item.category]) groups[item.category] = [];
69 groups[item.category]!.push(item);
70 }
71 return groups;
72 }, [filtered]);
73
74 const flatItems = useMemo(() => {
75 const items: CommandItem[] = [];
76 for (const category of Object.keys(grouped)) {
77 items.push(...grouped[category]!);
78 }
79 return items;
80 }, [grouped]);
81
82 useEffect(() => {
83 const handleKey = (e: KeyboardEvent): void => {
84 if ((e.metaKey || e.ctrlKey) && e.key === "k") {
85 e.preventDefault();
86 setOpen((prev) => !prev);
87 setQuery("");
88 setActiveIndex(0);
89 }
90 };
91 window.addEventListener("keydown", handleKey);
92 return () => window.removeEventListener("keydown", handleKey);
93 }, []);
94
95 useEffect(() => {
96 if (open) {
97 setTimeout(() => inputRef.current?.focus(), 50);
98 }
99 }, [open]);
100
101 useEffect(() => {
102 setActiveIndex(0);
103 }, [query]);
104
105 const runCommand = useCallback(
106 (item: CommandItem) => {
107 setOpen(false);
108 setQuery("");
109 item.action();
110 },
111 [],
112 );
113
114 const handleKeyDown = (e: React.KeyboardEvent): void => {
115 if (e.key === "ArrowDown") {
116 e.preventDefault();
117 setActiveIndex((prev) => Math.min(prev + 1, flatItems.length - 1));
118 } else if (e.key === "ArrowUp") {
119 e.preventDefault();
120 setActiveIndex((prev) => Math.max(prev - 1, 0));
121 } else if (e.key === "Enter") {
122 e.preventDefault();
123 const item = flatItems[activeIndex];
124 if (item) runCommand(item);
125 } else if (e.key === "Escape") {
126 setOpen(false);
127 }
128 };
129
130 useEffect(() => {
131 const active = listRef.current?.querySelector('[data-active="true"]');
132 active?.scrollIntoView({ block: "nearest" });
133 }, [activeIndex]);
134
135 return (
136 <AnimatePresence>
137 {open && (
138 <>
139 <motion.div
140 initial={{ opacity: 0 }}
141 animate={{ opacity: 1 }}
142 exit={{ opacity: 0 }}
143 className="fixed inset-0 z-[400] bg-black/40 backdrop-blur-sm"
144 onClick={() => setOpen(false)}
145 />
146 <motion.div
147 initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: -20 }}
148 animate={reduced ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
149 exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: -20 }}
150 transition={SPRING_BOUNCY}
151 className="fixed top-[20%] left-1/2 -translate-x-1/2 z-[401] w-[560px] bg-surface rounded-2xl border border-border shadow-2xl overflow-hidden"
152 role="dialog"
153 aria-label="Command palette"
154 >
155 <div className="flex items-center gap-3 px-4 py-3 border-b border-border">
156 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-content-tertiary flex-shrink-0" aria-hidden="true">
157 <circle cx="11" cy="11" r="8" />
158 <path d="M21 21l-4.35-4.35" />
159 </svg>
160 <input
161 ref={inputRef}
162 type="text"
163 value={query}
164 onChange={(e) => setQuery(e.target.value)}
165 onKeyDown={handleKeyDown}
166 placeholder="Type a command or search..."
167 className="flex-1 text-sm bg-transparent text-content placeholder:text-content-tertiary focus:outline-none"
168 role="combobox"
169 aria-expanded="true"
170 aria-autocomplete="list"
171 />
172 <kbd className="px-1.5 py-0.5 text-xs text-content-tertiary bg-surface-secondary border border-border rounded">
173 Esc
174 </kbd>
175 </div>
176
177 <div ref={listRef} className="max-h-[360px] overflow-y-auto py-2" role="listbox">
178 {flatItems.length === 0 ? (
179 <div className="px-4 py-8 text-center">
180 <p className="text-sm text-content-tertiary">
181 No commands found for "{query}"
182 </p>
183 </div>
184 ) : (
185 Object.entries(grouped).map(([category, items]) => (
186 <div key={category}>
187 <div className="px-4 py-1.5">
188 <p className="text-xs font-semibold text-content-tertiary uppercase tracking-wider">
189 {CATEGORIES[category] ?? category}
190 </p>
191 </div>
192 {items.map((item) => {
193 const globalIdx = flatItems.indexOf(item);
194 const isActive = globalIdx === activeIndex;
195 return (
196 <button
197 key={item.id}
198 type="button"
199 data-active={isActive}
200 onClick={() => runCommand(item)}
201 onMouseEnter={() => setActiveIndex(globalIdx)}
202 className={`w-full text-left flex items-center justify-between px-4 py-2.5 transition-colors ${
203 isActive ? "bg-brand-50 text-brand-700" : "text-content hover:bg-surface-secondary"
204 }`}
205 role="option"
206 aria-selected={isActive}
207 >
208 <div className="flex-1 min-w-0">
209 <p className="text-sm font-medium truncate">{item.label}</p>
210 {item.description && (
211 <p className="text-xs text-content-tertiary truncate mt-0.5">
212 {item.description}
213 </p>
214 )}
215 </div>
216 {item.shortcut && (
217 <span className="flex-shrink-0 ml-4 text-xs text-content-tertiary">
218 {item.shortcut.split("+").map((key, i) => (
219 <kbd
220 key={i}
221 className="inline-flex items-center justify-center min-w-[20px] h-5 px-1 text-xs bg-surface-secondary border border-border rounded shadow-sm ml-0.5"
222 >
223 {key === "Cmd" && typeof navigator !== "undefined" && !/Mac/.test(navigator.platform) ? "Ctrl" : key}
224 </kbd>
225 ))}
226 </span>
227 )}
228 </button>
229 );
230 })}
231 </div>
232 ))
233 )}
234 </div>
235
236 <div className="px-4 py-2 border-t border-border bg-surface-secondary/50 flex items-center justify-between">
237 <div className="flex items-center gap-3 text-xs text-content-tertiary">
238 <span className="flex items-center gap-1">
239 <kbd className="px-1 py-0.5 bg-surface border border-border rounded text-xs shadow-sm">↑</kbd>
240 <kbd className="px-1 py-0.5 bg-surface border border-border rounded text-xs shadow-sm">↓</kbd>
241 navigate
242 </span>
243 <span className="flex items-center gap-1">
244 <kbd className="px-1.5 py-0.5 bg-surface border border-border rounded text-xs shadow-sm">⏎</kbd>
245 select
246 </span>
247 </div>
248 <p className="text-xs text-content-tertiary">
249 {flatItems.length} command{flatItems.length !== 1 ? "s" : ""}
250 </p>
251 </div>
252 </motion.div>
253 </>
254 )}
255 </AnimatePresence>
256 );
257}
Addedapps/web/components/InstallPrompt.tsx+107−0View fileUnifiedSplit
@@ -0,0 +1,107 @@
1"use client";
2
3import { useState, useEffect, useRef, useCallback } from "react";
4import { AnimatePresence, motion } from "motion/react";
5import { SPRING_BOUNCY, useAlecRaeReducedMotion } from "../lib/animations";
6
7interface BeforeInstallPromptEvent extends Event {
8 prompt: () => Promise<void>;
9 userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
10}
11
12const DISMISSED_KEY = "alecrae_install_dismissed";
13const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
14
15export function InstallPrompt(): React.ReactNode {
16 const reduced = useAlecRaeReducedMotion();
17 const [canInstall, setCanInstall] = useState(false);
18 const [isInstalled, setIsInstalled] = useState(false);
19 const deferredPromptRef = useRef<BeforeInstallPromptEvent | null>(null);
20
21 useEffect(() => {
22 if (typeof window === "undefined") return;
23
24 const isStandalone =
25 window.matchMedia("(display-mode: standalone)").matches ||
26 (navigator as unknown as { standalone?: boolean }).standalone === true;
27 setIsInstalled(isStandalone);
28
29 const dismissed = localStorage.getItem(DISMISSED_KEY);
30 if (dismissed && Date.now() - Number(dismissed) < DISMISS_DURATION_MS) return;
31
32 const handler = (e: Event): void => {
33 e.preventDefault();
34 deferredPromptRef.current = e as BeforeInstallPromptEvent;
35 setCanInstall(true);
36 };
37
38 window.addEventListener("beforeinstallprompt", handler);
39 return () => window.removeEventListener("beforeinstallprompt", handler);
40 }, []);
41
42 const handleInstall = useCallback(async (): Promise<void> => {
43 const prompt = deferredPromptRef.current;
44 if (!prompt) return;
45
46 await prompt.prompt();
47 const { outcome } = await prompt.userChoice;
48 if (outcome === "accepted") {
49 setIsInstalled(true);
50 setCanInstall(false);
51 }
52 deferredPromptRef.current = null;
53 }, []);
54
55 const handleDismiss = useCallback((): void => {
56 setCanInstall(false);
57 localStorage.setItem(DISMISSED_KEY, String(Date.now()));
58 }, []);
59
60 if (isInstalled || !canInstall) return null;
61
62 return (
63 <AnimatePresence>
64 <motion.div
65 initial={reduced ? { opacity: 0 } : { opacity: 0, y: 20, scale: 0.95 }}
66 animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
67 exit={reduced ? { opacity: 0 } : { opacity: 0, y: 20, scale: 0.95 }}
68 transition={SPRING_BOUNCY}
69 className="fixed bottom-6 right-6 z-[100] w-80 bg-surface rounded-2xl border border-border shadow-2xl overflow-hidden"
70 role="alert"
71 >
72 <div className="p-5">
73 <div className="flex items-start gap-3">
74 <div className="w-10 h-10 rounded-xl bg-brand-100 flex items-center justify-center flex-shrink-0">
75 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-brand-600" aria-hidden="true">
76 <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" strokeLinecap="round" strokeLinejoin="round" />
77 </svg>
78 </div>
79 <div className="flex-1">
80 <p className="text-sm font-semibold text-content">Install AlecRae</p>
81 <p className="text-xs text-content-secondary mt-1">
82 Get faster access, offline support, and desktop notifications. Works like a native app.
83 </p>
84 </div>
85 </div>
86
87 <div className="flex items-center gap-2 mt-4">
88 <button
89 type="button"
90 onClick={() => void handleInstall()}
91 className="flex-1 px-4 py-2 text-sm font-semibold text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors"
92 >
93 Install
94 </button>
95 <button
96 type="button"
97 onClick={handleDismiss}
98 className="px-4 py-2 text-sm text-content-secondary hover:text-content transition-colors"
99 >
100 Not now
101 </button>
102 </div>
103 </div>
104 </motion.div>
105 </AnimatePresence>
106 );
107}
Addedapps/web/components/KeyboardShortcutHelp.tsx+184−0View fileUnifiedSplit
@@ -0,0 +1,184 @@
1"use client";
2
3import { useEffect, useState } from "react";
4import { AnimatePresence, motion } from "motion/react";
5import { SPRING_BOUNCY, useAlecRaeReducedMotion } from "../lib/animations";
6
7interface ShortcutEntry {
8 label: string;
9 keys: string;
10}
11
12interface ShortcutGroup {
13 title: string;
14 shortcuts: ShortcutEntry[];
15}
16
17const SHORTCUT_GROUPS: ShortcutGroup[] = [
18 {
19 title: "Navigation",
20 shortcuts: [
21 { label: "Command palette", keys: "Cmd+K" },
22 { label: "Search emails", keys: "/" },
23 { label: "Go to Inbox", keys: "G then I" },
24 { label: "Go to Sent", keys: "G then S" },
25 { label: "Go to Drafts", keys: "G then D" },
26 { label: "Go to Settings", keys: "G then ," },
27 { label: "Next email", keys: "J / Arrow Down" },
28 { label: "Previous email", keys: "K / Arrow Up" },
29 { label: "Open email", keys: "Enter / O" },
30 ],
31 },
32 {
33 title: "Actions",
34 shortcuts: [
35 { label: "Compose new email", keys: "C" },
36 { label: "Archive", keys: "E" },
37 { label: "Delete", keys: "#" },
38 { label: "Star / unstar", keys: "S" },
39 { label: "Snooze", keys: "B" },
40 { label: "Mark read", keys: "Shift+I" },
41 { label: "Mark unread", keys: "Shift+U" },
42 { label: "Undo last action", keys: "Cmd+Z" },
43 ],
44 },
45 {
46 title: "Compose",
47 shortcuts: [
48 { label: "Reply", keys: "R" },
49 { label: "Reply all", keys: "A" },
50 { label: "Forward", keys: "F" },
51 { label: "Send email", keys: "Cmd+Enter" },
52 ],
53 },
54 {
55 title: "AI Features",
56 shortcuts: [
57 { label: "AI compose", keys: "Cmd+Shift+C" },
58 { label: "AI reply", keys: "Cmd+Shift+R" },
59 { label: "AI summarize", keys: "Cmd+Shift+S" },
60 ],
61 },
62 {
63 title: "View",
64 shortcuts: [
65 { label: "Toggle dark mode", keys: "Cmd+Shift+D" },
66 { label: "Toggle focus mode", keys: "Cmd+Shift+F" },
67 { label: "Show shortcuts", keys: "?" },
68 ],
69 },
70];
71
72function KeyCombo({ keys }: { keys: string }): React.ReactNode {
73 const parts = keys.split(/(\+| \/ | then )/);
74
75 return (
76 <span className="flex items-center gap-0.5 flex-shrink-0">
77 {parts.map((part, i) => {
78 if (part === "+" || part === " / " || part === " then ") {
79 return (
80 <span key={i} className="text-xs text-content-tertiary mx-0.5">
81 {part.trim() === "+" ? "+" : part.trim() === "/" ? "or" : "then"}
82 </span>
83 );
84 }
85 return (
86 <kbd
87 key={i}
88 className="inline-flex items-center justify-center min-w-[24px] h-6 px-1.5 text-xs font-medium text-content bg-surface-secondary border border-border rounded shadow-sm"
89 >
90 {part === "Cmd" && typeof navigator !== "undefined" && !/Mac/.test(navigator.platform) ? "Ctrl" : part}
91 </kbd>
92 );
93 })}
94 </span>
95 );
96}
97
98export function KeyboardShortcutHelp(): React.ReactNode {
99 const [open, setOpen] = useState(false);
100 const reduced = useAlecRaeReducedMotion();
101
102 useEffect(() => {
103 const handleKey = (e: KeyboardEvent): void => {
104 if (e.key === "?" && !e.metaKey && !e.ctrlKey && !e.altKey) {
105 const target = e.target as HTMLElement;
106 if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return;
107 e.preventDefault();
108 setOpen((prev) => !prev);
109 }
110 if (e.key === "Escape" && open) {
111 setOpen(false);
112 }
113 };
114
115 window.addEventListener("keydown", handleKey);
116 return () => window.removeEventListener("keydown", handleKey);
117 }, [open]);
118
119 return (
120 <AnimatePresence>
121 {open && (
122 <>
123 <motion.div
124 initial={{ opacity: 0 }}
125 animate={{ opacity: 1 }}
126 exit={{ opacity: 0 }}
127 className="fixed inset-0 z-[300] bg-black/40 backdrop-blur-sm"
128 onClick={() => setOpen(false)}
129 />
130 <motion.div
131 initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 20 }}
132 animate={reduced ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
133 exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 20 }}
134 transition={SPRING_BOUNCY}
135 className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[301] w-[640px] max-h-[80vh] bg-surface rounded-2xl border border-border shadow-2xl overflow-hidden"
136 role="dialog"
137 aria-label="Keyboard shortcuts"
138 >
139 <div className="flex items-center justify-between px-6 py-4 border-b border-border">
140 <h2 className="text-lg font-semibold text-content">Keyboard Shortcuts</h2>
141 <button
142 type="button"
143 onClick={() => setOpen(false)}
144 className="w-8 h-8 flex items-center justify-center text-content-tertiary hover:text-content rounded-lg transition-colors"
145 aria-label="Close"
146 >
147 ✕
148 </button>
149 </div>
150
151 <div className="overflow-y-auto max-h-[calc(80vh-64px)] p-6">
152 <div className="grid grid-cols-2 gap-8">
153 {SHORTCUT_GROUPS.map((group) => (
154 <div key={group.title}>
155 <h3 className="text-xs font-semibold text-content-secondary uppercase tracking-wider mb-3">
156 {group.title}
157 </h3>
158 <div className="space-y-2">
159 {group.shortcuts.map((shortcut) => (
160 <div
161 key={shortcut.label}
162 className="flex items-center justify-between py-1"
163 >
164 <span className="text-sm text-content">{shortcut.label}</span>
165 <KeyCombo keys={shortcut.keys} />
166 </div>
167 ))}
168 </div>
169 </div>
170 ))}
171 </div>
172 </div>
173
174 <div className="px-6 py-3 border-t border-border bg-surface-secondary/50">
175 <p className="text-xs text-content-tertiary text-center">
176 Press <kbd className="px-1.5 py-0.5 text-xs bg-surface border border-border rounded shadow-sm">?</kbd> to toggle this panel
177 </p>
178 </div>
179 </motion.div>
180 </>
181 )}
182 </AnimatePresence>
183 );
184}
Addedapps/web/components/NewEmailNotification.tsx+179−0View fileUnifiedSplit
@@ -0,0 +1,179 @@
1"use client";
2
3import { useEffect, useCallback, useRef } from "react";
4
5export interface NewEmailData {
6 id: string;
7 from: string;
8 subject: string;
9 preview: string;
10}
11
12export interface NewEmailNotificationProps {
13 enabled: boolean;
14 onEmailClick?: (emailId: string) => void;
15}
16
17export function useNewEmailNotifications({
18 enabled,
19 onEmailClick,
20}: NewEmailNotificationProps): {
21 notifyNewEmail: (email: NewEmailData) => void;
22 requestPermission: () => Promise<boolean>;
23 permissionState: NotificationPermission | "unsupported";
24} {
25 const permissionRef = useRef<NotificationPermission | "unsupported">("default");
26
27 useEffect(() => {
28 if (typeof window === "undefined" || !("Notification" in window)) {
29 permissionRef.current = "unsupported";
30 return;
31 }
32 permissionRef.current = Notification.permission;
33 }, []);
34
35 const requestPermission = useCallback(async (): Promise<boolean> => {
36 if (typeof window === "undefined" || !("Notification" in window)) return false;
37 const result = await Notification.requestPermission();
38 permissionRef.current = result;
39 return result === "granted";
40 }, []);
41
42 const notifyNewEmail = useCallback(
43 (email: NewEmailData): void => {
44 if (!enabled) return;
45 if (typeof window === "undefined" || !("Notification" in window)) return;
46 if (Notification.permission !== "granted") return;
47 if (document.hasFocus()) return;
48
49 const notification = new Notification(email.from, {
50 body: `${email.subject}\n${email.preview.slice(0, 100)}`,
51 icon: "/icon-192.png",
52 tag: `email-${email.id}`,
53 renotify: true,
54 silent: false,
55 });
56
57 notification.onclick = (): void => {
58 window.focus();
59 onEmailClick?.(email.id);
60 notification.close();
61 };
62
63 setTimeout(() => notification.close(), 8000);
64 },
65 [enabled, onEmailClick],
66 );
67
68 return {
69 notifyNewEmail,
70 requestPermission,
71 permissionState: permissionRef.current,
72 };
73}
74
75export function usePageTitleNotification(): {
76 setUnreadCount: (count: number) => void;
77 clearNotification: () => void;
78} {
79 const originalTitleRef = useRef<string>("");
80 const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
81
82 useEffect(() => {
83 if (typeof document !== "undefined") {
84 originalTitleRef.current = document.title;
85 }
86 return () => {
87 if (intervalRef.current) clearInterval(intervalRef.current);
88 if (typeof document !== "undefined") {
89 document.title = originalTitleRef.current;
90 }
91 };
92 }, []);
93
94 const setUnreadCount = useCallback((count: number): void => {
95 if (typeof document === "undefined") return;
96 if (!originalTitleRef.current) {
97 originalTitleRef.current = document.title.replace(/^\(\d+\)\s*/, "");
98 }
99 if (count > 0) {
100 document.title = `(${count}) ${originalTitleRef.current}`;
101 } else {
102 document.title = originalTitleRef.current;
103 }
104 }, []);
105
106 const clearNotification = useCallback((): void => {
107 if (typeof document === "undefined") return;
108 if (intervalRef.current) {
109 clearInterval(intervalRef.current);
110 intervalRef.current = null;
111 }
112 document.title = originalTitleRef.current;
113 }, []);
114
115 return { setUnreadCount, clearNotification };
116}
117
118export function useFaviconBadge(): {
119 showBadge: (count: number) => void;
120 clearBadge: () => void;
121} {
122 const canvasRef = useRef<HTMLCanvasElement | null>(null);
123 const originalFaviconRef = useRef<string>("");
124
125 useEffect(() => {
126 if (typeof document === "undefined") return;
127 const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
128 if (link) originalFaviconRef.current = link.href;
129 }, []);
130
131 const showBadge = useCallback((count: number): void => {
132 if (typeof document === "undefined") return;
133 if (count <= 0) {
134 const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
135 if (link && originalFaviconRef.current) link.href = originalFaviconRef.current;
136 return;
137 }
138
139 if (!canvasRef.current) {
140 canvasRef.current = document.createElement("canvas");
141 canvasRef.current.width = 32;
142 canvasRef.current.height = 32;
143 }
144
145 const canvas = canvasRef.current;
146 const ctx = canvas.getContext("2d");
147 if (!ctx) return;
148
149 const img = new Image();
150 img.crossOrigin = "anonymous";
151 img.onload = (): void => {
152 ctx.clearRect(0, 0, 32, 32);
153 ctx.drawImage(img, 0, 0, 32, 32);
154
155 ctx.fillStyle = "#ef4444";
156 ctx.beginPath();
157 ctx.arc(24, 8, 8, 0, 2 * Math.PI);
158 ctx.fill();
159
160 ctx.fillStyle = "#ffffff";
161 ctx.font = "bold 10px sans-serif";
162 ctx.textAlign = "center";
163 ctx.textBaseline = "middle";
164 ctx.fillText(count > 99 ? "99+" : String(count), 24, 8);
165
166 const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
167 if (link) link.href = canvas.toDataURL("image/png");
168 };
169 img.src = originalFaviconRef.current || "/favicon.ico";
170 }, []);
171
172 const clearBadge = useCallback((): void => {
173 if (typeof document === "undefined") return;
174 const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
175 if (link && originalFaviconRef.current) link.href = originalFaviconRef.current;
176 }, []);
177
178 return { showBadge, clearBadge };
179}
Addedapps/web/components/OfflineComposeBanner.tsx+72−0View fileUnifiedSplit
@@ -0,0 +1,72 @@
1"use client";
2
3import { useState, useEffect } from "react";
4import { AnimatePresence, motion } from "motion/react";
5import { useAlecRaeReducedMotion } from "../lib/animations";
6
7export function OfflineComposeBanner(): React.ReactNode {
8 const reduced = useAlecRaeReducedMotion();
9 const [isOnline, setIsOnline] = useState(true);
10 const [dismissed, setDismissed] = useState(false);
11
12 useEffect(() => {
13 if (typeof window === "undefined") return;
14 setIsOnline(navigator.onLine);
15 const handleOnline = (): void => { setIsOnline(true); setDismissed(false); };
16 const handleOffline = (): void => { setIsOnline(false); setDismissed(false); };
17 window.addEventListener("online", handleOnline);
18 window.addEventListener("offline", handleOffline);
19 return () => {
20 window.removeEventListener("online", handleOnline);
21 window.removeEventListener("offline", handleOffline);
22 };
23 }, []);
24
25 if (isOnline || dismissed) return null;
26
27 return (
28 <AnimatePresence>
29 <motion.div
30 initial={reduced ? { opacity: 0 } : { opacity: 0, y: -4 }}
31 animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0 }}
32 exit={reduced ? { opacity: 0 } : { opacity: 0, y: -4 }}
33 className="flex items-center gap-3 px-4 py-2.5 bg-yellow-50 border-b border-yellow-200 text-sm text-yellow-800"
34 role="status"
35 >
36 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="flex-shrink-0" aria-hidden="true">
37 <path d="M1 1l22 22M16.72 11.06A10.94 10.94 0 0119 12.55M5 12.55a10.94 10.94 0 015.17-2.39M10.71 5.05A16 16 0 0122.56 9M1.42 9a15.91 15.91 0 014.7-2.88M8.53 16.11a6 6 0 016.95 0M12 20h.01" strokeLinecap="round" strokeLinejoin="round" />
38 </svg>
39 <span className="flex-1">
40 You are offline. Your email will be queued and sent automatically when you reconnect.
41 </span>
42 <button
43 type="button"
44 onClick={() => setDismissed(true)}
45 className="flex-shrink-0 text-yellow-600 hover:text-yellow-800 transition-colors"
46 aria-label="Dismiss"
47 >
48 ✕
49 </button>
50 </motion.div>
51 </AnimatePresence>
52 );
53}
54
55export function useOnlineStatus(): boolean {
56 const [isOnline, setIsOnline] = useState(true);
57
58 useEffect(() => {
59 if (typeof window === "undefined") return;
60 setIsOnline(navigator.onLine);
61 const handleOnline = (): void => setIsOnline(true);
62 const handleOffline = (): void => setIsOnline(false);
63 window.addEventListener("online", handleOnline);
64 window.addEventListener("offline", handleOffline);
65 return () => {
66 window.removeEventListener("online", handleOnline);
67 window.removeEventListener("offline", handleOffline);
68 };
69 }, []);
70
71 return isOnline;
72}
Addedapps/web/components/QuickReply.tsx+107−0View fileUnifiedSplit
@@ -0,0 +1,107 @@
1"use client";
2
3import { useState, useRef, useEffect } from "react";
4import { AnimatePresence, motion } from "motion/react";
5import { messagesApi } from "../lib/api";
6
7interface QuickReplyProps {
8 emailId: string;
9 toEmail: string;
10 toName: string;
11 subject: string;
12 userEmail: string;
13 onSent: () => void;
14 onClose: () => void;
15}
16
17export function QuickReply({ emailId, toEmail, toName, subject, userEmail, onSent, onClose }: QuickReplyProps) {
18 const [body, setBody] = useState("");
19 const [sending, setSending] = useState(false);
20 const [error, setError] = useState<string | null>(null);
21 const textareaRef = useRef<HTMLTextAreaElement>(null);
22
23 useEffect(() => {
24 textareaRef.current?.focus();
25 }, []);
26
27 const handleSend = async () => {
28 if (!body.trim() || sending) return;
29 setSending(true);
30 setError(null);
31
32 try {
33 await messagesApi.send({
34 from: { email: userEmail },
35 to: [{ email: toEmail, name: toName }],
36 subject: subject.startsWith("Re:") ? subject : `Re: ${subject}`,
37 text: body,
38 html: `<p>${body.replace(/\n/g, "<br>")}</p>`,
39 });
40 onSent();
41 } catch (err) {
42 setError(err instanceof Error ? err.message : "Failed to send");
43 setSending(false);
44 }
45 };
46
47 const handleKeyDown = (e: React.KeyboardEvent) => {
48 if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
49 e.preventDefault();
50 handleSend();
51 }
52 if (e.key === "Escape") {
53 onClose();
54 }
55 };
56
57 return (
58 <motion.div
59 initial={{ opacity: 0, y: 8, height: 0 }}
60 animate={{ opacity: 1, y: 0, height: "auto" }}
61 exit={{ opacity: 0, y: 8, height: 0 }}
62 transition={{ duration: 0.2 }}
63 className="border-t border-border bg-surface-secondary p-4"
64 >
65 <div className="flex items-center gap-2 mb-2">
66 <span className="text-body-sm text-content-secondary">Reply to</span>
67 <span className="text-body-sm font-medium text-content">{toName || toEmail}</span>
68 <button
69 type="button"
70 onClick={onClose}
71 className="ml-auto text-content-tertiary hover:text-content transition-colors text-sm"
72 aria-label="Close quick reply"
73 >
74 Cancel
75 </button>
76 </div>
77
78 <textarea
79 ref={textareaRef}
80 value={body}
81 onChange={(e) => setBody(e.target.value)}
82 onKeyDown={handleKeyDown}
83 placeholder="Write a quick reply..."
84 rows={3}
85 className="w-full resize-none rounded-lg border border-border bg-surface p-3 text-body-md text-content placeholder:text-content-tertiary focus:outline-none focus:ring-2 focus:ring-border-focus"
86 />
87
88 {error && (
89 <p className="text-caption text-status-error mt-1">{error}</p>
90 )}
91
92 <div className="flex items-center justify-between mt-2">
93 <span className="text-caption text-content-tertiary">
94 {typeof navigator !== "undefined" && /Mac/.test(navigator.platform) ? "⌘" : "Ctrl"}+Enter to send
95 </span>
96 <button
97 type="button"
98 onClick={handleSend}
99 disabled={!body.trim() || sending}
100 className="px-4 py-1.5 rounded-lg bg-brand-600 text-white text-body-sm font-medium hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
101 >
102 {sending ? "Sending..." : "Send"}
103 </button>
104 </div>
105 </motion.div>
106 );
107}
Addedapps/web/components/RecipientAutocomplete.tsx+185−0View fileUnifiedSplit
@@ -0,0 +1,185 @@
1"use client";
2
3import { useState, useRef, useEffect, useCallback } from "react";
4import { AnimatePresence, motion } from "motion/react";
5
6interface Suggestion {
7 email: string;
8 name: string;
9 avatarUrl?: string;
10}
11
12const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
13
14export interface RecipientAutocompleteProps {
15 value: string;
16 onChange: (value: string) => void;
17 placeholder?: string;
18 label?: string;
19 className?: string;
20}
21
22export function RecipientAutocomplete({
23 value,
24 onChange,
25 placeholder = "Recipients...",
26 label,
27 className = "",
28}: RecipientAutocompleteProps): React.ReactNode {
29 const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
30 const [showDropdown, setShowDropdown] = useState(false);
31 const [activeIndex, setActiveIndex] = useState(-1);
32 const inputRef = useRef<HTMLInputElement>(null);
33 const dropdownRef = useRef<HTMLDivElement>(null);
34 const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
35
36 const tokens = value.split(",").map((t) => t.trim());
37 const currentToken = tokens[tokens.length - 1] ?? "";
38
39 const fetchSuggestions = useCallback(async (query: string) => {
40 if (query.length < 2) {
41 setSuggestions([]);
42 setShowDropdown(false);
43 return;
44 }
45
46 try {
47 const token = typeof window !== "undefined" ? localStorage.getItem("alecrae_api_key") ?? "" : "";
48 const res = await fetch(
49 `${API_BASE}/v1/contacts/suggestions?q=${encodeURIComponent(query)}&limit=5`,
50 {
51 headers: token ? { Authorization: `Bearer ${token}` } : {},
52 },
53 );
54 if (res.ok) {
55 const data = (await res.json()) as { data: Suggestion[] };
56 setSuggestions(data.data);
57 setShowDropdown(data.data.length > 0);
58 setActiveIndex(-1);
59 }
60 } catch {
61 setSuggestions([]);
62 }
63 }, []);
64
65 useEffect(() => {
66 if (debounceRef.current) clearTimeout(debounceRef.current);
67 debounceRef.current = setTimeout(() => {
68 fetchSuggestions(currentToken);
69 }, 200);
70 return () => {
71 if (debounceRef.current) clearTimeout(debounceRef.current);
72 };
73 }, [currentToken, fetchSuggestions]);
74
75 useEffect(() => {
76 const handleClickOutside = (e: MouseEvent): void => {
77 if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node) &&
78 inputRef.current && !inputRef.current.contains(e.target as Node)) {
79 setShowDropdown(false);
80 }
81 };
82 document.addEventListener("mousedown", handleClickOutside);
83 return () => document.removeEventListener("mousedown", handleClickOutside);
84 }, []);
85
86 const selectSuggestion = (suggestion: Suggestion): void => {
87 const before = tokens.slice(0, -1);
88 const newValue = [...before, suggestion.email].join(", ") + ", ";
89 onChange(newValue);
90 setSuggestions([]);
91 setShowDropdown(false);
92 inputRef.current?.focus();
93 };
94
95 const handleKeyDown = (e: React.KeyboardEvent): void => {
96 if (!showDropdown || suggestions.length === 0) return;
97
98 if (e.key === "ArrowDown") {
99 e.preventDefault();
100 setActiveIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
101 } else if (e.key === "ArrowUp") {
102 e.preventDefault();
103 setActiveIndex((prev) => Math.max(prev - 1, 0));
104 } else if (e.key === "Enter" || e.key === "Tab") {
105 if (activeIndex >= 0 && activeIndex < suggestions.length) {
106 e.preventDefault();
107 selectSuggestion(suggestions[activeIndex]!);
108 }
109 } else if (e.key === "Escape") {
110 setShowDropdown(false);
111 }
112 };
113
114 const initials = (name: string): string =>
115 name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) || "?";
116
117 return (
118 <div className={`relative ${className}`}>
119 {label && (
120 <label className="block text-xs font-medium text-content-secondary mb-1">
121 {label}
122 </label>
123 )}
124 <input
125 ref={inputRef}
126 type="text"
127 value={value}
128 onChange={(e) => onChange(e.target.value)}
129 onKeyDown={handleKeyDown}
130 onFocus={() => {
131 if (suggestions.length > 0) setShowDropdown(true);
132 }}
133 placeholder={placeholder}
134 className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-surface text-content placeholder:text-content-tertiary focus:outline-none focus:ring-2 focus:ring-border-focus"
135 autoComplete="off"
136 role="combobox"
137 aria-expanded={showDropdown}
138 aria-autocomplete="list"
139 aria-activedescendant={activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined}
140 />
141
142 <AnimatePresence>
143 {showDropdown && suggestions.length > 0 && (
144 <motion.div
145 ref={dropdownRef}
146 initial={{ opacity: 0, y: -4 }}
147 animate={{ opacity: 1, y: 0 }}
148 exit={{ opacity: 0, y: -4 }}
149 transition={{ duration: 0.12 }}
150 className="absolute top-full left-0 right-0 mt-1 z-50 bg-surface border border-border rounded-lg shadow-lg overflow-hidden"
151 role="listbox"
152 >
153 {suggestions.map((suggestion, idx) => (
154 <button
155 key={suggestion.email}
156 id={`suggestion-${idx}`}
157 type="button"
158 onClick={() => selectSuggestion(suggestion)}
159 className={`w-full text-left flex items-center gap-3 px-3 py-2.5 transition-colors ${
160 idx === activeIndex ? "bg-brand-50" : "hover:bg-surface-secondary"
161 }`}
162 role="option"
163 aria-selected={idx === activeIndex}
164 >
165 <div className="w-8 h-8 rounded-full bg-brand-100 flex items-center justify-center flex-shrink-0">
166 <span className="text-xs font-semibold text-brand-700">
167 {initials(suggestion.name)}
168 </span>
169 </div>
170 <div className="flex-1 min-w-0">
171 <p className="text-sm font-medium text-content truncate">
172 {suggestion.name}
173 </p>
174 <p className="text-xs text-content-tertiary truncate">
175 {suggestion.email}
176 </p>
177 </div>
178 </button>
179 ))}
180 </motion.div>
181 )}
182 </AnimatePresence>
183 </div>
184 );
185}
Addedapps/web/components/SignatureManager.tsx+267−0View fileUnifiedSplit
@@ -0,0 +1,267 @@
1"use client";
2
3import { useState, useEffect, useCallback } from "react";
4import { AnimatePresence, motion } from "motion/react";
5import { SPRING_BOUNCY, useAlecRaeReducedMotion } from "../lib/animations";
6
7export interface EmailSignature {
8 id: string;
9 name: string;
10 html: string;
11 isDefault: boolean;
12}
13
14const STORAGE_KEY = "alecrae_signatures";
15
16function loadSignatures(): EmailSignature[] {
17 if (typeof window === "undefined") return [];
18 try {
19 const stored = localStorage.getItem(STORAGE_KEY);
20 return stored ? (JSON.parse(stored) as EmailSignature[]) : [];
21 } catch {
22 return [];
23 }
24}
25
26function saveSignatures(sigs: EmailSignature[]): void {
27 if (typeof window === "undefined") return;
28 localStorage.setItem(STORAGE_KEY, JSON.stringify(sigs));
29}
30
31function generateId(): string {
32 return `sig_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
33}
34
35export interface SignatureManagerProps {
36 onSelect?: (signature: EmailSignature) => void;
37 mode?: "manage" | "picker";
38}
39
40export function SignatureManager({ onSelect, mode = "manage" }: SignatureManagerProps): React.ReactNode {
41 const reduced = useAlecRaeReducedMotion();
42 const [signatures, setSignatures] = useState<EmailSignature[]>([]);
43 const [editingId, setEditingId] = useState<string | null>(null);
44 const [editName, setEditName] = useState("");
45 const [editHtml, setEditHtml] = useState("");
46 const [creating, setCreating] = useState(false);
47
48 useEffect(() => {
49 setSignatures(loadSignatures());
50 }, []);
51
52 const persist = useCallback((sigs: EmailSignature[]) => {
53 setSignatures(sigs);
54 saveSignatures(sigs);
55 }, []);
56
57 const handleCreate = (): void => {
58 const sig: EmailSignature = {
59 id: generateId(),
60 name: editName || "Untitled Signature",
61 html: editHtml,
62 isDefault: signatures.length === 0,
63 };
64 persist([...signatures, sig]);
65 setCreating(false);
66 setEditName("");
67 setEditHtml("");
68 };
69
70 const handleUpdate = (): void => {
71 if (!editingId) return;
72 persist(
73 signatures.map((s) =>
74 s.id === editingId ? { ...s, name: editName, html: editHtml } : s,
75 ),
76 );
77 setEditingId(null);
78 setEditName("");
79 setEditHtml("");
80 };
81
82 const handleDelete = (id: string): void => {
83 const updated = signatures.filter((s) => s.id !== id);
84 if (updated.length > 0 && !updated.some((s) => s.isDefault)) {
85 updated[0]!.isDefault = true;
86 }
87 persist(updated);
88 };
89
90 const handleSetDefault = (id: string): void => {
91 persist(
92 signatures.map((s) => ({ ...s, isDefault: s.id === id })),
93 );
94 };
95
96 const startEdit = (sig: EmailSignature): void => {
97 setEditingId(sig.id);
98 setEditName(sig.name);
99 setEditHtml(sig.html);
100 setCreating(false);
101 };
102
103 if (mode === "picker") {
104 return (
105 <div className="space-y-1">
106 {signatures.length === 0 ? (
107 <p className="text-xs text-content-tertiary py-2">No signatures. Create one in Settings.</p>
108 ) : (
109 signatures.map((sig) => (
110 <button
111 key={sig.id}
112 type="button"
113 onClick={() => onSelect?.(sig)}
114 className="w-full text-left px-3 py-2 rounded-lg hover:bg-surface-secondary transition-colors"
115 >
116 <span className="text-sm font-medium text-content">{sig.name}</span>
117 {sig.isDefault && (
118 <span className="ml-2 text-xs text-brand-600 font-medium">Default</span>
119 )}
120 </button>
121 ))
122 )}
123 </div>
124 );
125 }
126
127 return (
128 <div className="space-y-4">
129 <div className="flex items-center justify-between">
130 <h3 className="text-sm font-semibold text-content">Email Signatures</h3>
131 {!creating && !editingId && (
132 <button
133 type="button"
134 onClick={() => { setCreating(true); setEditName(""); setEditHtml(""); }}
135 className="text-xs font-medium text-brand-600 hover:text-brand-700 transition-colors"
136 >
137 + New signature
138 </button>
139 )}
140 </div>
141
142 <AnimatePresence>
143 {(creating || editingId) && (
144 <motion.div
145 initial={reduced ? false : { opacity: 0, height: 0 }}
146 animate={{ opacity: 1, height: "auto" }}
147 exit={{ opacity: 0, height: 0 }}
148 transition={SPRING_BOUNCY}
149 className="overflow-hidden"
150 >
151 <div className="p-4 rounded-lg border border-border bg-surface-secondary space-y-3">
152 <input
153 type="text"
154 value={editName}
155 onChange={(e) => setEditName(e.target.value)}
156 placeholder="Signature name (e.g., Work, Personal)"
157 className="w-full px-3 py-2 text-sm rounded-md border border-border bg-surface text-content focus:ring-2 focus:ring-brand-500 focus:outline-none"
158 />
159 <textarea
160 value={editHtml}
161 onChange={(e) => setEditHtml(e.target.value)}
162 placeholder="Signature content (HTML supported) Example: Best regards, John Doe CEO, Acme Inc. john@acme.com | (555) 123-4567"
163 rows={6}
164 className="w-full resize-none px-3 py-2 text-sm rounded-md border border-border bg-surface text-content font-mono focus:ring-2 focus:ring-brand-500 focus:outline-none"
165 />
166 {editHtml && (
167 <div className="p-3 rounded-md border border-border bg-surface">
168 <p className="text-xs text-content-tertiary mb-1">Preview:</p>
169 <div
170 className="text-sm text-content prose prose-sm max-w-none"
171 dangerouslySetInnerHTML={{ __html: editHtml.replace(/\n/g, "<br>") }}
172 />
173 </div>
174 )}
175 <div className="flex gap-2 justify-end">
176 <button
177 type="button"
178 onClick={() => { setCreating(false); setEditingId(null); }}
179 className="px-3 py-1.5 text-xs text-content-secondary hover:text-content transition-colors"
180 >
181 Cancel
182 </button>
183 <button
184 type="button"
185 onClick={editingId ? handleUpdate : handleCreate}
186 disabled={!editHtml.trim()}
187 className="px-4 py-1.5 text-xs font-medium text-white bg-brand-600 rounded-md hover:bg-brand-700 disabled:opacity-50 transition-colors"
188 >
189 {editingId ? "Update" : "Create"}
190 </button>
191 </div>
192 </div>
193 </motion.div>
194 )}
195 </AnimatePresence>
196
197 {signatures.length === 0 && !creating ? (
198 <p className="text-sm text-content-tertiary py-4 text-center">
199 No signatures yet. Create your first signature to auto-append to emails.
200 </p>
201 ) : (
202 <div className="space-y-2">
203 {signatures.map((sig) => (
204 <motion.div
205 key={sig.id}
206 layout
207 className="flex items-start gap-3 p-3 rounded-lg border border-border bg-surface hover:bg-surface-secondary transition-colors group"
208 >
209 <div className="flex-1 min-w-0">
210 <div className="flex items-center gap-2">
211 <span className="text-sm font-medium text-content">{sig.name}</span>
212 {sig.isDefault && (
213 <span className="px-1.5 py-0.5 text-xs font-medium bg-brand-50 text-brand-700 rounded">
214 Default
215 </span>
216 )}
217 </div>
218 <div
219 className="text-xs text-content-secondary mt-1 line-clamp-2"
220 dangerouslySetInnerHTML={{ __html: sig.html.replace(/\n/g, " ") }}
221 />
222 </div>
223 <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
224 {!sig.isDefault && (
225 <button
226 type="button"
227 onClick={() => handleSetDefault(sig.id)}
228 className="px-2 py-1 text-xs text-content-tertiary hover:text-brand-600 transition-colors"
229 title="Set as default"
230 >
231 Default
232 </button>
233 )}
234 <button
235 type="button"
236 onClick={() => startEdit(sig)}
237 className="px-2 py-1 text-xs text-content-tertiary hover:text-content transition-colors"
238 >
239 Edit
240 </button>
241 <button
242 type="button"
243 onClick={() => handleDelete(sig.id)}
244 className="px-2 py-1 text-xs text-content-tertiary hover:text-red-600 transition-colors"
245 >
246 Delete
247 </button>
248 </div>
249 </motion.div>
250 ))}
251 </div>
252 )}
253 </div>
254 );
255}
256
257export function useDefaultSignature(): EmailSignature | null {
258 const [sig, setSig] = useState<EmailSignature | null>(null);
259
260 useEffect(() => {
261 const signatures = loadSignatures();
262 const defaultSig = signatures.find((s) => s.isDefault) ?? signatures[0] ?? null;
263 setSig(defaultSig);
264 }, []);
265
266 return sig;
267}
Addedapps/web/components/SnoozePicker.tsx+155−0View fileUnifiedSplit
@@ -0,0 +1,155 @@
1"use client";
2
3import { useState } from "react";
4import { AnimatePresence, motion } from "motion/react";
5import { SPRING_BOUNCY, useAlecRaeReducedMotion } from "../lib/animations";
6
7export interface SnoozePickerProps {
8 open: boolean;
9 onSnooze: (until: Date) => void;
10 onClose: () => void;
11}
12
13function getPresets(): Array<{ label: string; time: Date }> {
14 const now = new Date();
15 const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
16
17 const laterToday = new Date(today);
18 laterToday.setHours(now.getHours() + 3);
19 if (laterToday.getHours() >= 20) {
20 laterToday.setDate(laterToday.getDate() + 1);
21 laterToday.setHours(8, 0, 0, 0);
22 }
23
24 const tomorrow = new Date(today);
25 tomorrow.setDate(tomorrow.getDate() + 1);
26 tomorrow.setHours(8, 0, 0, 0);
27
28 const nextWeek = new Date(today);
29 nextWeek.setDate(nextWeek.getDate() + ((8 - nextWeek.getDay()) % 7 || 7));
30 nextWeek.setHours(8, 0, 0, 0);
31
32 const weekend = new Date(today);
33 const daysUntilSat = (6 - weekend.getDay() + 7) % 7 || 7;
34 weekend.setDate(weekend.getDate() + daysUntilSat);
35 weekend.setHours(9, 0, 0, 0);
36
37 return [
38 { label: "Later today", time: laterToday },
39 { label: "Tomorrow morning", time: tomorrow },
40 { label: "This weekend", time: weekend },
41 { label: "Next week", time: nextWeek },
42 ];
43}
44
45function formatPresetTime(date: Date): string {
46 return date.toLocaleDateString(undefined, {
47 weekday: "short",
48 month: "short",
49 day: "numeric",
50 hour: "numeric",
51 minute: "2-digit",
52 });
53}
54
55export function SnoozePicker({
56 open,
57 onSnooze,
58 onClose,
59}: SnoozePickerProps): React.ReactNode {
60 const reduced = useAlecRaeReducedMotion();
61 const [customDate, setCustomDate] = useState("");
62 const [customTime, setCustomTime] = useState("08:00");
63 const presets = getPresets();
64
65 const handleCustomSnooze = (): void => {
66 if (!customDate) return;
67 const [year, month, day] = customDate.split("-").map(Number);
68 const [hours, minutes] = customTime.split(":").map(Number);
69 if (year === undefined || month === undefined || day === undefined) return;
70 const date = new Date(year, month - 1, day, hours ?? 8, minutes ?? 0);
71 if (date > new Date()) {
72 onSnooze(date);
73 }
74 };
75
76 return (
77 <AnimatePresence>
78 {open && (
79 <>
80 <motion.div
81 initial={{ opacity: 0 }}
82 animate={{ opacity: 1 }}
83 exit={{ opacity: 0 }}
84 className="fixed inset-0 z-[150] bg-black/20"
85 onClick={onClose}
86 />
87 <motion.div
88 initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: -8 }}
89 animate={reduced ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
90 exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: -8 }}
91 transition={SPRING_BOUNCY}
92 className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[151] w-80 bg-surface rounded-xl border border-border shadow-2xl overflow-hidden"
93 role="dialog"
94 aria-label="Snooze email"
95 >
96 <div className="px-4 py-3 border-b border-border">
97 <p className="text-sm font-semibold text-content">Snooze until...</p>
98 </div>
99
100 <div className="p-2">
101 {presets.map((preset) => (
102 <button
103 key={preset.label}
104 type="button"
105 onClick={() => onSnooze(preset.time)}
106 className="w-full flex items-center justify-between px-3 py-2.5 rounded-lg text-left hover:bg-surface-secondary transition-colors"
107 >
108 <span className="text-sm font-medium text-content">{preset.label}</span>
109 <span className="text-xs text-content-tertiary">{formatPresetTime(preset.time)}</span>
110 </button>
111 ))}
112 </div>
113
114 <div className="border-t border-border p-3">
115 <p className="text-xs font-medium text-content-secondary mb-2">Custom date & time</p>
116 <div className="flex gap-2">
117 <input
118 type="date"
119 value={customDate}
120 onChange={(e) => setCustomDate(e.target.value)}
121 min={new Date().toISOString().split("T")[0]}
122 className="flex-1 px-2 py-1.5 text-xs rounded-md border border-border bg-surface text-content focus:ring-2 focus:ring-brand-500 focus:outline-none"
123 />
124 <input
125 type="time"
126 value={customTime}
127 onChange={(e) => setCustomTime(e.target.value)}
128 className="w-24 px-2 py-1.5 text-xs rounded-md border border-border bg-surface text-content focus:ring-2 focus:ring-brand-500 focus:outline-none"
129 />
130 <button
131 type="button"
132 onClick={handleCustomSnooze}
133 disabled={!customDate}
134 className="px-3 py-1.5 text-xs font-medium text-white bg-brand-600 rounded-md hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
135 >
136 Set
137 </button>
138 </div>
139 </div>
140
141 <div className="border-t border-border px-4 py-2">
142 <button
143 type="button"
144 onClick={onClose}
145 className="w-full text-center text-xs text-content-tertiary hover:text-content transition-colors py-1"
146 >
147 Cancel
148 </button>
149 </div>
150 </motion.div>
151 </>
152 )}
153 </AnimatePresence>
154 );
155}
Addedapps/web/components/SyncStatusBar.tsx+186−0View fileUnifiedSplit
@@ -0,0 +1,186 @@
1"use client";
2
3import { useState, useEffect } from "react";
4import { AnimatePresence, motion } from "motion/react";
5import { SPRING_BOUNCY, useAlecRaeReducedMotion } from "../lib/animations";
6
7export interface SyncStatusBarProps {
8 isOnline: boolean;
9 isSyncing: boolean;
10 pendingOutbox: number;
11 lastSyncAt: Date | null;
12 error: string | null;
13 onSyncNow?: () => void;
14}
15
16function formatLastSync(date: Date | null): string {
17 if (!date) return "Never synced";
18 const diffMs = Date.now() - date.getTime();
19 const diffSec = Math.floor(diffMs / 1000);
20 if (diffSec < 10) return "Just now";
21 if (diffSec < 60) return `${diffSec}s ago`;
22 const diffMin = Math.floor(diffSec / 60);
23 if (diffMin < 60) return `${diffMin}m ago`;
24 return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
25}
26
27export function SyncStatusBar({
28 isOnline,
29 isSyncing,
30 pendingOutbox,
31 lastSyncAt,
32 error,
33 onSyncNow,
34}: SyncStatusBarProps): React.ReactNode {
35 const reduced = useAlecRaeReducedMotion();
36 const [lastSyncLabel, setLastSyncLabel] = useState("Never synced");
37
38 useEffect(() => {
39 setLastSyncLabel(formatLastSync(lastSyncAt));
40 const interval = setInterval(() => {
41 setLastSyncLabel(formatLastSync(lastSyncAt));
42 }, 10000);
43 return () => clearInterval(interval);
44 }, [lastSyncAt]);
45
46 return (
47 <AnimatePresence>
48 {(!isOnline || isSyncing || pendingOutbox > 0 || error) && (
49 <motion.div
50 initial={reduced ? { opacity: 0 } : { opacity: 0, y: -4 }}
51 animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0 }}
52 exit={reduced ? { opacity: 0 } : { opacity: 0, y: -4 }}
53 transition={SPRING_BOUNCY}
54 className={`flex items-center gap-3 px-4 py-2 text-xs font-medium border-b ${
55 !isOnline
56 ? "bg-yellow-50 text-yellow-800 border-yellow-200"
57 : error
58 ? "bg-red-50 text-red-800 border-red-200"
59 : isSyncing
60 ? "bg-blue-50 text-blue-700 border-blue-200"
61 : "bg-orange-50 text-orange-700 border-orange-200"
62 }`}
63 role="status"
64 aria-live="polite"
65 >
66 {!isOnline ? (
67 <>
68 <OfflineIcon />
69 <span>You are offline. Changes will sync when you reconnect.</span>
70 </>
71 ) : error ? (
72 <>
73 <ErrorIcon />
74 <span className="flex-1 truncate">Sync error: {error}</span>
75 {onSyncNow && (
76 <button
77 type="button"
78 onClick={onSyncNow}
79 className="flex-shrink-0 px-2 py-1 text-xs font-semibold text-red-700 hover:bg-red-100 rounded transition-colors"
80 >
81 Retry
82 </button>
83 )}
84 </>
85 ) : isSyncing ? (
86 <>
87 <SyncIcon />
88 <span>Syncing emails...</span>
89 </>
90 ) : pendingOutbox > 0 ? (
91 <>
92 <OutboxIcon />
93 <span>
94 {pendingOutbox} email{pendingOutbox !== 1 ? "s" : ""} waiting to send
95 </span>
96 {onSyncNow && (
97 <button
98 type="button"
99 onClick={onSyncNow}
100 className="flex-shrink-0 px-2 py-1 text-xs font-semibold text-orange-700 hover:bg-orange-100 rounded transition-colors"
101 >
102 Send now
103 </button>
104 )}
105 </>
106 ) : null}
107
108 <span className="ml-auto text-xs opacity-70 flex-shrink-0">
109 Last sync: {lastSyncLabel}
110 </span>
111 </motion.div>
112 )}
113 </AnimatePresence>
114 );
115}
116
117export function OfflineBadge(): React.ReactNode {
118 const [isOnline, setIsOnline] = useState(true);
119
120 useEffect(() => {
121 if (typeof window === "undefined") return;
122 setIsOnline(navigator.onLine);
123 const handleOnline = (): void => setIsOnline(true);
124 const handleOffline = (): void => setIsOnline(false);
125 window.addEventListener("online", handleOnline);
126 window.addEventListener("offline", handleOffline);
127 return () => {
128 window.removeEventListener("online", handleOnline);
129 window.removeEventListener("offline", handleOffline);
130 };
131 }, []);
132
133 if (isOnline) return null;
134
135 return (
136 <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
137 <span className="w-1.5 h-1.5 rounded-full bg-yellow-500" />
138 Offline
139 </span>
140 );
141}
142
143function OfflineIcon(): React.ReactNode {
144 return (
145 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="flex-shrink-0" aria-hidden="true">
146 <path d="M1 1l22 22M16.72 11.06A10.94 10.94 0 0119 12.55M5 12.55a10.94 10.94 0 015.17-2.39M10.71 5.05A16 16 0 0122.56 9M1.42 9a15.91 15.91 0 014.7-2.88M8.53 16.11a6 6 0 016.95 0M12 20h.01" strokeLinecap="round" strokeLinejoin="round" />
147 </svg>
148 );
149}
150
151function SyncIcon(): React.ReactNode {
152 return (
153 <motion.svg
154 width="14"
155 height="14"
156 viewBox="0 0 24 24"
157 fill="none"
158 stroke="currentColor"
159 strokeWidth="2"
160 className="flex-shrink-0"
161 aria-hidden="true"
162 animate={{ rotate: 360 }}
163 transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
164 >
165 <path d="M21 12a9 9 0 11-6.219-8.56" strokeLinecap="round" />
166 </motion.svg>
167 );
168}
169
170function ErrorIcon(): React.ReactNode {
171 return (
172 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="flex-shrink-0" aria-hidden="true">
173 <circle cx="12" cy="12" r="10" />
174 <path d="M12 8v4M12 16h.01" strokeLinecap="round" />
175 </svg>
176 );
177}
178
179function OutboxIcon(): React.ReactNode {
180 return (
181 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="flex-shrink-0" aria-hidden="true">
182 <path d="M22 12h-6l-2 3h-4l-2-3H2" strokeLinecap="round" strokeLinejoin="round" />
183 <path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z" strokeLinecap="round" strokeLinejoin="round" />
184 </svg>
185 );
186}
Addedapps/web/components/UndoToast.tsx+183−0View fileUnifiedSplit
@@ -0,0 +1,183 @@
1"use client";
2
3import { useCallback, useEffect, useRef, useState } from "react";
4import { AnimatePresence, motion } from "motion/react";
5import {
6 SPRING_BOUNCY,
7 useAlecRaeReducedMotion,
8} from "../lib/animations";
9
10export interface UndoAction {
11 id: string;
12 label: string;
13 onUndo: () => void;
14 duration?: number;
15}
16
17interface ActiveToast extends UndoAction {
18 createdAt: number;
19 progress: number;
20}
21
22export interface UndoToastManagerProps {
23 actions: UndoAction[];
24 onExpire: (id: string) => void;
25 onDismiss: (id: string) => void;
26}
27
28function UndoToastItem({
29 toast,
30 onUndo,
31 onDismiss,
32}: {
33 toast: ActiveToast;
34 onUndo: () => void;
35 onDismiss: () => void;
36}): React.ReactNode {
37 const reduced = useAlecRaeReducedMotion();
38 const duration = toast.duration ?? 5000;
39 const [progress, setProgress] = useState(100);
40 const startRef = useRef(toast.createdAt);
41 const rafRef = useRef(0);
42
43 useEffect(() => {
44 startRef.current = toast.createdAt;
45 const tick = (): void => {
46 const elapsed = Date.now() - startRef.current;
47 const remaining = Math.max(0, 100 - (elapsed / duration) * 100);
48 setProgress(remaining);
49 if (remaining > 0) {
50 rafRef.current = requestAnimationFrame(tick);
51 }
52 };
53 rafRef.current = requestAnimationFrame(tick);
54 return () => cancelAnimationFrame(rafRef.current);
55 }, [duration, toast.createdAt]);
56
57 return (
58 <motion.div
59 layout
60 initial={reduced ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.95 }}
61 animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
62 exit={reduced ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.95 }}
63 transition={SPRING_BOUNCY}
64 className="relative overflow-hidden rounded-xl border border-border bg-surface shadow-xl max-w-sm w-full backdrop-blur-sm"
65 role="alert"
66 aria-live="assertive"
67 >
68 <div className="flex items-center gap-3 px-4 py-3">
69 <span className="flex-1 text-sm font-medium text-content truncate">
70 {toast.label}
71 </span>
72 <button
73 type="button"
74 onClick={onUndo}
75 className="flex-shrink-0 px-3 py-1.5 text-sm font-semibold text-brand-600 hover:text-brand-700 hover:bg-brand-50 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
76 aria-label="Undo action"
77 >
78 Undo
79 </button>
80 <button
81 type="button"
82 onClick={onDismiss}
83 className="flex-shrink-0 w-6 h-6 flex items-center justify-center text-content-tertiary hover:text-content rounded transition-colors"
84 aria-label="Dismiss"
85 >
86 ✕
87 </button>
88 </div>
89 <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-black/5">
90 <div
91 className="h-full bg-brand-500/40 transition-none"
92 style={{ width: `${progress}%` }}
93 />
94 </div>
95 </motion.div>
96 );
97}
98
99export function UndoToastManager({
100 actions,
101 onExpire,
102 onDismiss,
103}: UndoToastManagerProps): React.ReactNode {
104 const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
105
106 useEffect(() => {
107 for (const action of actions) {
108 if (!timersRef.current.has(action.id)) {
109 const timer = setTimeout(() => {
110 timersRef.current.delete(action.id);
111 onExpire(action.id);
112 }, action.duration ?? 5000);
113 timersRef.current.set(action.id, timer);
114 }
115 }
116
117 const activeIds = new Set(actions.map((a) => a.id));
118 for (const [id, timer] of timersRef.current.entries()) {
119 if (!activeIds.has(id)) {
120 clearTimeout(timer);
121 timersRef.current.delete(id);
122 }
123 }
124 }, [actions, onExpire]);
125
126 useEffect(() => {
127 return () => {
128 for (const timer of timersRef.current.values()) {
129 clearTimeout(timer);
130 }
131 };
132 }, []);
133
134 const handleUndo = useCallback(
135 (action: UndoAction) => {
136 const timer = timersRef.current.get(action.id);
137 if (timer) {
138 clearTimeout(timer);
139 timersRef.current.delete(action.id);
140 }
141 action.onUndo();
142 onDismiss(action.id);
143 },
144 [onDismiss],
145 );
146
147 const handleDismiss = useCallback(
148 (id: string) => {
149 const timer = timersRef.current.get(id);
150 if (timer) {
151 clearTimeout(timer);
152 timersRef.current.delete(id);
153 }
154 onDismiss(id);
155 },
156 [onDismiss],
157 );
158
159 const toasts: ActiveToast[] = actions.map((a) => ({
160 ...a,
161 createdAt: Date.now(),
162 progress: 100,
163 }));
164
165 return (
166 <div
167 className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[200] flex flex-col-reverse gap-2 items-center pointer-events-none"
168 aria-label="Undo actions"
169 >
170 <AnimatePresence>
171 {toasts.map((toast) => (
172 <div key={toast.id} className="pointer-events-auto">
173 <UndoToastItem
174 toast={toast}
175 onUndo={() => handleUndo(toast)}
176 onDismiss={() => handleDismiss(toast.id)}
177 />
178 </div>
179 ))}
180 </AnimatePresence>
181 </div>
182 );
183}
Addedapps/web/components/landing/AIShowcase.tsx+76−0View fileUnifiedSplit
@@ -0,0 +1,76 @@
1"use client";
2
3import { motion } from "motion/react";
4
5const fadeUp = { initial: { opacity: 0, y: 30 }, whileInView: { opacity: 1, y: 0 }, viewport: { once: true, margin: "-100px" }, transition: { duration: 0.6 } };
6
7const capabilities = [
8 { title: "Overnight Agent", desc: "AI triages your inbox while you sleep. Wake up to a sorted inbox with reply drafts ready for one-tap approval.", gradient: "from-blue-500 to-cyan-500" },
9 { title: "Voice Profile", desc: "AI learns your writing style — vocabulary, rhythm, formality. Every draft sounds like you, not a template.", gradient: "from-purple-500 to-pink-500" },
10 { title: "Natural Language Search", desc: "\"Find the email where someone mentioned the budget for Q3\" — search by meaning, not just keywords.", gradient: "from-emerald-500 to-teal-500" },
11 { title: "Newsletter Summaries", desc: "Every newsletter reduced to 3 bullets in your inbox preview. Full text on demand.", gradient: "from-amber-500 to-orange-500" },
12 { title: "Commitment Tracker", desc: "AI catches every promise made in email. \"I'll send that by Friday\" — tracked automatically.", gradient: "from-red-500 to-rose-500" },
13 { title: "Smart Unsubscribe", desc: "One click. AI navigates the unsubscribe page for you and confirms removal.", gradient: "from-indigo-500 to-violet-500" },
14];
15
16export function AIShowcase() {
17 return (
18 <section id="ai" className="py-32 px-6 relative">
19 <div className="absolute inset-0 pointer-events-none">
20 <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-purple-500 rounded-full mix-blend-screen filter blur-[200px] opacity-[0.07]" />
21 </div>
22
23 <div className="max-w-6xl mx-auto relative z-10">
24 <motion.div {...fadeUp} className="text-center mb-16">
25 <p className="text-sm font-medium uppercase tracking-widest text-purple-400 mb-4">AI Engine</p>
26 <h2 className="text-3xl md:text-5xl font-bold tracking-tight text-white mb-4">
27 AI in every layer. Not bolted on.
28 </h2>
29 <p className="text-lg text-blue-100/50 max-w-xl mx-auto">
30 Three-tier AI: free on-device inference, sub-50ms edge processing,
31 full cloud power when you need it.
32 </p>
33 </motion.div>
34
35 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
36 {capabilities.map((c, i) => (
37 <motion.div
38 key={c.title}
39 initial={{ opacity: 0, y: 30 }}
40 whileInView={{ opacity: 1, y: 0 }}
41 viewport={{ once: true, margin: "-50px" }}
42 transition={{ duration: 0.5, delay: i * 0.08 }}
43 className="group relative p-6 rounded-2xl bg-white/[0.03] border border-white/10 hover:border-white/20 transition-all overflow-hidden"
44 >
45 <div className={`absolute top-0 left-0 right-0 h-px bg-gradient-to-r ${c.gradient} opacity-50 group-hover:opacity-100 transition-opacity`} />
46 <h3 className="text-lg font-semibold text-white mb-3">{c.title}</h3>
47 <p className="text-sm text-blue-100/50 leading-relaxed">{c.desc}</p>
48 </motion.div>
49 ))}
50 </div>
51
52 <motion.div {...fadeUp} className="mt-16 text-center">
53 <div className="inline-flex flex-col sm:flex-row items-center gap-6 p-6 rounded-2xl bg-white/[0.03] border border-white/10">
54 <div className="text-left">
55 <div className="text-sm text-blue-200/40 mb-1">On-device AI</div>
56 <div className="text-white font-semibold">$0/token</div>
57 <div className="text-xs text-blue-200/30">Runs on your GPU</div>
58 </div>
59 <div className="w-px h-12 bg-white/10 hidden sm:block" />
60 <div className="text-left">
61 <div className="text-sm text-blue-200/40 mb-1">Edge AI</div>
62 <div className="text-white font-semibold"><50ms</div>
63 <div className="text-xs text-blue-200/30">330+ global locations</div>
64 </div>
65 <div className="w-px h-12 bg-white/10 hidden sm:block" />
66 <div className="text-left">
67 <div className="text-sm text-blue-200/40 mb-1">Cloud AI</div>
68 <div className="text-white font-semibold">Full power</div>
69 <div className="text-xs text-blue-200/30">H100 GPUs on demand</div>
70 </div>
71 </div>
72 </motion.div>
73 </div>
74 </section>
75 );
76}
Addedapps/web/components/landing/CTA.tsx+38−0View fileUnifiedSplit
@@ -0,0 +1,38 @@
1"use client";
2
3import { motion } from "motion/react";
4import Link from "next/link";
5
6export function CTA() {
7 return (
8 <section className="py-32 px-6 relative">
9 <div className="absolute inset-0 pointer-events-none">
10 <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-blue-500 rounded-full mix-blend-screen filter blur-[200px] opacity-[0.08]" />
11 </div>
12
13 <motion.div
14 className="max-w-3xl mx-auto text-center relative z-10"
15 initial={{ opacity: 0, y: 30 }}
16 whileInView={{ opacity: 1, y: 0 }}
17 viewport={{ once: true, margin: "-100px" }}
18 transition={{ duration: 0.6 }}
19 >
20 <h2 className="text-3xl md:text-5xl font-bold tracking-tight text-white mb-6">
21 Ready to upgrade your email?
22 </h2>
23 <p className="text-lg text-blue-100/50 mb-10 max-w-xl mx-auto">
24 Join the beta. Free forever on the starter plan.
25 No credit card required.
26 </p>
27 <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
28 <Link
29 href="/register"
30 className="w-full sm:w-auto px-10 py-4 bg-white text-slate-950 font-semibold rounded-full hover:bg-blue-100 transition-all hover:shadow-lg hover:shadow-blue-500/20 text-center text-lg"
31 >
32 Get Started Free
33 </Link>
34 </div>
35 </motion.div>
36 </section>
37 );
38}
Addedapps/web/components/landing/Comparison.tsx+53−0View fileUnifiedSplit
@@ -0,0 +1,53 @@
1"use client";
2
3import { motion } from "motion/react";
4
5const fadeUp = { initial: { opacity: 0, y: 30 }, whileInView: { opacity: 1, y: 0 }, viewport: { once: true, margin: "-100px" }, transition: { duration: 0.6 } };
6
7const stack = [
8 { tool: "Email + AI assistant", theirPrice: "$12–30/mo", included: true },
9 { tool: "Grammar & writing tool", theirPrice: "$12–30/mo", included: true },
10 { tool: "Premium email client", theirPrice: "$30/mo", included: true },
11 { tool: "Dictation software", theirPrice: "$15/mo", included: true },
12 { tool: "Shared inbox tool", theirPrice: "$19–59/mo", included: true },
13 { tool: "Encrypted email", theirPrice: "$5–10/mo", included: true },
14 { tool: "Meeting transcription", theirPrice: "$10/mo", included: true },
15];
16
17export function Comparison() {
18 return (
19 <section className="py-32 px-6">
20 <div className="max-w-4xl mx-auto">
21 <motion.div {...fadeUp} className="text-center mb-16">
22 <p className="text-sm font-medium uppercase tracking-widest text-emerald-400 mb-4">The math</p>
23 <h2 className="text-3xl md:text-5xl font-bold tracking-tight text-white mb-4">
24 Replace your entire stack.
25 </h2>
26 <p className="text-lg text-blue-100/50 max-w-xl mx-auto">
27 Stop paying seven subscriptions for things one app should do.
28 </p>
29 </motion.div>
30
31 <motion.div {...fadeUp} className="rounded-2xl border border-white/10 overflow-hidden">
32 <div className="grid grid-cols-3 gap-0 px-6 py-4 bg-white/[0.05] border-b border-white/10 text-sm font-medium">
33 <div className="text-blue-100/50">Tool</div>
34 <div className="text-center text-blue-100/50">Separate cost</div>
35 <div className="text-center text-emerald-400">AlecRae</div>
36 </div>
37 {stack.map((item, i) => (
38 <div key={i} className="grid grid-cols-3 gap-0 px-6 py-4 border-b border-white/5 last:border-b-0 hover:bg-white/[0.02] transition-colors">
39 <div className="text-sm text-white">{item.tool}</div>
40 <div className="text-center text-sm text-red-400/80 line-through">{item.theirPrice}</div>
41 <div className="text-center text-sm text-emerald-400 font-medium">Included</div>
42 </div>
43 ))}
44 <div className="grid grid-cols-3 gap-0 px-6 py-5 bg-white/[0.05] border-t border-white/10">
45 <div className="text-white font-semibold">Total</div>
46 <div className="text-center text-red-400 font-bold text-lg">$100+/mo</div>
47 <div className="text-center text-emerald-400 font-bold text-lg">$9/mo</div>
48 </div>
49 </motion.div>
50 </div>
51 </section>
52 );
53}
Addedapps/web/components/landing/Features.tsx+121−0View fileUnifiedSplit
@@ -0,0 +1,121 @@
1"use client";
2
3import { motion } from "motion/react";
4
5const fadeUp = { initial: { opacity: 0, y: 30 }, whileInView: { opacity: 1, y: 0 }, viewport: { once: true, margin: "-100px" }, transition: { duration: 0.6 } };
6
7const features = [
8 {
9 title: "AI Compose",
10 desc: "Writes drafts that sound like you. Not a robot. Your vocabulary, your rhythm, your tone.",
11 icon: (
12 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-blue-400">
13 <path d="M12 20h9M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4L16.5 3.5z" />
14 </svg>
15 ),
16 },
17 {
18 title: "Universal Inbox",
19 desc: "Gmail, Outlook, iCloud, Yahoo, any IMAP — all unified under one AI-powered inbox.",
20 icon: (
21 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-cyan-400">
22 <path d="M22 12h-6l-2 3h-4l-2-3H2" />
23 <path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z" />
24 </svg>
25 ),
26 },
27 {
28 title: "Grammar Agent",
29 desc: "Built-in grammar, tone, and clarity checking. Replaces standalone tools that cost $30/month.",
30 icon: (
31 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-emerald-400">
32 <path d="M9 11l3 3L22 4" />
33 <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
34 </svg>
35 ),
36 },
37 {
38 title: "Voice Dictation",
39 desc: "Email-aware voice commands with multi-language support. Say it, send it.",
40 icon: (
41 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-purple-400">
42 <path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z" />
43 <path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8" />
44 </svg>
45 ),
46 },
47 {
48 title: "Smart Inbox",
49 desc: "AI triages your email overnight. Wake up to a sorted inbox with drafts ready to approve.",
50 icon: (
51 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-amber-400">
52 <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
53 </svg>
54 ),
55 },
56 {
57 title: "Email Recall",
58 desc: "Actually works. Not the fake recall other providers pretend to offer.",
59 icon: (
60 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-red-400">
61 <path d="M1 4v6h6M23 20v-6h-6" />
62 <path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15" />
63 </svg>
64 ),
65 },
66 {
67 title: "Sub-100ms Speed",
68 desc: "Local-first architecture. Your inbox loads from device cache — instant, even offline.",
69 icon: (
70 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-yellow-400">
71 <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
72 </svg>
73 ),
74 },
75 {
76 title: "E2E Encryption",
77 desc: "RSA-4096 + AES-256. Zero-knowledge architecture. We can't read your email. Nobody can.",
78 icon: (
79 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-green-400">
80 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
81 </svg>
82 ),
83 },
84];
85
86export function Features() {
87 return (
88 <section id="features" className="py-32 px-6">
89 <div className="max-w-6xl mx-auto">
90 <motion.div {...fadeUp} className="text-center mb-16">
91 <p className="text-sm font-medium uppercase tracking-widest text-blue-400 mb-4">Features</p>
92 <h2 className="text-3xl md:text-5xl font-bold tracking-tight text-white mb-4">
93 Everything you need. Nothing you don't.
94 </h2>
95 <p className="text-lg text-blue-100/50 max-w-xl mx-auto">
96 Every feature is built in — not bolted on. No plugins, no add-ons, no extra subscriptions.
97 </p>
98 </motion.div>
99
100 <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
101 {features.map((f, i) => (
102 <motion.div
103 key={f.title}
104 initial={{ opacity: 0, y: 30 }}
105 whileInView={{ opacity: 1, y: 0 }}
106 viewport={{ once: true, margin: "-50px" }}
107 transition={{ duration: 0.5, delay: i * 0.05 }}
108 className="group p-6 rounded-2xl bg-white/[0.03] border border-white/10 hover:bg-white/[0.06] hover:border-white/20 transition-all"
109 >
110 <div className="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
111 {f.icon}
112 </div>
113 <h3 className="text-base font-semibold text-white mb-2">{f.title}</h3>
114 <p className="text-sm text-blue-100/50 leading-relaxed">{f.desc}</p>
115 </motion.div>
116 ))}
117 </div>
118 </div>
119 </section>
120 );
121}
Addedapps/web/components/landing/Footer.tsx+69−0View fileUnifiedSplit
@@ -0,0 +1,69 @@
1import Link from "next/link";
2
3const links = {
4 Product: [
5 { label: "Features", href: "#features" },
6 { label: "AI Engine", href: "#ai" },
7 { label: "Pricing", href: "#pricing" },
8 { label: "Security", href: "#security" },
9 { label: "Changelog", href: "/changelog" },
10 { label: "Status", href: "/status" },
11 ],
12 Resources: [
13 { label: "Documentation", href: "/docs" },
14 { label: "API Reference", href: "/docs" },
15 { label: "Migration Guides", href: "/docs" },
16 ],
17 Legal: [
18 { label: "Privacy Policy", href: "/privacy" },
19 { label: "Terms of Service", href: "/terms" },
20 { label: "Cookie Policy", href: "/cookies" },
21 { label: "DPA", href: "/dpa" },
22 { label: "SLA", href: "/sla" },
23 { label: "DMCA", href: "/dmca" },
24 ],
25};
26
27export function Footer() {
28 return (
29 <footer className="border-t border-white/5 py-16 px-6">
30 <div className="max-w-6xl mx-auto">
31 <div className="grid grid-cols-2 md:grid-cols-4 gap-10 mb-16">
32 <div className="col-span-2 md:col-span-1">
33 <Link href="/" className="text-xl font-bold tracking-tighter bg-gradient-to-r from-white to-blue-300 bg-clip-text text-transparent">
34 AlecRae
35 </Link>
36 <p className="text-sm text-blue-100/40 mt-3 leading-relaxed">
37 Email, Evolved.
38 <br />
39 The reinvention of email.
40 </p>
41 </div>
42 {Object.entries(links).map(([category, items]) => (
43 <div key={category}>
44 <h4 className="text-sm font-semibold text-white mb-4">{category}</h4>
45 <ul className="space-y-2.5">
46 {items.map((item) => (
47 <li key={item.label}>
48 <Link href={item.href} className="text-sm text-blue-100/40 hover:text-white transition-colors">
49 {item.label}
50 </Link>
51 </li>
52 ))}
53 </ul>
54 </div>
55 ))}
56 </div>
57
58 <div className="pt-8 border-t border-white/5 flex flex-col md:flex-row items-center justify-between gap-4">
59 <p className="text-xs text-blue-100/30">
60 © {new Date().getFullYear()} AlecRae. All rights reserved.
61 </p>
62 <p className="text-xs text-blue-100/20">
63 No ads. No tracking. No data mining. Ever.
64 </p>
65 </div>
66 </div>
67 </footer>
68 );
69}
Addedapps/web/components/landing/Hero.tsx+126−0View fileUnifiedSplit
@@ -0,0 +1,126 @@
1"use client";
2
3import { motion } from "motion/react";
4import Link from "next/link";
5
6export function Hero() {
7 return (
8 <section className="relative min-h-screen flex items-center justify-center pt-16 overflow-hidden">
9 <div className="absolute inset-0 pointer-events-none">
10 <div className="absolute -top-40 -right-40 w-[500px] h-[500px] bg-blue-500 rounded-full mix-blend-screen filter blur-[120px] opacity-20 animate-pulse" />
11 <div className="absolute -bottom-40 -left-40 w-[500px] h-[500px] bg-purple-500 rounded-full mix-blend-screen filter blur-[120px] opacity-15 animate-pulse" style={{ animationDelay: "1s" }} />
12 <div className="absolute top-1/3 left-1/2 -translate-x-1/2 w-[800px] h-[400px] bg-cyan-500 rounded-full mix-blend-screen filter blur-[120px] opacity-10 animate-pulse" style={{ animationDelay: "2s" }} />
13 </div>
14
15 <div className="relative z-10 max-w-6xl mx-auto px-6 text-center">
16 <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6 }}>
17 <div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/5 border border-white/10 text-sm text-blue-200 mb-8">
18 <span className="relative flex h-2 w-2">
19 <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
20 <span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
21 </span>
22 Now in beta
23 </div>
24 </motion.div>
25
26 <motion.h1
27 className="text-5xl sm:text-7xl md:text-8xl font-bold tracking-tighter leading-[0.9] mb-6"
28 initial={{ opacity: 0, y: 30 }}
29 animate={{ opacity: 1, y: 0 }}
30 transition={{ duration: 0.7, delay: 0.1 }}
31 >
32 <span className="bg-gradient-to-r from-white via-blue-100 to-cyan-200 bg-clip-text text-transparent">
33 Your inbox,
34 </span>
35 <br />
36 <span className="bg-gradient-to-r from-cyan-200 via-blue-400 to-purple-400 bg-clip-text text-transparent">
37 finally intelligent.
38 </span>
39 </motion.h1>
40
41 <motion.p
42 className="text-lg md:text-xl text-blue-100/60 max-w-2xl mx-auto mb-10 leading-relaxed"
43 initial={{ opacity: 0, y: 20 }}
44 animate={{ opacity: 1, y: 0 }}
45 transition={{ duration: 0.6, delay: 0.3 }}
46 >
47 AlecRae replaces your email client, grammar checker, dictation software,
48 and newsletter reader. One app. One subscription. Every account. AI in every layer.
49 </motion.p>
50
51 <motion.div
52 className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-20"
53 initial={{ opacity: 0, y: 20 }}
54 animate={{ opacity: 1, y: 0 }}
55 transition={{ duration: 0.6, delay: 0.5 }}
56 >
57 <Link
58 href="/register"
59 className="w-full sm:w-auto px-8 py-3.5 bg-white text-slate-950 font-semibold rounded-full hover:bg-blue-100 transition-all hover:shadow-lg hover:shadow-blue-500/20 text-center"
60 >
61 Get Started Free
62 </Link>
63 <a
64 href="#features"
65 className="w-full sm:w-auto px-8 py-3.5 border border-white/20 text-white font-medium rounded-full hover:bg-white/5 transition-all text-center"
66 >
67 See Features
68 </a>
69 </motion.div>
70
71 <motion.div
72 className="relative max-w-4xl mx-auto"
73 initial={{ opacity: 0, y: 40 }}
74 animate={{ opacity: 1, y: 0 }}
75 transition={{ duration: 0.8, delay: 0.7 }}
76 >
77 <div className="absolute -inset-4 bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-cyan-500/20 rounded-2xl blur-xl" />
78 <InboxPreview />
79 </motion.div>
80 </div>
81 </section>
82 );
83}
84
85function InboxPreview() {
86 const emails = [
87 { from: "Sarah Chen", subject: "Q3 Revenue Report — Final Numbers", time: "10:32 AM", unread: true, ai: "Contains 3 action items" },
88 { from: "Dev Team", subject: "Deployment successful: v2.4.1 is live", time: "9:15 AM", unread: true, ai: "No action needed" },
89 { from: "Alex Rivera", subject: "Re: Partnership proposal — thoughts?", time: "8:48 AM", unread: false, ai: "Follow-up by Friday" },
90 { from: "Newsletter", subject: "This Week in AI: Claude 4 benchmarks...", time: "7:00 AM", unread: false, ai: "3-bullet summary ready" },
91 { from: "Jordan Lee", subject: "Meeting moved to 3pm tomorrow", time: "Yesterday", unread: false, ai: "Calendar updated" },
92 ];
93
94 return (
95 <div className="relative bg-slate-900/90 backdrop-blur-sm border border-white/10 rounded-xl overflow-hidden shadow-2xl">
96 <div className="flex items-center gap-2 px-4 py-3 border-b border-white/5 bg-slate-900/50">
97 <div className="flex gap-1.5">
98 <div className="w-3 h-3 rounded-full bg-red-500/70" />
99 <div className="w-3 h-3 rounded-full bg-yellow-500/70" />
100 <div className="w-3 h-3 rounded-full bg-green-500/70" />
101 </div>
102 <div className="flex-1 text-center text-xs text-blue-200/40">AlecRae — Inbox</div>
103 </div>
104 <div className="divide-y divide-white/5">
105 {emails.map((email, i) => (
106 <div key={i} className={`flex items-center gap-4 px-5 py-3.5 hover:bg-white/[0.02] transition-colors ${email.unread ? "bg-white/[0.03]" : ""}`}>
107 <div className={`w-2 h-2 rounded-full flex-shrink-0 ${email.unread ? "bg-blue-400" : "bg-transparent"}`} />
108 <div className="flex-1 min-w-0">
109 <div className="flex items-center justify-between gap-4">
110 <span className={`text-sm truncate ${email.unread ? "text-white font-semibold" : "text-blue-100/70"}`}>{email.from}</span>
111 <span className="text-xs text-blue-200/30 flex-shrink-0">{email.time}</span>
112 </div>
113 <div className="text-sm text-blue-100/50 truncate">{email.subject}</div>
114 </div>
115 <div className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 flex-shrink-0">
116 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-purple-400">
117 <path d="M12 2a4 4 0 0 0-4 4v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6a4 4 0 0 0-4-4z" />
118 </svg>
119 <span className="text-[11px] text-purple-300">{email.ai}</span>
120 </div>
121 </div>
122 ))}
123 </div>
124 </div>
125 );
126}
Addedapps/web/components/landing/Navbar.tsx+62−0View fileUnifiedSplit
@@ -0,0 +1,62 @@
1"use client";
2
3import Link from "next/link";
4import { useState } from "react";
5
6export function Navbar() {
7 const [mobileOpen, setMobileOpen] = useState(false);
8
9 return (
10 <nav className="fixed top-0 left-0 right-0 z-50 bg-slate-950/80 backdrop-blur-xl border-b border-white/5">
11 <div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
12 <Link href="/" className="text-2xl font-bold tracking-tighter bg-gradient-to-r from-white to-blue-300 bg-clip-text text-transparent">
13 AlecRae
14 </Link>
15
16 <div className="hidden md:flex items-center gap-8 text-sm text-blue-100/70">
17 <a href="#features" className="hover:text-white transition-colors">Features</a>
18 <a href="#ai" className="hover:text-white transition-colors">AI</a>
19 <a href="#pricing" className="hover:text-white transition-colors">Pricing</a>
20 <a href="#security" className="hover:text-white transition-colors">Security</a>
21 </div>
22
23 <div className="hidden md:flex items-center gap-3">
24 <Link href="/login" className="text-sm text-blue-100/70 hover:text-white transition-colors px-4 py-2">
25 Sign In
26 </Link>
27 <Link href="/register" className="text-sm font-medium bg-white text-slate-950 px-5 py-2 rounded-full hover:bg-blue-100 transition-colors">
28 Get Started Free
29 </Link>
30 </div>
31
32 <button
33 type="button"
34 className="md:hidden text-white p-2"
35 onClick={() => setMobileOpen(!mobileOpen)}
36 aria-label="Toggle menu"
37 >
38 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
39 {mobileOpen ? (
40 <path d="M18 6L6 18M6 6l12 12" />
41 ) : (
42 <path d="M4 6h16M4 12h16M4 18h16" />
43 )}
44 </svg>
45 </button>
46 </div>
47
48 {mobileOpen && (
49 <div className="md:hidden bg-slate-950/95 backdrop-blur-xl border-b border-white/5 px-6 py-4 flex flex-col gap-4">
50 <a href="#features" className="text-blue-100/70 hover:text-white" onClick={() => setMobileOpen(false)}>Features</a>
51 <a href="#ai" className="text-blue-100/70 hover:text-white" onClick={() => setMobileOpen(false)}>AI</a>
52 <a href="#pricing" className="text-blue-100/70 hover:text-white" onClick={() => setMobileOpen(false)}>Pricing</a>
53 <a href="#security" className="text-blue-100/70 hover:text-white" onClick={() => setMobileOpen(false)}>Security</a>
54 <Link href="/login" className="text-blue-100/70 hover:text-white">Sign In</Link>
55 <Link href="/register" className="font-medium bg-white text-slate-950 px-5 py-2 rounded-full text-center hover:bg-blue-100">
56 Get Started Free
57 </Link>
58 </div>
59 )}
60 </nav>
61 );
62}
Addedapps/web/components/landing/Platforms.tsx+75−0View fileUnifiedSplit
@@ -0,0 +1,75 @@
1"use client";
2
3import { motion } from "motion/react";
4
5const fadeUp = { initial: { opacity: 0, y: 30 }, whileInView: { opacity: 1, y: 0 }, viewport: { once: true, margin: "-100px" }, transition: { duration: 0.6 } };
6
7const platforms = [
8 {
9 name: "Web",
10 desc: "Any browser, any device. Nothing to install.",
11 icon: (
12 <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-blue-400">
13 <circle cx="12" cy="12" r="10" />
14 <path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
15 </svg>
16 ),
17 },
18 {
19 name: "Desktop",
20 desc: "Native app for Mac, Windows, and Linux.",
21 icon: (
22 <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-cyan-400">
23 <rect x="2" y="3" width="20" height="14" rx="2" />
24 <path d="M8 21h8M12 17v4" />
25 </svg>
26 ),
27 },
28 {
29 name: "Mobile",
30 desc: "iOS and Android with native performance.",
31 icon: (
32 <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-purple-400">
33 <rect x="5" y="2" width="14" height="20" rx="2" />
34 <path d="M12 18h.01" />
35 </svg>
36 ),
37 },
38];
39
40export function Platforms() {
41 return (
42 <section className="py-32 px-6">
43 <div className="max-w-4xl mx-auto">
44 <motion.div {...fadeUp} className="text-center mb-16">
45 <p className="text-sm font-medium uppercase tracking-widest text-blue-400 mb-4">Platforms</p>
46 <h2 className="text-3xl md:text-5xl font-bold tracking-tight text-white mb-4">
47 Every device. One experience.
48 </h2>
49 <p className="text-lg text-blue-100/50 max-w-xl mx-auto">
50 Start on your phone, finish on your desktop. Your inbox syncs everywhere.
51 </p>
52 </motion.div>
53
54 <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
55 {platforms.map((p, i) => (
56 <motion.div
57 key={p.name}
58 initial={{ opacity: 0, y: 20 }}
59 whileInView={{ opacity: 1, y: 0 }}
60 viewport={{ once: true }}
61 transition={{ duration: 0.5, delay: i * 0.1 }}
62 className="flex flex-col items-center text-center p-8 rounded-2xl bg-white/[0.03] border border-white/10 hover:bg-white/[0.06] transition-all"
63 >
64 <div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
65 {p.icon}
66 </div>
67 <h3 className="text-lg font-semibold text-white mb-2">{p.name}</h3>
68 <p className="text-sm text-blue-100/50">{p.desc}</p>
69 </motion.div>
70 ))}
71 </div>
72 </div>
73 </section>
74 );
75}
Addedapps/web/components/landing/Pricing.tsx+119−0View fileUnifiedSplit
@@ -0,0 +1,119 @@
1"use client";
2
3import { motion } from "motion/react";
4import Link from "next/link";
5
6const fadeUp = { initial: { opacity: 0, y: 30 }, whileInView: { opacity: 1, y: 0 }, viewport: { once: true, margin: "-100px" }, transition: { duration: 0.6 } };
7
8const plans = [
9 {
10 name: "Free",
11 price: "$0",
12 period: "forever",
13 desc: "Get started with one account",
14 features: ["1 email account", "5 AI composes per day", "30-day search history", "Basic smart inbox", "Keyboard shortcuts"],
15 cta: "Start Free",
16 highlighted: false,
17 },
18 {
19 name: "Personal",
20 price: "$9",
21 period: "/month",
22 desc: "For professionals who mean business",
23 features: ["3 email accounts", "Unlimited AI compose", "Unlimited search", "E2E encryption", "Snooze & schedule send", "Voice dictation", "Grammar agent", "Email recall"],
24 cta: "Get Personal",
25 highlighted: true,
26 },
27 {
28 name: "Pro",
29 price: "$19",
30 period: "/month",
31 desc: "For power users and creators",
32 features: ["Unlimited accounts", "Priority AI (faster model)", "Email analytics", "API access", "Custom automations", "Advanced search operators", "Everything in Personal"],
33 cta: "Go Pro",
34 highlighted: false,
35 },
36 {
37 name: "Team",
38 price: "$12",
39 period: "/user/month",
40 desc: "For teams that share inboxes",
41 features: ["Shared inboxes", "Admin console", "Audit logs", "SSO / SAML", "Priority support", "Collaboration tools", "Everything in Pro"],
42 cta: "Start Team Trial",
43 highlighted: false,
44 },
45];
46
47export function Pricing() {
48 return (
49 <section id="pricing" className="py-32 px-6">
50 <div className="max-w-6xl mx-auto">
51 <motion.div {...fadeUp} className="text-center mb-16">
52 <p className="text-sm font-medium uppercase tracking-widest text-blue-400 mb-4">Pricing</p>
53 <h2 className="text-3xl md:text-5xl font-bold tracking-tight text-white mb-4">
54 Simple pricing. No surprises.
55 </h2>
56 <p className="text-lg text-blue-100/50 max-w-xl mx-auto">
57 Start free. Upgrade when you need more. Cancel anytime.
58 </p>
59 </motion.div>
60
61 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
62 {plans.map((plan, i) => (
63 <motion.div
64 key={plan.name}
65 initial={{ opacity: 0, y: 30 }}
66 whileInView={{ opacity: 1, y: 0 }}
67 viewport={{ once: true, margin: "-50px" }}
68 transition={{ duration: 0.5, delay: i * 0.08 }}
69 className={`relative flex flex-col p-6 rounded-2xl border transition-all ${
70 plan.highlighted
71 ? "bg-white/[0.08] border-blue-500/50 shadow-lg shadow-blue-500/10"
72 : "bg-white/[0.03] border-white/10 hover:border-white/20"
73 }`}
74 >
75 {plan.highlighted && (
76 <div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-blue-500 text-white text-xs font-semibold rounded-full">
77 Most Popular
78 </div>
79 )}
80 <div className="mb-6">
81 <h3 className="text-lg font-semibold text-white mb-1">{plan.name}</h3>
82 <p className="text-sm text-blue-100/40 mb-4">{plan.desc}</p>
83 <div className="flex items-baseline gap-1">
84 <span className="text-4xl font-bold text-white">{plan.price}</span>
85 <span className="text-sm text-blue-100/40">{plan.period}</span>
86 </div>
87 </div>
88 <ul className="flex-1 space-y-3 mb-8">
89 {plan.features.map((f) => (
90 <li key={f} className="flex items-start gap-2 text-sm text-blue-100/60">
91 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-emerald-400 mt-0.5 flex-shrink-0">
92 <path d="M20 6L9 17l-5-5" />
93 </svg>
94 {f}
95 </li>
96 ))}
97 </ul>
98 <Link
99 href="/register"
100 className={`text-center py-2.5 rounded-full text-sm font-medium transition-all ${
101 plan.highlighted
102 ? "bg-white text-slate-950 hover:bg-blue-100"
103 : "bg-white/10 text-white hover:bg-white/20 border border-white/10"
104 }`}
105 >
106 {plan.cta}
107 </Link>
108 </motion.div>
109 ))}
110 </div>
111
112 <motion.p {...fadeUp} className="text-center text-sm text-blue-100/30 mt-8">
113 Need enterprise? Custom pricing with on-prem deployment, SLA, and dedicated support.{" "}
114 <a href="mailto:hello@alecrae.com" className="text-blue-400 hover:text-blue-300 underline">Contact us</a>
115 </motion.p>
116 </div>
117 </section>
118 );
119}
Addedapps/web/components/landing/Problem.tsx+41−0View fileUnifiedSplit
@@ -0,0 +1,41 @@
1"use client";
2
3import { motion } from "motion/react";
4
5const fadeUp = { initial: { opacity: 0, y: 30 }, whileInView: { opacity: 1, y: 0 }, viewport: { once: true, margin: "-100px" }, transition: { duration: 0.6 } };
6
7export function Problem() {
8 return (
9 <section className="py-32 px-6">
10 <div className="max-w-4xl mx-auto text-center">
11 <motion.p {...fadeUp} className="text-sm font-medium uppercase tracking-widest text-blue-400 mb-6">
12 The problem
13 </motion.p>
14 <motion.h2 {...fadeUp} className="text-3xl md:text-5xl font-bold tracking-tight text-white mb-8 leading-tight">
15 Email hasn't been reinvented in{" "}
16 <span className="bg-gradient-to-r from-red-400 to-orange-400 bg-clip-text text-transparent">22 years.</span>
17 </motion.h2>
18 <motion.p {...fadeUp} className="text-lg text-blue-100/50 max-w-2xl mx-auto mb-16 leading-relaxed">
19 Your current email was designed before the iPhone existed. You're patching it
20 with a grammar checker, a dictation app, a scheduling tool, and an AI sidebar
21 — paying $100+/month for tools that don't talk to each other.
22 </motion.p>
23
24 <motion.div {...fadeUp} className="grid grid-cols-1 md:grid-cols-3 gap-6">
25 <StatCard number="$100+" label="Monthly cost of your current email stack" />
26 <StatCard number="5+" label="Separate tools to do what one app should" />
27 <StatCard number="0" label="Tools that actually learn how you write" />
28 </motion.div>
29 </div>
30 </section>
31 );
32}
33
34function StatCard({ number, label }: { number: string; label: string }) {
35 return (
36 <div className="p-6 rounded-2xl bg-white/[0.03] border border-white/10">
37 <div className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-cyan-400 bg-clip-text text-transparent mb-2">{number}</div>
38 <div className="text-sm text-blue-100/50">{label}</div>
39 </div>
40 );
41}
Addedapps/web/components/landing/Security.tsx+57−0View fileUnifiedSplit
@@ -0,0 +1,57 @@
1"use client";
2
3import { motion } from "motion/react";
4
5const fadeUp = { initial: { opacity: 0, y: 30 }, whileInView: { opacity: 1, y: 0 }, viewport: { once: true, margin: "-100px" }, transition: { duration: 0.6 } };
6
7const pledges = [
8 { title: "No ads. Ever.", desc: "We make money from subscriptions, not surveillance. Your inbox is yours." },
9 { title: "No data mining. Ever.", desc: "We don't read your emails for ad targeting. We don't sell your data. Period." },
10 { title: "No third-party trackers.", desc: "Zero analytics scripts that send your behavior to advertising networks." },
11 { title: "E2E encryption.", desc: "RSA-OAEP-4096 + AES-256-GCM. Zero-knowledge architecture. We cannot read encrypted mail." },
12 { title: "TLS 1.3 minimum.", desc: "Every connection encrypted. No exceptions. No downgrades." },
13 { title: "Passkey-first auth.", desc: "FIDO2 WebAuthn by default. 98% login success rate vs 13.8% for passwords." },
14];
15
16export function Security() {
17 return (
18 <section id="security" className="py-32 px-6 relative">
19 <div className="absolute inset-0 pointer-events-none">
20 <div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[800px] h-[400px] bg-emerald-500 rounded-full mix-blend-screen filter blur-[200px] opacity-[0.05]" />
21 </div>
22
23 <div className="max-w-5xl mx-auto relative z-10">
24 <motion.div {...fadeUp} className="text-center mb-16">
25 <p className="text-sm font-medium uppercase tracking-widest text-emerald-400 mb-4">Security & Privacy</p>
26 <h2 className="text-3xl md:text-5xl font-bold tracking-tight text-white mb-4">
27 Your email is none of our business.
28 </h2>
29 <p className="text-lg text-blue-100/50 max-w-xl mx-auto">
30 Privacy isn't a feature toggle. It's the architecture.
31 </p>
32 </motion.div>
33
34 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
35 {pledges.map((p, i) => (
36 <motion.div
37 key={p.title}
38 initial={{ opacity: 0, y: 20 }}
39 whileInView={{ opacity: 1, y: 0 }}
40 viewport={{ once: true, margin: "-50px" }}
41 transition={{ duration: 0.5, delay: i * 0.06 }}
42 className="p-6 rounded-2xl bg-white/[0.03] border border-white/10"
43 >
44 <div className="flex items-center gap-2 mb-3">
45 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-emerald-400">
46 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
47 </svg>
48 <h3 className="text-base font-semibold text-white">{p.title}</h3>
49 </div>
50 <p className="text-sm text-blue-100/50 leading-relaxed">{p.desc}</p>
51 </motion.div>
52 ))}
53 </div>
54 </div>
55 </section>
56 );
57}
Modifiedapps/web/e2e/smoke.spec.ts+41−3View fileUnifiedSplit
@@ -32,18 +32,25 @@ test.describe("Landing page (/)", () => {
3232
3333 test("displays the AlecRae wordmark", async ({ page }) => {
3434 await page.goto("/");
35 // The wordmark is rendered as an <h1> in the Hero section.
3536 await expect(page.locator("h1").filter({ hasText: /AlecRae/i }).first()).toBeVisible();
3637 });
3738
3839 test("displays hero tagline copy", async ({ page }) => {
3940 await page.goto("/");
40 await expect(page.getByText(/Email, considered/i).first()).toBeVisible();
41 // Tagline rendered inside the Hero section below the wordmark.
42 await expect(
43 page.getByText(/Email, considered/i).first(),
44 ).toBeVisible();
4145 });
4246
4347 test("shows the waitlist / request-access form", async ({ page }) => {
4448 await page.goto("/");
49 // The waitlist section has id="waitlist" and contains an email input.
4550 const waitlistSection = page.locator("#waitlist");
4651 await expect(waitlistSection).toBeVisible();
52
53 // Email input inside the form must be present.
4754 const emailInput = waitlistSection.locator('input[type="email"]');
4855 await expect(emailInput).toBeVisible();
4956 });
@@ -72,6 +79,7 @@ test.describe("Login page (/login)", () => {
7279
7380 test("has a passkey sign-in button", async ({ page }) => {
7481 await page.goto("/login");
82 // The passkey button reads "Sign in with Passkey" (or "Authenticating..." when loading).
7583 await expect(
7684 page.getByRole("button", { name: /sign in with passkey/i }),
7785 ).toBeVisible();
@@ -79,6 +87,7 @@ test.describe("Login page (/login)", () => {
7987
8088 test("has an email input for email/password login", async ({ page }) => {
8189 await page.goto("/login");
90 // EmailLogin component renders an email input for the password-based path.
8291 const emailInput = page.locator('input[type="email"]').first();
8392 await expect(emailInput).toBeVisible();
8493 });
@@ -92,12 +101,23 @@ test.describe("Login page (/login)", () => {
92101// ─── 3. Dashboard redirects when unauthenticated ────────────────────────────
93102
94103test.describe("Dashboard auth guard", () => {
104 // The dashboard layout is a client component; on first render it calls
105 // authApi.me() which will fail without a token. The logout handler
106 // redirects to /login. For unauthenticated smoke tests we verify the
107 // dashboard URL is either redirected server-side (HTTP 3xx → /login) or
108 // still returns a page that ultimately contains the login surface.
109 //
110 // We do NOT wait for client-side JS to execute the redirect here — that
111 // would require real auth infrastructure. We simply assert the HTTP layer
112 // does not 500 and does not expose raw dashboard content without auth.
113
95114 const dashboardRoutes = ["/inbox", "/compose", "/settings", "/analytics", "/domains"];
96115
97116 for (const route of dashboardRoutes) {
98117 test(`${route} does not return a server error`, async ({ page }) => {
99118 const response = await page.goto(route);
100119 expect(response, "navigation response must exist").not.toBeNull();
120 // Must not be a 5xx server error.
101121 expect(response!.status(), `GET ${route} must not 5xx`).toBeLessThan(500);
102122 });
103123 }
@@ -106,9 +126,25 @@ test.describe("Dashboard auth guard", () => {
106126// ─── 4. API health endpoint ──────────────────────────────────────────────────
107127
108128test.describe("API health", () => {
109 test("GET /api/health returns OK or 404 (not a server crash)", async ({ request }) => {
129 // The Next.js web app does not proxy /health itself — the health endpoint
130 // lives on the API server (api.alecrae.com). In the test environment we
131 // check a Next.js API route if it exists, otherwise skip gracefully.
132 //
133 // If NEXT_PUBLIC_API_URL is set and reachable, we hit it directly.
134 // This test is designed to be skipped cleanly in pure-frontend CI where
135 // the API server isn't running.
136
137 test("GET /api/health (next.js route or rewrite) returns OK or 404", async ({
138 request,
139 }) => {
110140 const response = await request.get("/api/health");
111 expect(response.status(), "Health check must not be a server error").toBeLessThan(500);
141 // Accept 200 (route exists and is healthy) or 404 (route not wired in
142 // web app — that is expected; the real health check is on the API server).
143 // Reject anything that indicates a server crash (5xx).
144 expect(
145 response.status(),
146 "Health check must not be a server error",
147 ).toBeLessThan(500);
112148 });
113149});
114150
@@ -127,6 +163,7 @@ test.describe("robots.txt", () => {
127163 });
128164
129165 test("disallows /admin path", async ({ request }) => {
166 // The admin route is robots-disallowed (set in apps/web/app/robots.ts).
130167 const response = await request.get("/robots.txt");
131168 const body = await response.text();
132169 expect(body, "robots.txt should disallow /admin").toMatch(/Disallow:\s*\/admin/i);
@@ -167,6 +204,7 @@ test.describe("sitemap.xml", () => {
167204 test("includes the landing page URL", async ({ request }) => {
168205 const response = await request.get("/sitemap.xml");
169206 const body = await response.text();
207 // The landing page URL (/) must appear in the sitemap.
170208 expect(body, "sitemap must reference the landing page").toMatch(/alecrae\.com/i);
171209 });
172210});
Modifiedapps/web/lib/api.ts+219−4View fileUnifiedSplit
@@ -437,6 +437,28 @@ export const messagesApi = {
437437 );
438438 },
439439
440 archive(id: string) {
441 return apiFetch<{ data: { id: string; updated: boolean } }>(
442 `/v1/messages/${id}`,
443 { method: "PATCH", body: JSON.stringify({ status: "dropped", tags: ["archived"] }) },
444 );
445 },
446
447 delete(id: string) {
448 return apiFetch<{ data: { id: string; deleted: boolean } }>(
449 `/v1/messages/${id}`,
450 { method: "DELETE" },
451 );
452 },
453
454 star(id: string, starred: boolean) {
455 const tags = starred ? ["starred"] : [];
456 return apiFetch<{ data: { id: string; updated: boolean } }>(
457 `/v1/messages/${id}`,
458 { method: "PATCH", body: JSON.stringify({ tags }) },
459 );
460 },
461
440462 search(params: { q: string; mailbox?: string; limit?: number; offset?: number }) {
441463 const qs = new URLSearchParams();
442464 qs.set("q", params.q);
@@ -628,14 +650,57 @@ export const apiKeysApi = {
628650
629651// ─── Account ───────────────────────────────────────────────────────────────
630652
653export interface PasskeyInfo {
654 id: string;
655 credentialId: string;
656 deviceName: string;
657 createdAt: string | null;
658 lastUsedAt: string | null;
659}
660
661export interface NotificationPrefs {
662 emailNotifications: boolean;
663 aiDigest: boolean;
664 deliverabilityAlerts: boolean;
665}
666
631667export const accountApi = {
632668 get() {
633669 return apiFetch<{ data: Account }>("/v1/account");
634670 },
635 update(input: { name?: string; accountName?: string; billingEmail?: string }) {
636 return apiFetch<{ data: { success: boolean } }>("/v1/account", {
637 method: "PATCH",
638 body: JSON.stringify(input),
671
672 updateProfile(payload: { name?: string; email?: string }) {
673 return apiFetch<{ data: { id: string; name: string; email: string; role: string } }>(
674 "/v1/account/profile",
675 { method: "PATCH", body: JSON.stringify(payload) },
676 );
677 },
678
679 deleteAccount() {
680 return apiFetch<{ data: { deleted: boolean } }>("/v1/account", {
681 method: "DELETE",
682 });
683 },
684
685 listPasskeys() {
686 return apiFetch<{ data: PasskeyInfo[] }>("/v1/account/passkeys");
687 },
688
689 deletePasskey(id: string) {
690 return apiFetch<{ data: { deleted: boolean; id: string } }>(
691 `/v1/account/passkeys/${id}`,
692 { method: "DELETE" },
693 );
694 },
695
696 getNotificationPrefs() {
697 return apiFetch<{ data: NotificationPrefs }>("/v1/account/notifications");
698 },
699
700 updateNotificationPrefs(payload: Partial<NotificationPrefs>) {
701 return apiFetch<{ data: NotificationPrefs }>("/v1/account/notifications", {
702 method: "PUT",
703 body: JSON.stringify(payload),
639704 });
640705 },
641706};
@@ -922,6 +987,62 @@ export const emailExplainerApi = {
922987 },
923988};
924989
990// ─── Grammar & AI Compose Suggestions ────────────────────────────────────
991
992export interface GrammarIssue {
993 type: string;
994 message: string;
995 offset: number;
996 length: number;
997 replacements: string[];
998}
999
1000export interface GrammarCheckResponse {
1001 issues: GrammarIssue[];
1002 score: number;
1003 correctedText?: string;
1004}
1005
1006export const grammarApi = {
1007 check(payload: { text: string; language?: string }) {
1008 return apiFetch<{ data: GrammarCheckResponse }>("/v1/grammar/check", {
1009 method: "POST",
1010 body: JSON.stringify(payload),
1011 });
1012 },
1013
1014 correct(payload: { text: string; language?: string }) {
1015 return apiFetch<{ data: { correctedText: string; changes: number } }>(
1016 "/v1/grammar/correct",
1017 { method: "POST", body: JSON.stringify(payload) },
1018 );
1019 },
1020};
1021
1022// ─── Snooze ───────────────────────────────────────────────────────────────
1023
1024export const snoozeApi = {
1025 snooze(emailId: string, until: string) {
1026 return apiFetch<{ data: { id: string; emailId: string; snoozedUntil: string } }>(
1027 `/v1/snooze/${emailId}`,
1028 { method: "POST", body: JSON.stringify({ until }) },
1029 );
1030 },
1031
1032 unsnooze(emailId: string) {
1033 return apiFetch<{ data: { deleted: boolean } }>(
1034 `/v1/snooze/${emailId}`,
1035 { method: "DELETE" },
1036 );
1037 },
1038
1039 list() {
1040 return apiFetch<{ data: Array<{ id: string; emailId: string; snoozedUntil: string; subject: string }> }>(
1041 "/v1/snooze",
1042 );
1043 },
1044};
1045
9251046// ─── Suppressions ──────────────────────────────────────────────────────────
9261047
9271048export const suppressionsApi = {
@@ -1599,3 +1720,97 @@ export const emailQueryApi = {
15991720 );
16001721 },
16011722};
1723
1724// ─── Templates ────────────────────────────────────────────────────────────
1725
1726export interface Template {
1727 id: string;
1728 name: string;
1729 subject: string;
1730 htmlBody: string | null;
1731 textBody: string | null;
1732 metadata: Record<string, unknown> | null;
1733 createdAt: string;
1734 updatedAt: string;
1735}
1736
1737export interface TemplateRenderResult {
1738 subject: string;
1739 htmlBody: string | null;
1740 textBody: string | null;
1741}
1742
1743export const templatesApi = {
1744 /** List templates with optional pagination and name filter. */
1745 list(params?: {
1746 limit?: number;
1747 cursor?: string;
1748 name?: string;
1749 }): Promise<PaginatedResponse<Template>> {
1750 const qs = new URLSearchParams();
1751 if (params?.limit) qs.set("limit", String(params.limit));
1752 if (params?.cursor) qs.set("cursor", params.cursor);
1753 if (params?.name) qs.set("name", params.name);
1754 const query = qs.toString();
1755 return apiFetch<PaginatedResponse<Template>>(
1756 `/v1/templates${query ? `?${query}` : ""}`,
1757 );
1758 },
1759
1760 /** Get a single template by ID. */
1761 get(id: string): Promise<{ data: Template }> {
1762 return apiFetch<{ data: Template }>(
1763 `/v1/templates/${encodeURIComponent(id)}`,
1764 );
1765 },
1766
1767 /** Create a new template. */
1768 create(payload: {
1769 name: string;
1770 subject: string;
1771 htmlBody?: string;
1772 textBody?: string;
1773 metadata?: Record<string, unknown>;
1774 }): Promise<{ data: Template }> {
1775 return apiFetch<{ data: Template }>("/v1/templates", {
1776 method: "POST",
1777 body: JSON.stringify(payload),
1778 });
1779 },
1780
1781 /** Update an existing template. */
1782 update(
1783 id: string,
1784 payload: {
1785 name?: string;
1786 subject?: string;
1787 htmlBody?: string;
1788 textBody?: string;
1789 metadata?: Record<string, unknown>;
1790 },
1791 ): Promise<{ data: Template }> {
1792 return apiFetch<{ data: Template }>(
1793 `/v1/templates/${encodeURIComponent(id)}`,
1794 { method: "PUT", body: JSON.stringify(payload) },
1795 );
1796 },
1797
1798 /** Delete a template. */
1799 delete(id: string): Promise<{ data: { deleted: boolean; id: string } }> {
1800 return apiFetch<{ data: { deleted: boolean; id: string } }>(
1801 `/v1/templates/${encodeURIComponent(id)}`,
1802 { method: "DELETE" },
1803 );
1804 },
1805
1806 /** Render a template with variable substitution. */
1807 render(
1808 id: string,
1809 variables: Record<string, unknown>,
1810 ): Promise<{ data: TemplateRenderResult }> {
1811 return apiFetch<{ data: TemplateRenderResult }>(
1812 `/v1/templates/${encodeURIComponent(id)}/render`,
1813 { method: "POST", body: JSON.stringify({ variables }) },
1814 );
1815 },
1816};
Addedapps/web/lib/offline-store.ts+622−0View fileUnifiedSplit
@@ -0,0 +1,622 @@
1"use client";
2
3/**
4 * AlecRae Offline Store — IndexedDB-backed email cache
5 *
6 * Core of the offline-first architecture. All reads hit local cache first
7 * (sub-50ms), background sync keeps data fresh from the server.
8 *
9 * Database: "alecrae_mail" (version 1)
10 * Object stores:
11 * - emails — cached email messages (key: id)
12 * - drafts — locally saved drafts (key: id)
13 * - outbox — emails queued for sending while offline (key: id)
14 * - sync_meta — sync cursor/timestamp tracking (key: storeName)
15 *
16 * Uses the native IndexedDB API directly — no Dexie or other wrapper libraries.
17 */
18
19import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
20
21// ─── Constants ──────────────────────────────────────────────────────────────
22
23const DB_NAME = "alecrae_mail";
24const DB_VERSION = 1;
25
26const STORE_EMAILS = "emails" as const;
27const STORE_DRAFTS = "drafts" as const;
28const STORE_OUTBOX = "outbox" as const;
29const STORE_SYNC_META = "sync_meta" as const;
30const STORE_QUEUED_ACTIONS = "queued_actions" as const;
31
32// ─── Types ──────────────────────────────────────────────────────────────────
33
34export interface EmailContact {
35 email: string;
36 name?: string;
37}
38
39export interface CachedEmail {
40 id: string;
41 messageId: string;
42 from: EmailContact;
43 to: EmailContact[];
44 cc?: EmailContact[];
45 subject: string;
46 preview: string;
47 textBody?: string;
48 htmlBody?: string;
49 status: string;
50 tags: string[];
51 hasAttachments: boolean;
52 starred: boolean;
53 read: boolean;
54 snoozedUntil?: string;
55 createdAt: string;
56 updatedAt: string;
57 sentAt: string | null;
58 /** Timestamp (ms since epoch) for cache invalidation */
59 cachedAt: number;
60}
61
62export interface CachedDraft {
63 id: string;
64 to: EmailContact[];
65 cc?: EmailContact[];
66 bcc?: EmailContact[];
67 subject: string;
68 body: string;
69 bodyFormat: "text" | "html";
70 replyToId?: string;
71 forwardOfId?: string;
72 savedAt: number;
73}
74
75export interface OutboxEmail {
76 id: string;
77 to: EmailContact[];
78 cc?: EmailContact[];
79 bcc?: EmailContact[];
80 subject: string;
81 body: string;
82 bodyFormat: "text" | "html";
83 replyToId?: string;
84 forwardOfId?: string;
85 queuedAt: number;
86 retryCount: number;
87 lastError?: string;
88}
89
90export type QueuedActionType = "star" | "unstar" | "archive" | "delete" | "read" | "unread";
91
92export interface QueuedAction {
93 id: string;
94 emailId: string;
95 action: QueuedActionType;
96 queuedAt: number;
97}
98
99interface SyncMeta {
100 storeName: string;
101 cursor: string;
102 updatedAt: number;
103}
104
105export type EmailFilter = "all" | "unread" | "starred" | "sent";
106
107export interface CacheStats {
108 emailCount: number;
109 draftCount: number;
110 outboxCount: number;
111 lastSyncAt: string | null;
112}
113
114// ─── Database Connection ────────────────────────────────────────────────────
115
116let dbInstance: IDBDatabase | null = null;
117
118/**
119 * Opens (or upgrades) the alecrae_mail IndexedDB database.
120 * Creates all four object stores and the required indexes on first run.
121 * Subsequent calls return the cached database handle.
122 */
123export function openDB(): Promise<IDBDatabase> {
124 if (dbInstance !== null) {
125 return Promise.resolve(dbInstance);
126 }
127
128 return new Promise<IDBDatabase>((resolve, reject) => {
129 const request: IDBOpenDBRequest = indexedDB.open(DB_NAME, DB_VERSION);
130
131 request.onupgradeneeded = (event: IDBVersionChangeEvent): void => {
132 const db: IDBDatabase = (event.target as IDBOpenDBRequest).result;
133
134 // ── emails store ──────────────────────────────────────────────────
135 if (!db.objectStoreNames.contains(STORE_EMAILS)) {
136 const emailStore: IDBObjectStore = db.createObjectStore(STORE_EMAILS, {
137 keyPath: "id",
138 });
139 emailStore.createIndex("by-status", "status", { unique: false });
140 emailStore.createIndex("by-starred", "starred", { unique: false });
141 emailStore.createIndex("by-read", "read", { unique: false });
142 emailStore.createIndex("by-createdAt", "createdAt", { unique: false });
143 emailStore.createIndex("by-from", "from.email", { unique: false });
144 }
145
146 // ── drafts store ──────────────────────────────────────────────────
147 if (!db.objectStoreNames.contains(STORE_DRAFTS)) {
148 db.createObjectStore(STORE_DRAFTS, { keyPath: "id" });
149 }
150
151 // ── outbox store ──────────────────────────────────────────────────
152 if (!db.objectStoreNames.contains(STORE_OUTBOX)) {
153 db.createObjectStore(STORE_OUTBOX, { keyPath: "id" });
154 }
155
156 // ── sync_meta store ───────────────────────────────────────────────
157 if (!db.objectStoreNames.contains(STORE_SYNC_META)) {
158 db.createObjectStore(STORE_SYNC_META, { keyPath: "storeName" });
159 }
160
161 // ── queued_actions store (offline action queue for sync) ────────
162 if (!db.objectStoreNames.contains(STORE_QUEUED_ACTIONS)) {
163 db.createObjectStore(STORE_QUEUED_ACTIONS, { keyPath: "id" });
164 }
165 };
166
167 request.onsuccess = (): void => {
168 dbInstance = request.result;
169
170 // Reset the cached handle if the database is closed externally
171 dbInstance.onclose = (): void => {
172 dbInstance = null;
173 };
174
175 resolve(dbInstance);
176 };
177
178 request.onerror = (): void => {
179 reject(
180 new Error(
181 `Failed to open IndexedDB "${DB_NAME}": ${String(request.error?.message ?? "unknown error")}`,
182 ),
183 );
184 };
185 });
186}
187
188// ─── Internal Helpers ───────────────────────────────────────────────────────
189
190/** Promisify an IDBTransaction's completion. */
191function txComplete(tx: IDBTransaction): Promise<void> {
192 return new Promise<void>((resolve, reject) => {
193 tx.oncomplete = (): void => resolve();
194 tx.onerror = (): void => reject(tx.error);
195 });
196}
197
198/** Promisify a single IDBRequest. */
199function reqResult<T>(request: IDBRequest<T>): Promise<T> {
200 return new Promise<T>((resolve, reject) => {
201 request.onsuccess = (): void => resolve(request.result);
202 request.onerror = (): void => reject(request.error);
203 });
204}
205
206// ─── Emails ─────────────────────────────────────────────────────────────────
207
208/**
209 * Bulk upsert emails into the cache.
210 * Stamps `cachedAt` with the current time if not already set to a positive value.
211 */
212export async function cacheEmails(emails: CachedEmail[]): Promise<void> {
213 if (emails.length === 0) return;
214
215 const db: IDBDatabase = await openDB();
216 const tx: IDBTransaction = db.transaction(STORE_EMAILS, "readwrite");
217 const store: IDBObjectStore = tx.objectStore(STORE_EMAILS);
218 const now: number = Date.now();
219
220 for (const email of emails) {
221 const record: CachedEmail = {
222 ...email,
223 cachedAt: email.cachedAt > 0 ? email.cachedAt : now,
224 };
225 store.put(record);
226 }
227
228 await txComplete(tx);
229}
230
231/**
232 * Read cached emails with optional filtering and limit.
233 * Results are sorted by `createdAt` descending (newest first).
234 */
235export async function getCachedEmails(options?: {
236 limit?: number;
237 filter?: EmailFilter;
238}): Promise<CachedEmail[]> {
239 const db: IDBDatabase = await openDB();
240 const tx: IDBTransaction = db.transaction(STORE_EMAILS, "readonly");
241 const store: IDBObjectStore = tx.objectStore(STORE_EMAILS);
242
243 const filter: EmailFilter = options?.filter ?? "all";
244 const limit: number | undefined = options?.limit;
245
246 let results: CachedEmail[];
247
248 const allEmails: CachedEmail[] = await reqResult<CachedEmail[]>(store.getAll());
249
250 if (filter === "unread") {
251 results = allEmails.filter((e: CachedEmail): boolean => !e.read);
252 } else if (filter === "starred") {
253 results = allEmails.filter((e: CachedEmail): boolean => e.starred);
254 } else if (filter === "sent") {
255 results = allEmails.filter((e: CachedEmail): boolean => e.status === "sent");
256 } else {
257 results = allEmails;
258 }
259
260 // Sort newest first by ISO date string (lexicographic comparison works for ISO 8601)
261 results.sort((a: CachedEmail, b: CachedEmail): number => {
262 if (a.createdAt > b.createdAt) return -1;
263 if (a.createdAt < b.createdAt) return 1;
264 return 0;
265 });
266
267 if (limit !== undefined && limit > 0) {
268 return results.slice(0, limit);
269 }
270
271 return results;
272}
273
274/**
275 * Retrieve a single cached email by its ID.
276 * Returns `undefined` if not found.
277 */
278export async function getCachedEmail(id: string): Promise<CachedEmail | undefined> {
279 const db: IDBDatabase = await openDB();
280 const tx: IDBTransaction = db.transaction(STORE_EMAILS, "readonly");
281 const store: IDBObjectStore = tx.objectStore(STORE_EMAILS);
282 const result: CachedEmail | undefined = await reqResult<CachedEmail | undefined>(
283 store.get(id),
284 );
285 return result;
286}
287
288/**
289 * Partially update a cached email. Merges `updates` into the existing record.
290 * The `id` field is immutable and cannot be changed via updates.
291 * No-op if the email does not exist in cache.
292 */
293export async function updateCachedEmail(
294 id: string,
295 updates: Partial<CachedEmail>,
296): Promise<void> {
297 const db: IDBDatabase = await openDB();
298 const tx: IDBTransaction = db.transaction(STORE_EMAILS, "readwrite");
299 const store: IDBObjectStore = tx.objectStore(STORE_EMAILS);
300
301 const existing: CachedEmail | undefined = await reqResult<CachedEmail | undefined>(
302 store.get(id),
303 );
304 if (existing === undefined) return;
305
306 const merged: CachedEmail = {
307 ...existing,
308 ...updates,
309 id: existing.id, // id is immutable
310 cachedAt: Date.now(),
311 };
312
313 store.put(merged);
314 await txComplete(tx);
315}
316
317/**
318 * Remove a single email from the cache by ID.
319 */
320export async function deleteCachedEmail(id: string): Promise<void> {
321 const db: IDBDatabase = await openDB();
322 const tx: IDBTransaction = db.transaction(STORE_EMAILS, "readwrite");
323 tx.objectStore(STORE_EMAILS).delete(id);
324 await txComplete(tx);
325}
326
327// ─── Drafts ─────────────────────────────────────────────────────────────────
328
329/**
330 * Save (or overwrite) a draft locally.
331 */
332export async function saveDraft(draft: CachedDraft): Promise<void> {
333 const db: IDBDatabase = await openDB();
334 const tx: IDBTransaction = db.transaction(STORE_DRAFTS, "readwrite");
335 tx.objectStore(STORE_DRAFTS).put(draft);
336 await txComplete(tx);
337}
338
339/**
340 * List all locally saved drafts, newest first (by `savedAt`).
341 */
342export async function getDrafts(): Promise<CachedDraft[]> {
343 const db: IDBDatabase = await openDB();
344 const tx: IDBTransaction = db.transaction(STORE_DRAFTS, "readonly");
345 const results: CachedDraft[] = await reqResult<CachedDraft[]>(
346 tx.objectStore(STORE_DRAFTS).getAll(),
347 );
348 results.sort((a: CachedDraft, b: CachedDraft): number => b.savedAt - a.savedAt);
349 return results;
350}
351
352/**
353 * Delete a draft by ID.
354 */
355export async function deleteDraft(id: string): Promise<void> {
356 const db: IDBDatabase = await openDB();
357 const tx: IDBTransaction = db.transaction(STORE_DRAFTS, "readwrite");
358 tx.objectStore(STORE_DRAFTS).delete(id);
359 await txComplete(tx);
360}
361
362// ─── Outbox ─────────────────────────────────────────────────────────────────
363
364/**
365 * Queue an email for sending when the client comes back online.
366 */
367export async function queueOutboxEmail(email: OutboxEmail): Promise<void> {
368 const db: IDBDatabase = await openDB();
369 const tx: IDBTransaction = db.transaction(STORE_OUTBOX, "readwrite");
370 tx.objectStore(STORE_OUTBOX).put(email);
371 await txComplete(tx);
372}
373
374/**
375 * Get all emails waiting in the outbox, oldest first (by `queuedAt`).
376 */
377export async function getOutboxEmails(): Promise<OutboxEmail[]> {
378 const db: IDBDatabase = await openDB();
379 const tx: IDBTransaction = db.transaction(STORE_OUTBOX, "readonly");
380 const results: OutboxEmail[] = await reqResult<OutboxEmail[]>(
381 tx.objectStore(STORE_OUTBOX).getAll(),
382 );
383 results.sort((a: OutboxEmail, b: OutboxEmail): number => a.queuedAt - b.queuedAt);
384 return results;
385}
386
387/**
388 * Remove an outbox email after it has been successfully sent.
389 */
390export async function removeOutboxEmail(id: string): Promise<void> {
391 const db: IDBDatabase = await openDB();
392 const tx: IDBTransaction = db.transaction(STORE_OUTBOX, "readwrite");
393 tx.objectStore(STORE_OUTBOX).delete(id);
394 await txComplete(tx);
395}
396
397// ─── Sync Meta ──────────────────────────────────────────────────────────────
398
399/**
400 * Get the last sync cursor for a given store name.
401 * Returns `undefined` if the store has never been synced.
402 */
403export async function getSyncCursor(storeName: string): Promise<string | undefined> {
404 const db: IDBDatabase = await openDB();
405 const tx: IDBTransaction = db.transaction(STORE_SYNC_META, "readonly");
406 const result: SyncMeta | undefined = await reqResult<SyncMeta | undefined>(
407 tx.objectStore(STORE_SYNC_META).get(storeName),
408 );
409 return result?.cursor;
410}
411
412/**
413 * Update the sync cursor for a given store name.
414 */
415export async function setSyncCursor(storeName: string, cursor: string): Promise<void> {
416 const db: IDBDatabase = await openDB();
417 const tx: IDBTransaction = db.transaction(STORE_SYNC_META, "readwrite");
418 const meta: SyncMeta = {
419 storeName,
420 cursor,
421 updatedAt: Date.now(),
422 };
423 tx.objectStore(STORE_SYNC_META).put(meta);
424 await txComplete(tx);
425}
426
427// ─── Queued Actions (Offline Conflict Resolution) ───────────────────────────
428
429/**
430 * Queue an offline action (star, archive, delete, etc.) for later sync.
431 * Deduplicates by emailId + action type — the latest action wins.
432 */
433export async function queueAction(action: QueuedAction): Promise<void> {
434 const db: IDBDatabase = await openDB();
435 const tx: IDBTransaction = db.transaction(STORE_QUEUED_ACTIONS, "readwrite");
436 const store: IDBObjectStore = tx.objectStore(STORE_QUEUED_ACTIONS);
437
438 // Remove any existing action for the same email + action type
439 const existing: QueuedAction[] = await reqResult<QueuedAction[]>(store.getAll());
440 for (const item of existing) {
441 if (item.emailId === action.emailId && item.action === action.action) {
442 store.delete(item.id);
443 }
444 }
445
446 store.put(action);
447 await txComplete(tx);
448}
449
450/**
451 * Get all queued offline actions, oldest first.
452 */
453export async function getQueuedActions(): Promise<QueuedAction[]> {
454 const db: IDBDatabase = await openDB();
455 const tx: IDBTransaction = db.transaction(STORE_QUEUED_ACTIONS, "readonly");
456 const results: QueuedAction[] = await reqResult<QueuedAction[]>(
457 tx.objectStore(STORE_QUEUED_ACTIONS).getAll(),
458 );
459 results.sort((a: QueuedAction, b: QueuedAction): number => a.queuedAt - b.queuedAt);
460 return results;
461}
462
463/**
464 * Remove a specific queued action after it has been applied to the server.
465 */
466export async function removeQueuedAction(id: string): Promise<void> {
467 const db: IDBDatabase = await openDB();
468 const tx: IDBTransaction = db.transaction(STORE_QUEUED_ACTIONS, "readwrite");
469 tx.objectStore(STORE_QUEUED_ACTIONS).delete(id);
470 await txComplete(tx);
471}
472
473/**
474 * Clear all queued actions (e.g. after a full sync).
475 */
476export async function clearQueuedActions(): Promise<void> {
477 const db: IDBDatabase = await openDB();
478 const tx: IDBTransaction = db.transaction(STORE_QUEUED_ACTIONS, "readwrite");
479 tx.objectStore(STORE_QUEUED_ACTIONS).clear();
480 await txComplete(tx);
481}
482
483// ─── Utilities ──────────────────────────────────────────────────────────────
484
485/**
486 * Wipe all data from every object store.
487 * Intended for logout or account reset.
488 */
489export async function clearAllData(): Promise<void> {
490 const db: IDBDatabase = await openDB();
491 const storeNames: string[] = [
492 STORE_EMAILS,
493 STORE_DRAFTS,
494 STORE_OUTBOX,
495 STORE_SYNC_META,
496 STORE_QUEUED_ACTIONS,
497 ];
498 const tx: IDBTransaction = db.transaction(storeNames, "readwrite");
499
500 for (const name of storeNames) {
501 tx.objectStore(name).clear();
502 }
503
504 await txComplete(tx);
505}
506
507/**
508 * Return aggregate statistics about the local cache.
509 */
510export async function getCacheStats(): Promise<CacheStats> {
511 const db: IDBDatabase = await openDB();
512
513 const storeNames: string[] = [
514 STORE_EMAILS,
515 STORE_DRAFTS,
516 STORE_OUTBOX,
517 STORE_SYNC_META,
518 ];
519 const tx: IDBTransaction = db.transaction(storeNames, "readonly");
520
521 const emailCountReq: IDBRequest<number> = tx.objectStore(STORE_EMAILS).count();
522 const draftCountReq: IDBRequest<number> = tx.objectStore(STORE_DRAFTS).count();
523 const outboxCountReq: IDBRequest<number> = tx.objectStore(STORE_OUTBOX).count();
524 const syncMetaReq: IDBRequest<SyncMeta[]> = tx.objectStore(STORE_SYNC_META).getAll();
525
526 const [emailCount, draftCount, outboxCount, syncRecords] = await Promise.all([
527 reqResult(emailCountReq),
528 reqResult(draftCountReq),
529 reqResult(outboxCountReq),
530 reqResult(syncMetaReq),
531 ]);
532
533 let lastSyncAt: string | null = null;
534
535 if (syncRecords.length > 0) {
536 let mostRecentTimestamp = 0;
537 for (const record of syncRecords) {
538 if (record.updatedAt > mostRecentTimestamp) {
539 mostRecentTimestamp = record.updatedAt;
540 }
541 }
542 lastSyncAt = new Date(mostRecentTimestamp).toISOString();
543 }
544
545 return { emailCount, draftCount, outboxCount, lastSyncAt };
546}
547
548// ─── React Hook ─────────────────────────────────────────────────────────────
549
550function subscribeOnline(callback: () => void): () => void {
551 window.addEventListener("online", callback);
552 window.addEventListener("offline", callback);
553 return (): void => {
554 window.removeEventListener("online", callback);
555 window.removeEventListener("offline", callback);
556 };
557}
558
559function getOnlineSnapshot(): boolean {
560 return navigator.onLine;
561}
562
563function getServerSnapshot(): boolean {
564 // During SSR, assume online
565 return true;
566}
567
568interface OfflineStoreState {
569 isOnline: boolean;
570 cachedCount: number;
571 outboxCount: number;
572 lastSyncAt: string | null;
573}
574
575/**
576 * React hook that tracks online/offline status and cache statistics.
577 * Refreshes stats on mount, when online status changes, and via the
578 * returned `refresh` callback.
579 */
580export function useOfflineStore(): OfflineStoreState & { refresh: () => void } {
581 const isOnline: boolean = useSyncExternalStore(
582 subscribeOnline,
583 getOnlineSnapshot,
584 getServerSnapshot,
585 );
586
587 const [stats, setStats] = useState<{
588 cachedCount: number;
589 outboxCount: number;
590 lastSyncAt: string | null;
591 }>({
592 cachedCount: 0,
593 outboxCount: 0,
594 lastSyncAt: null,
595 });
596
597 const refresh = useCallback((): void => {
598 getCacheStats()
599 .then((result: CacheStats): void => {
600 setStats({
601 cachedCount: result.emailCount,
602 outboxCount: result.outboxCount,
603 lastSyncAt: result.lastSyncAt,
604 });
605 })
606 .catch((): void => {
607 // IndexedDB unavailable (e.g. incognito in some browsers) — keep defaults
608 });
609 }, []);
610
611 useEffect((): void => {
612 refresh();
613 }, [isOnline, refresh]);
614
615 return {
616 isOnline,
617 cachedCount: stats.cachedCount,
618 outboxCount: stats.outboxCount,
619 lastSyncAt: stats.lastSyncAt,
620 refresh,
621 };
622}
Addedapps/web/lib/register-sw.ts+235−0View fileUnifiedSplit
@@ -0,0 +1,235 @@
1'use client';
2
3import { useCallback, useEffect, useRef, useState } from 'react';
4
5// ─── Service Worker Registration ────────────────────────────────────────────
6
7type UpdateCallback = (registration: ServiceWorkerRegistration) => void;
8
9/**
10 * Register the AlecRae service worker and listen for updates.
11 *
12 * Returns a cleanup function that removes the listener when the calling
13 * component unmounts.
14 */
15function registerServiceWorker(
16 onUpdate?: UpdateCallback,
17): () => void {
18 if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
19 return () => {};
20 }
21
22 let cancelled = false;
23
24 navigator.serviceWorker
25 .register('/sw.js')
26 .then((registration: ServiceWorkerRegistration) => {
27 if (cancelled) return;
28
29 registration.addEventListener('updatefound', () => {
30 const installing = registration.installing;
31 if (!installing) return;
32
33 installing.addEventListener('statechange', () => {
34 if (
35 installing.state === 'installed' &&
36 navigator.serviceWorker.controller
37 ) {
38 // A new version is available and waiting to activate.
39 onUpdate?.(registration);
40 }
41 });
42 });
43 })
44 .catch((error: unknown) => {
45 if (!cancelled) {
46 console.error('[AlecRae] Service worker registration failed:', error);
47 }
48 });
49
50 return (): void => {
51 cancelled = true;
52 };
53}
54
55// ─── Notification Permission ────────────────────────────────────────────────
56
57/**
58 * Request notification permission from the user.
59 *
60 * Resolves to `true` when permission is granted, `false` otherwise.
61 */
62async function requestNotificationPermission(): Promise<boolean> {
63 if (typeof window === 'undefined' || !('Notification' in window)) {
64 return false;
65 }
66
67 if (Notification.permission === 'granted') {
68 return true;
69 }
70
71 if (Notification.permission === 'denied') {
72 return false;
73 }
74
75 const result = await Notification.requestPermission();
76 return result === 'granted';
77}
78
79// ─── usePWA Hook ────────────────────────────────────────────────────────────
80
81interface BeforeInstallPromptEvent extends Event {
82 readonly platforms: ReadonlyArray<string>;
83 readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
84 prompt(): Promise<void>;
85}
86
87interface UsePWAReturn {
88 /** Whether the app is running in standalone / installed PWA mode. */
89 isInstalled: boolean;
90 /** Whether the browser has offered the A2HS install prompt. */
91 canInstall: boolean;
92 /** Trigger the native install prompt. */
93 promptInstall: () => Promise<void>;
94 /** Whether a new service worker is waiting to activate. */
95 hasUpdate: boolean;
96 /** Send `skipWaiting` to the waiting service worker and reload. */
97 applyUpdate: () => void;
98 /** Whether the user has granted notification permission. */
99 notificationsEnabled: boolean;
100 /** Request notification permission. Returns `true` if granted. */
101 enableNotifications: () => Promise<boolean>;
102}
103
104function usePWA(): UsePWAReturn {
105 const [isInstalled, setIsInstalled] = useState<boolean>(false);
106 const [canInstall, setCanInstall] = useState<boolean>(false);
107 const [hasUpdate, setHasUpdate] = useState<boolean>(false);
108 const [notificationsEnabled, setNotificationsEnabled] = useState<boolean>(false);
109
110 const deferredPromptRef = useRef<BeforeInstallPromptEvent | null>(null);
111 const waitingWorkerRef = useRef<ServiceWorker | null>(null);
112
113 // ── Detect standalone mode ──
114 useEffect((): (() => void) | undefined => {
115 if (typeof window === 'undefined') return undefined;
116
117 const mql = window.matchMedia('(display-mode: standalone)');
118 setIsInstalled(mql.matches);
119
120 const handler = (e: MediaQueryListEvent): void => {
121 setIsInstalled(e.matches);
122 };
123
124 mql.addEventListener('change', handler);
125 return (): void => {
126 mql.removeEventListener('change', handler);
127 };
128 }, []);
129
130 // ── Capture beforeinstallprompt ──
131 useEffect((): (() => void) | undefined => {
132 if (typeof window === 'undefined') return undefined;
133
134 const handler = (e: Event): void => {
135 e.preventDefault();
136 deferredPromptRef.current = e as BeforeInstallPromptEvent;
137 setCanInstall(true);
138 };
139
140 window.addEventListener('beforeinstallprompt', handler);
141 return (): void => {
142 window.removeEventListener('beforeinstallprompt', handler);
143 };
144 }, []);
145
146 // ── Register service worker + listen for updates ──
147 useEffect((): (() => void) | undefined => {
148 if (typeof window === 'undefined') return undefined;
149
150 const cleanup = registerServiceWorker(
151 (registration: ServiceWorkerRegistration): void => {
152 waitingWorkerRef.current = registration.waiting;
153 setHasUpdate(true);
154 },
155 );
156
157 return cleanup;
158 }, []);
159
160 // ── Check existing notification permission ──
161 useEffect((): undefined => {
162 if (typeof window === 'undefined' || !('Notification' in window)) return undefined;
163 setNotificationsEnabled(Notification.permission === 'granted');
164 return undefined;
165 }, []);
166
167 // ── Listen for controlling SW change to auto-reload ──
168 useEffect((): (() => void) | undefined => {
169 if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
170 return undefined;
171 }
172
173 let refreshing = false;
174
175 const handler = (): void => {
176 if (refreshing) return;
177 refreshing = true;
178 window.location.reload();
179 };
180
181 navigator.serviceWorker.addEventListener('controllerchange', handler);
182 return (): void => {
183 navigator.serviceWorker.removeEventListener('controllerchange', handler);
184 };
185 }, []);
186
187 // ── Actions ──
188
189 const promptInstall = useCallback(async (): Promise<void> => {
190 const prompt = deferredPromptRef.current;
191 if (!prompt) return;
192
193 await prompt.prompt();
194 const choice = await prompt.userChoice;
195
196 if (choice.outcome === 'accepted') {
197 setIsInstalled(true);
198 }
199
200 deferredPromptRef.current = null;
201 setCanInstall(false);
202 }, []);
203
204 const applyUpdate = useCallback((): void => {
205 const waiting = waitingWorkerRef.current;
206 if (!waiting) return;
207
208 waiting.postMessage({ type: 'SKIP_WAITING' });
209 // The `controllerchange` listener above handles the reload.
210 }, []);
211
212 const enableNotifications = useCallback(async (): Promise<boolean> => {
213 const granted = await requestNotificationPermission();
214 setNotificationsEnabled(granted);
215 return granted;
216 }, []);
217
218 return {
219 isInstalled,
220 canInstall,
221 promptInstall,
222 hasUpdate,
223 applyUpdate,
224 notificationsEnabled,
225 enableNotifications,
226 };
227}
228
229export {
230 registerServiceWorker,
231 requestNotificationPermission,
232 usePWA,
233};
234
235export type { UsePWAReturn, BeforeInstallPromptEvent };
Addedapps/web/lib/sync-engine.ts+591−0View fileUnifiedSplit
@@ -0,0 +1,591 @@
1"use client";
2
3/**
4 * AlecRae Background Sync Engine
5 *
6 * Coordinates between the IndexedDB local cache and the remote API.
7 * Handles periodic polling, outbox flushing, offline action queuing,
8 * network awareness, and conflict resolution (last-write-wins).
9 *
10 * Architecture:
11 * - Polls the server every 30s (configurable) for new emails
12 * - Flushes queued outbox emails when online
13 * - Applies queued offline actions (star, archive, delete) to the server
14 * - Pauses automatically when offline, resumes when back online
15 * - Emits typed events for UI reactivity
16 * - Singleton pattern via getSyncEngine()
17 */
18
19import {
20 cacheEmails,
21 getOutboxEmails,
22 removeOutboxEmail,
23 getSyncCursor,
24 setSyncCursor,
25 getQueuedActions,
26 removeQueuedAction,
27 type CachedEmail,
28} from "./offline-store";
29
30import { messagesApi, type Message } from "./api";
31import { useState, useEffect, useCallback, useRef } from "react";
32
33// ─── Constants ──────────────────────────────────────────────────────────────
34
35const DEFAULT_SYNC_INTERVAL_MS = 30_000;
36const SYNC_PAGE_LIMIT = 50;
37
38// ─── Event Types ────────────────────────────────────────────────────────────
39
40export type SyncEventType =
41 | "sync:start"
42 | "sync:complete"
43 | "sync:error"
44 | "sync:new-emails"
45 | "outbox:sent"
46 | "outbox:failed";
47
48export interface SyncStartEvent {
49 type: "sync:start";
50 timestamp: number;
51}
52
53export interface SyncCompleteEvent {
54 type: "sync:complete";
55 timestamp: number;
56 newEmailCount: number;
57 syncedActions: number;
58}
59
60export interface SyncErrorEvent {
61 type: "sync:error";
62 timestamp: number;
63 error: string;
64}
65
66export interface SyncNewEmailsEvent {
67 type: "sync:new-emails";
68 timestamp: number;
69 emails: CachedEmail[];
70}
71
72export interface OutboxSentEvent {
73 type: "outbox:sent";
74 timestamp: number;
75 emailId: string;
76}
77
78export interface OutboxFailedEvent {
79 type: "outbox:failed";
80 timestamp: number;
81 emailId: string;
82 error: string;
83}
84
85export type SyncEvent =
86 | SyncStartEvent
87 | SyncCompleteEvent
88 | SyncErrorEvent
89 | SyncNewEmailsEvent
90 | OutboxSentEvent
91 | OutboxFailedEvent;
92
93type SyncEventCallback = (event: SyncEvent) => void;
94
95// ─── Message → CachedEmail Mapper ───────────────────────────────────────────
96
97function mapContact(addr: { email: string; name?: string }): { email: string; name?: string } {
98 const contact: { email: string; name?: string } = { email: addr.email };
99 if (addr.name !== undefined) {
100 contact.name = addr.name;
101 }
102 return contact;
103}
104
105function mapMessageToCachedEmail(message: Message): CachedEmail {
106 const tags: string[] = message.tags ?? [];
107 return {
108 id: message.id,
109 messageId: message.messageId,
110 from: mapContact(message.from),
111 to: message.to.map(mapContact),
112 cc: (message.cc ?? []).map(mapContact),
113 subject: message.subject,
114 preview: message.preview,
115 status: message.status,
116 tags,
117 hasAttachments: message.hasAttachments,
118 starred: tags.includes("starred"),
119 read: !tags.includes("unread"),
120 createdAt: message.createdAt,
121 updatedAt: message.updatedAt,
122 sentAt: message.sentAt,
123 cachedAt: Date.now(),
124 };
125}
126
127// ─── SyncEngine Class ───────────────────────────────────────────────────────
128
129export class SyncEngine {
130 private intervalId: ReturnType<typeof setInterval> | null = null;
131 private intervalMs: number = DEFAULT_SYNC_INTERVAL_MS;
132 private syncing: boolean = false;
133 private online: boolean = true;
134 private lastSyncAt: Date | null = null;
135 private lastError: string | null = null;
136 private listeners: Map<SyncEventType, Set<SyncEventCallback>> = new Map();
137 private boundOnline: (() => void) | null = null;
138 private boundOffline: (() => void) | null = null;
139
140 constructor() {
141 if (typeof window !== "undefined") {
142 this.online = navigator.onLine;
143 this.boundOnline = this.handleOnline.bind(this);
144 this.boundOffline = this.handleOffline.bind(this);
145 window.addEventListener("online", this.boundOnline);
146 window.addEventListener("offline", this.boundOffline);
147 }
148 }
149
150 // ─── Event System ───────────────────────────────────────────────────────
151
152 /** Subscribe to a sync event. Returns an unsubscribe function. */
153 on(eventType: SyncEventType, callback: SyncEventCallback): () => void {
154 let callbacks = this.listeners.get(eventType);
155 if (!callbacks) {
156 callbacks = new Set();
157 this.listeners.set(eventType, callbacks);
158 }
159 callbacks.add(callback);
160 return (): void => {
161 callbacks?.delete(callback);
162 };
163 }
164
165 /** Remove a specific listener. */
166 off(eventType: SyncEventType, callback: SyncEventCallback): void {
167 const callbacks = this.listeners.get(eventType);
168 if (callbacks) {
169 callbacks.delete(callback);
170 }
171 }
172
173 private emit(event: SyncEvent): void {
174 const callbacks = this.listeners.get(event.type);
175 if (callbacks) {
176 for (const cb of callbacks) {
177 try {
178 cb(event);
179 } catch {
180 // Swallow listener errors — never let a listener crash the engine
181 }
182 }
183 }
184 }
185
186 // ─── Network Awareness ──────────────────────────────────────────────────
187
188 private handleOnline(): void {
189 this.online = true;
190 // Resume sync and flush outbox immediately when coming back online
191 if (this.intervalId !== null) {
192 // Periodic sync was active before going offline — trigger immediate sync
193 void this.syncNow();
194 }
195 }
196
197 private handleOffline(): void {
198 this.online = false;
199 // Sync will naturally skip on next tick since we check this.online
200 }
201
202 /** Returns current network status. */
203 getOnlineStatus(): boolean {
204 return this.online;
205 }
206
207 // ─── Periodic Sync ─────────────────────────────────────────────────────
208
209 /** Start periodic background sync. Default interval: 30 seconds. */
210 startPeriodicSync(intervalMs?: number): void {
211 if (this.intervalId !== null) {
212 // Already running — stop and restart with new interval
213 this.stopPeriodicSync();
214 }
215
216 this.intervalMs = intervalMs ?? DEFAULT_SYNC_INTERVAL_MS;
217
218 // Run an immediate sync, then set up the interval
219 void this.syncNow();
220
221 this.intervalId = setInterval(() => {
222 void this.syncNow();
223 }, this.intervalMs);
224 }
225
226 /** Stop periodic background sync. */
227 stopPeriodicSync(): void {
228 if (this.intervalId !== null) {
229 clearInterval(this.intervalId);
230 this.intervalId = null;
231 }
232 }
233
234 /** Returns true if periodic sync is currently active. */
235 isRunning(): boolean {
236 return this.intervalId !== null;
237 }
238
239 // ─── Core Sync ──────────────────────────────────────────────────────────
240
241 /** Perform an immediate full sync: fetch new emails, apply queued actions, flush outbox. */
242 async syncNow(): Promise<void> {
243 // Skip if offline or already syncing
244 if (!this.online) return;
245 if (this.syncing) return;
246
247 this.syncing = true;
248 this.lastError = null;
249
250 this.emit({
251 type: "sync:start",
252 timestamp: Date.now(),
253 });
254
255 let newEmailCount = 0;
256 let syncedActions = 0;
257
258 try {
259 // 1. Apply queued offline actions to the server
260 syncedActions = await this.applyQueuedActions();
261
262 // 2. Fetch new emails since last cursor
263 const cursor = await getSyncCursor("emails");
264 let hasMore = true;
265 let currentCursor: string | null | undefined = cursor;
266 const allNewEmails: CachedEmail[] = [];
267
268 while (hasMore) {
269 const listParams: Parameters<typeof messagesApi.list>[0] = {
270 limit: SYNC_PAGE_LIMIT,
271 };
272 if (currentCursor != null) listParams.cursor = currentCursor;
273 const response = await messagesApi.list(listParams);
274
275 const emails = response.data;
276 if (emails.length > 0) {
277 const cached = emails.map(mapMessageToCachedEmail);
278 await cacheEmails(cached);
279 allNewEmails.push(...cached);
280 }
281
282 hasMore = response.hasMore;
283 currentCursor = response.cursor;
284
285 // Update the cursor to the latest position
286 if (response.cursor) {
287 await setSyncCursor("emails", response.cursor);
288 }
289 }
290
291 newEmailCount = allNewEmails.length;
292
293 // Emit new-emails event if we got any
294 if (allNewEmails.length > 0) {
295 this.emit({
296 type: "sync:new-emails",
297 timestamp: Date.now(),
298 emails: allNewEmails,
299 });
300 }
301
302 // 3. Flush the outbox
303 await this.flushOutbox();
304
305 // 4. Record successful sync
306 this.lastSyncAt = new Date();
307
308 this.emit({
309 type: "sync:complete",
310 timestamp: Date.now(),
311 newEmailCount,
312 syncedActions,
313 });
314 } catch (err: unknown) {
315 const message = err instanceof Error ? err.message : "Unknown sync error";
316 this.lastError = message;
317
318 this.emit({
319 type: "sync:error",
320 timestamp: Date.now(),
321 error: message,
322 });
323 } finally {
324 this.syncing = false;
325 }
326 }
327
328 /** Sync a single email from the server to the local cache. */
329 async syncEmail(id: string): Promise<void> {
330 if (!this.online) return;
331
332 try {
333 const response = await messagesApi.get(id);
334 const message = response.data;
335
336 // MessageDetail extends Message, so we can map it the same way
337 const cached = mapMessageToCachedEmail(message);
338
339 // Preserve body content from the detail response
340 if (message.textBody !== null) cached.textBody = message.textBody;
341 if (message.htmlBody !== null) cached.htmlBody = message.htmlBody;
342
343 await cacheEmails([cached]);
344 } catch (err: unknown) {
345 const message = err instanceof Error ? err.message : "Failed to sync email";
346 this.emit({
347 type: "sync:error",
348 timestamp: Date.now(),
349 error: `syncEmail(${id}): ${message}`,
350 });
351 }
352 }
353
354 // ─── Outbox ─────────────────────────────────────────────────────────────
355
356 /** Send all queued outbox emails. Remove from outbox on success. */
357 async flushOutbox(): Promise<void> {
358 if (!this.online) return;
359
360 const outboxEmails = await getOutboxEmails();
361 if (outboxEmails.length === 0) return;
362
363 for (const email of outboxEmails) {
364 try {
365 const sendPayload: Parameters<typeof messagesApi.send>[0] = {
366 from: email.to[0] ?? { email: "" },
367 to: email.to,
368 subject: email.subject,
369 };
370 if (email.cc !== undefined) sendPayload.cc = email.cc;
371 if (email.bcc !== undefined) sendPayload.bcc = email.bcc;
372 if (email.bodyFormat === "text") sendPayload.text = email.body;
373 if (email.bodyFormat === "html") sendPayload.html = email.body;
374 await messagesApi.send(sendPayload);
375 await removeOutboxEmail(email.id);
376
377 this.emit({
378 type: "outbox:sent",
379 timestamp: Date.now(),
380 emailId: email.id,
381 });
382 } catch (err: unknown) {
383 const errorMessage = err instanceof Error ? err.message : "Failed to send";
384
385 this.emit({
386 type: "outbox:failed",
387 timestamp: Date.now(),
388 emailId: email.id,
389 error: errorMessage,
390 });
391 }
392 }
393 }
394
395 // ─── Queued Action Application ──────────────────────────────────────────
396
397 /** Apply all queued offline actions (star, archive, delete, etc.) to the server. */
398 private async applyQueuedActions(): Promise<number> {
399 const actions = await getQueuedActions();
400 if (actions.length === 0) return 0;
401
402 let applied = 0;
403
404 for (const action of actions) {
405 try {
406 switch (action.action) {
407 case "star":
408 await messagesApi.star(action.emailId, true);
409 break;
410 case "unstar":
411 await messagesApi.star(action.emailId, false);
412 break;
413 case "archive":
414 await messagesApi.archive(action.emailId);
415 break;
416 case "delete":
417 await messagesApi.delete(action.emailId);
418 break;
419 case "read":
420 case "unread":
421 // These map to a PATCH on the message — currently no dedicated API method,
422 // so we skip server sync for read status. The local cache is authoritative.
423 break;
424 }
425
426 await removeQueuedAction(action.id);
427 applied++;
428 } catch {
429 // If applying fails, leave the action in the queue for next sync attempt
430 }
431 }
432
433 return applied;
434 }
435
436 // ─── State Accessors ────────────────────────────────────────────────────
437
438 /** Whether a sync operation is currently in progress. */
439 getIsSyncing(): boolean {
440 return this.syncing;
441 }
442
443 /** The timestamp of the last successful sync, or null if never synced. */
444 getLastSyncAt(): Date | null {
445 return this.lastSyncAt;
446 }
447
448 /** The last error message, or null if no error. */
449 getLastError(): string | null {
450 return this.lastError;
451 }
452
453 /** Get the count of emails waiting in the outbox. */
454 async getPendingOutboxCount(): Promise<number> {
455 const emails = await getOutboxEmails();
456 return emails.length;
457 }
458
459 // ─── Cleanup ────────────────────────────────────────────────────────────
460
461 /** Tear down the engine: stop sync, remove event listeners. */
462 destroy(): void {
463 this.stopPeriodicSync();
464
465 if (typeof window !== "undefined") {
466 if (this.boundOnline) {
467 window.removeEventListener("online", this.boundOnline);
468 }
469 if (this.boundOffline) {
470 window.removeEventListener("offline", this.boundOffline);
471 }
472 }
473
474 this.listeners.clear();
475 this.boundOnline = null;
476 this.boundOffline = null;
477 }
478}
479
480// ─── Singleton ──────────────────────────────────────────────────────────────
481
482let singletonInstance: SyncEngine | null = null;
483
484/** Get the singleton SyncEngine instance. Creates one if it does not exist. */
485export function getSyncEngine(): SyncEngine {
486 if (singletonInstance === null) {
487 singletonInstance = new SyncEngine();
488 }
489 return singletonInstance;
490}
491
492// ─── React Hook ─────────────────────────────────────────────────────────────
493
494export interface UseSyncEngineReturn {
495 isSyncing: boolean;
496 isOnline: boolean;
497 lastSyncAt: Date | null;
498 pendingOutbox: number;
499 syncNow: () => Promise<void>;
500 error: string | null;
501}
502
503/** React hook that provides reactive sync engine state for UI components. */
504export function useSyncEngine(): UseSyncEngineReturn {
505 const [isSyncing, setIsSyncing] = useState<boolean>(false);
506 const [isOnline, setIsOnline] = useState<boolean>(
507 typeof navigator !== "undefined" ? navigator.onLine : true,
508 );
509 const [lastSyncAt, setLastSyncAt] = useState<Date | null>(null);
510 const [pendingOutbox, setPendingOutbox] = useState<number>(0);
511 const [error, setError] = useState<string | null>(null);
512
513 const engineRef = useRef<SyncEngine | null>(null);
514
515 useEffect(() => {
516 const engine = getSyncEngine();
517 engineRef.current = engine;
518
519 // Initialize state from engine
520 setIsSyncing(engine.getIsSyncing());
521 setIsOnline(engine.getOnlineStatus());
522 setLastSyncAt(engine.getLastSyncAt());
523 setError(engine.getLastError());
524
525 // Fetch initial outbox count
526 void engine.getPendingOutboxCount().then(setPendingOutbox);
527
528 // Subscribe to events
529 const unsubStart = engine.on("sync:start", () => {
530 setIsSyncing(true);
531 setError(null);
532 });
533
534 const unsubComplete = engine.on("sync:complete", (event) => {
535 setIsSyncing(false);
536 if (event.type === "sync:complete") {
537 setLastSyncAt(new Date(event.timestamp));
538 }
539 void engine.getPendingOutboxCount().then(setPendingOutbox);
540 });
541
542 const unsubError = engine.on("sync:error", (event) => {
543 setIsSyncing(false);
544 if (event.type === "sync:error") {
545 setError(event.error);
546 }
547 });
548
549 const unsubOutboxSent = engine.on("outbox:sent", () => {
550 void engine.getPendingOutboxCount().then(setPendingOutbox);
551 });
552
553 const unsubOutboxFailed = engine.on("outbox:failed", (event) => {
554 if (event.type === "outbox:failed") {
555 setError(event.error);
556 }
557 void engine.getPendingOutboxCount().then(setPendingOutbox);
558 });
559
560 // Online/offline tracking
561 const handleOnline = (): void => setIsOnline(true);
562 const handleOffline = (): void => setIsOnline(false);
563 window.addEventListener("online", handleOnline);
564 window.addEventListener("offline", handleOffline);
565
566 return () => {
567 unsubStart();
568 unsubComplete();
569 unsubError();
570 unsubOutboxSent();
571 unsubOutboxFailed();
572 window.removeEventListener("online", handleOnline);
573 window.removeEventListener("offline", handleOffline);
574 };
575 }, []);
576
577 const syncNow = useCallback(async (): Promise<void> => {
578 if (engineRef.current) {
579 await engineRef.current.syncNow();
580 }
581 }, []);
582
583 return {
584 isSyncing,
585 isOnline,
586 lastSyncAt,
587 pendingOutbox,
588 syncNow,
589 error,
590 };
591}
Modifiedapps/web/next.config.ts+1−1View fileUnifiedSplit
@@ -3,7 +3,7 @@ import type { NextConfig } from "next";
33const nextConfig: NextConfig = {
44 transpilePackages: ["@alecrae/ui"],
55 reactStrictMode: true,
6 // typedRoutes promoted to top-level in Next.js 15
6 // typedRoutes moved out of experimental in Next.js 15
77 typedRoutes: true,
88 typescript: {
99 ignoreBuildErrors: false,
Addedapps/web/public/manifest.json+25−0View fileUnifiedSplit
@@ -0,0 +1,25 @@
1{
2 "name": "AlecRae",
3 "short_name": "AlecRae",
4 "description": "Email, Evolved.",
5 "start_url": "/inbox",
6 "display": "standalone",
7 "background_color": "#0f172a",
8 "theme_color": "#7c3aed",
9 "orientation": "any",
10 "categories": ["email", "productivity"],
11 "icons": [
12 {
13 "src": "/icons/icon-192x192.png",
14 "sizes": "192x192",
15 "type": "image/png",
16 "purpose": "any maskable"
17 },
18 {
19 "src": "/icons/icon-512x512.png",
20 "sizes": "512x512",
21 "type": "image/png",
22 "purpose": "any maskable"
23 }
24 ]
25}
Addedapps/web/public/sw.js+211−0View fileUnifiedSplit
@@ -0,0 +1,211 @@
1/// AlecRae Service Worker
2/// Email, Evolved.
3
4const CACHE_VERSION = 1;
5const CACHE_NAME = 'alecrae-v1';
6
7const APP_SHELL_ROUTES = [
8 '/',
9 '/inbox',
10 '/compose',
11 '/sent',
12 '/drafts',
13 '/settings',
14 '/contacts',
15 '/templates',
16 '/snoozed',
17 '/analytics',
18 '/domains',
19];
20
21// ─── Install ────────────────────────────────────────────────────────────────
22// Pre-cache the app shell so the core UI is available offline immediately.
23
24self.addEventListener('install', (event) => {
25 event.waitUntil(
26 caches.open(CACHE_NAME).then((cache) => {
27 return cache.addAll(APP_SHELL_ROUTES);
28 }),
29 );
30 // Activate the new SW immediately rather than waiting for the old one to release.
31 self.skipWaiting();
32});
33
34// ─── Activate ───────────────────────────────────────────────────────────────
35// Purge caches from previous versions so we never serve stale assets.
36
37self.addEventListener('activate', (event) => {
38 event.waitUntil(
39 caches.keys().then((keys) => {
40 return Promise.all(
41 keys
42 .filter((key) => key !== CACHE_NAME)
43 .map((key) => caches.delete(key)),
44 );
45 }),
46 );
47 // Take control of all open tabs without requiring a reload.
48 self.clients.claim();
49});
50
51// ─── Fetch ──────────────────────────────────────────────────────────────────
52
53/**
54 * Returns true when the URL points to a static asset we should cache
55 * aggressively (JS bundles, CSS, images, fonts).
56 */
57function isStaticAsset(url) {
58 const path = url.pathname;
59 return (
60 path.startsWith('/_next/static/') ||
61 path.startsWith('/static/') ||
62 /\.(js|css|woff2?|ttf|otf|eot|png|jpe?g|gif|webp|avif|svg|ico)$/i.test(
63 path,
64 )
65 );
66}
67
68self.addEventListener('fetch', (event) => {
69 const url = new URL(event.request.url);
70
71 // ── API requests: network-only (IndexedDB handles offline data) ──
72 if (url.pathname.startsWith('/v1/') || url.pathname.startsWith('/api/')) {
73 event.respondWith(fetch(event.request));
74 return;
75 }
76
77 // ── Tracking pixel: network-only, never cache ──
78 if (url.pathname.startsWith('/t/')) {
79 event.respondWith(fetch(event.request));
80 return;
81 }
82
83 // ── Static assets: cache-first with network fallback ──
84 if (isStaticAsset(url)) {
85 event.respondWith(
86 caches.match(event.request).then((cached) => {
87 if (cached) {
88 return cached;
89 }
90 return fetch(event.request).then((response) => {
91 // Only cache successful, same-origin responses.
92 if (
93 response.ok &&
94 response.type === 'basic'
95 ) {
96 const clone = response.clone();
97 caches.open(CACHE_NAME).then((cache) => {
98 cache.put(event.request, clone);
99 });
100 }
101 return response;
102 });
103 }),
104 );
105 return;
106 }
107
108 // ── Navigation requests (HTML): network-first with cache fallback ──
109 if (event.request.mode === 'navigate') {
110 event.respondWith(
111 fetch(event.request)
112 .then((response) => {
113 // Stale-while-revalidate: update the cache for next time.
114 const clone = response.clone();
115 caches.open(CACHE_NAME).then((cache) => {
116 cache.put(event.request, clone);
117 });
118 return response;
119 })
120 .catch(() => {
121 // Offline — serve from cache.
122 return caches.match(event.request).then((cached) => {
123 // Fall back to the root app shell if we don't have the exact page.
124 return cached || caches.match('/');
125 });
126 }),
127 );
128 return;
129 }
130
131 // ── Everything else: network with cache fallback ──
132 event.respondWith(
133 fetch(event.request).catch(() => caches.match(event.request)),
134 );
135});
136
137// ─── Message Handler ────────────────────────────────────────────────────────
138// Accept SKIP_WAITING from the client so a waiting worker can take over.
139
140self.addEventListener('message', (event) => {
141 if (event.data && event.data.type === 'SKIP_WAITING') {
142 self.skipWaiting();
143 }
144});
145
146// ─── Background Sync ────────────────────────────────────────────────────────
147// When the browser regains connectivity it fires a sync event so we can flush
148// queued outbox emails that were composed offline.
149
150self.addEventListener('sync', (event) => {
151 if (event.tag === 'outbox-flush') {
152 event.waitUntil(
153 self.clients.matchAll().then((clients) => {
154 for (const client of clients) {
155 client.postMessage({ type: 'outbox-flush' });
156 }
157 }),
158 );
159 }
160});
161
162// ─── Push Notifications ─────────────────────────────────────────────────────
163
164self.addEventListener('push', (event) => {
165 /** @type {{ title?: string; body?: string; icon?: string; badge?: string; tag?: string; data?: unknown }} */
166 let payload = {};
167
168 if (event.data) {
169 try {
170 payload = event.data.json();
171 } catch {
172 payload = { body: event.data.text() };
173 }
174 }
175
176 const title = payload.title || 'AlecRae';
177 const options = {
178 body: payload.body || 'You have a new email.',
179 icon: payload.icon || '/icons/icon-192x192.png',
180 badge: payload.badge || '/icons/icon-192x192.png',
181 tag: payload.tag || 'alecrae-email',
182 data: payload.data || {},
183 vibrate: [100, 50, 100],
184 actions: [
185 { action: 'open', title: 'Open' },
186 { action: 'dismiss', title: 'Dismiss' },
187 ],
188 };
189
190 event.waitUntil(self.registration.showNotification(title, options));
191});
192
193self.addEventListener('notificationclick', (event) => {
194 event.notification.close();
195
196 if (event.action === 'dismiss') {
197 return;
198 }
199
200 // Open or focus the AlecRae inbox.
201 event.waitUntil(
202 self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
203 for (const client of windowClients) {
204 if (new URL(client.url).pathname.startsWith('/inbox') && 'focus' in client) {
205 return client.focus();
206 }
207 }
208 return self.clients.openWindow('/inbox');
209 }),
210 );
211});
Modifiedpackages/db/src/index.ts+9−1View fileUnifiedSplit
@@ -268,6 +268,9 @@ export {
268268 meetingProviderEnum,
269269 meetingLinkStatusEnum,
270270 meetingLinksRelations,
271 meetingProviderConnections,
272 transcriptProviderEnum,
273 meetingProviderConnectionsRelations,
271274} from "./schema/meeting-links.js";
272275
273276// Schema - Saved Queries & Query History (B2 — email-as-database)
@@ -734,7 +737,10 @@ import type {
734737 userAchievements,
735738 dailyStats,
736739} from "./schema/gamification.js";
737import type { meetingLinks } from "./schema/meeting-links.js";
740import type {
741 meetingLinks,
742 meetingProviderConnections,
743} from "./schema/meeting-links.js";
738744import type { savedQueries, queryHistory } from "./schema/saved-queries.js";
739745import type { emailScripts, scriptRuns } from "./schema/email-scripts.js";
740746import type { changelogEntries } from "./schema/changelog.js";
@@ -857,6 +863,8 @@ export type TaskProviderConfig = InferSelectModel<typeof taskProviderConfigs>;
857863export type NewTaskProviderConfig = InferInsertModel<typeof taskProviderConfigs>;
858864export type MeetingLink = InferSelectModel<typeof meetingLinks>;
859865export type NewMeetingLink = InferInsertModel<typeof meetingLinks>;
866export type MeetingProviderConnection = InferSelectModel<typeof meetingProviderConnections>;
867export type NewMeetingProviderConnection = InferInsertModel<typeof meetingProviderConnections>;
860868export type SavedQuery = InferSelectModel<typeof savedQueries>;
861869export type NewSavedQuery = InferInsertModel<typeof savedQueries>;
862870export type QueryHistoryRecord = InferSelectModel<typeof queryHistory>;
Modifiedpackages/db/src/schema/meeting-links.ts+61−0View fileUnifiedSplit
@@ -9,6 +9,18 @@ import {
99import { relations } from "drizzle-orm";
1010import { accounts } from "./users.js";
1111
12// ---------------------------------------------------------------------------
13// Transcript provider enum — used by the provider connections table
14// ---------------------------------------------------------------------------
15
16export const transcriptProviderEnum = pgEnum("transcript_provider", [
17 "zoom",
18 "otter",
19 "fathom",
20 "granola",
21 "read.ai",
22]);
23
1224// ---------------------------------------------------------------------------
1325// Enums
1426// ---------------------------------------------------------------------------
@@ -97,6 +109,45 @@ export const meetingLinks = pgTable(
97109 ],
98110);
99111
112// ---------------------------------------------------------------------------
113// Meeting Provider Connections — persisted OAuth tokens for Zoom/Otter/etc.
114// ---------------------------------------------------------------------------
115
116export const meetingProviderConnections = pgTable(
117 "meeting_provider_connections",
118 {
119 id: text("id").primaryKey(),
120 accountId: text("account_id")
121 .notNull()
122 .references(() => accounts.id, { onDelete: "cascade" }),
123
124 /** Which transcript provider this token belongs to. */
125 provider: transcriptProviderEnum("provider").notNull(),
126
127 /**
128 * Encrypted access token.
129 * Store encrypted at rest — callers must decrypt before use.
130 * Use AES-256-GCM via the Web Crypto API.
131 */
132 accessTokenEncrypted: text("access_token_encrypted").notNull(),
133
134 connectedAt: timestamp("connected_at", { withTimezone: true })
135 .notNull()
136 .defaultNow(),
137
138 updatedAt: timestamp("updated_at", { withTimezone: true })
139 .notNull()
140 .defaultNow(),
141 },
142 (table) => [
143 index("meeting_provider_connections_account_idx").on(table.accountId),
144 index("meeting_provider_connections_account_provider_idx").on(
145 table.accountId,
146 table.provider,
147 ),
148 ],
149);
150
100151// ---------------------------------------------------------------------------
101152// Relations
102153// ---------------------------------------------------------------------------
@@ -107,3 +158,13 @@ export const meetingLinksRelations = relations(meetingLinks, ({ one }) => ({
107158 references: [accounts.id],
108159 }),
109160}));
161
162export const meetingProviderConnectionsRelations = relations(
163 meetingProviderConnections,
164 ({ one }) => ({
165 account: one(accounts, {
166 fields: [meetingProviderConnections.accountId],
167 references: [accounts.id],
168 }),
169 }),
170);
110171
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts