From e43b3c2e74201c20a5cf43cfb2173e40a689fd50 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:29:16 +0000 Subject: [PATCH 1/3] Redesign mobile blocking screen with device detection, demo video, email reminder flow, and moderniz --- src/app/api/mobile-reminder/route.ts | 48 +++ .../email/desktop-reminder-email.tsx | 124 +++++++ src/components/email/invite-email.tsx | 312 ++++++++---------- src/components/ui/MobileWarning.tsx | 211 +++++++++--- src/hooks/ui/use-mobile-device.ts | 20 ++ 5 files changed, 501 insertions(+), 214 deletions(-) create mode 100644 src/app/api/mobile-reminder/route.ts create mode 100644 src/components/email/desktop-reminder-email.tsx create mode 100644 src/hooks/ui/use-mobile-device.ts diff --git a/src/app/api/mobile-reminder/route.ts b/src/app/api/mobile-reminder/route.ts new file mode 100644 index 00000000..c570f4de --- /dev/null +++ b/src/app/api/mobile-reminder/route.ts @@ -0,0 +1,48 @@ +import { DesktopReminderEmail } from "@/components/email/desktop-reminder-email"; +import { NextRequest, NextResponse } from "next/server"; +import { Resend } from "resend"; + +const resend = new Resend(process.env.RESEND_API_KEY); + +export async function POST(request: NextRequest) { + try { + const { email } = await request.json(); + + if (!email || typeof email !== "string") { + return NextResponse.json({ error: "Email is required" }, { status: 400 }); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email.trim())) { + return NextResponse.json( + { error: "Invalid email address" }, + { status: 400 }, + ); + } + + const normalizedEmail = email.trim().toLowerCase(); + + const { error } = await resend.emails.send({ + from: "ThinkEx ", + to: [normalizedEmail], + subject: "Your ThinkEx link — open on desktop", + react: DesktopReminderEmail(), + }); + + if (error) { + console.error("[mobile-reminder] Resend error:", error); + return NextResponse.json( + { error: "Failed to send email" }, + { status: 500 }, + ); + } + + return NextResponse.json({ success: true }); + } catch (err) { + console.error("[mobile-reminder] Error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/components/email/desktop-reminder-email.tsx b/src/components/email/desktop-reminder-email.tsx new file mode 100644 index 00000000..7a5ad968 --- /dev/null +++ b/src/components/email/desktop-reminder-email.tsx @@ -0,0 +1,124 @@ +import { + Body, + Button, + Container, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + Section, + Text, +} from "@react-email/components"; + +export function DesktopReminderEmail() { + return ( + + + + Open ThinkEx on your desktop to get started + +
+ ThinkEx +
+ + Continue on your desktop + + + ThinkEx works best on a desktop or laptop computer. Click the button + below to open ThinkEx and start organizing your knowledge. + +
+ +
+ + or visit:{" "} + + thinkex.app + + +
+ + The Workspace That Thinks With You + + + © {new Date().getFullYear()} ThinkEx + +
+ + + ); +} + +export default DesktopReminderEmail; diff --git a/src/components/email/invite-email.tsx b/src/components/email/invite-email.tsx index 4505c33e..4a50cc71 100644 --- a/src/components/email/invite-email.tsx +++ b/src/components/email/invite-email.tsx @@ -1,178 +1,152 @@ -import * as React from 'react'; -import { Html, Body } from '@react-email/components'; - -const FONT = - 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; -const BRAND_FONT = - 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; -const BG = '#111113'; +import { + Body, + Button, + Container, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + Section, + Text, +} from "@react-email/components"; interface InviteEmailTemplateProps { - inviterName?: string; - workspaceName: string; - workspaceUrl: string; - permissionLevel?: string; + inviterName?: string; + workspaceName: string; + workspaceUrl: string; + permissionLevel?: string; } export function InviteEmailTemplate({ - inviterName, - workspaceName, - workspaceUrl, - permissionLevel, -}: Readonly): React.ReactElement { - const roleText = permissionLevel === 'viewer' ? 'a Viewer' : 'an Editor'; - - return ( - - -
- - - - -
- - {/* Heading + Body text */} - - - - - {/* CTA Button */} - - - - - {/* Fallback URL */} - - - + inviterName, + workspaceName, + workspaceUrl, + permissionLevel, +}: Readonly) { + const roleText = permissionLevel === "viewer" ? "a Viewer" : "an Editor"; + const previewText = `${inviterName || "Someone"} invited you to "${workspaceName}" on ThinkEx`; - {/* Footer */} - - - -
-

- You're invited to collaborate! -

-

- {inviterName || 'Someone'} has invited you to join the workspace{' '} - “{workspaceName}” - {permissionLevel ? ` as ${roleText}` : ''}. -

-
- - Join Workspace - -
-

- or copy and paste this link into your browser: -
- - {workspaceUrl} - -

-
-

- The Workspace That Thinks With You -

-

- You received this email because {inviterName || 'someone'} invited you to collaborate on ThinkEx. If you believe this was sent in error, you can safely ignore this email. -

-

- © {new Date().getFullYear()} ThinkEx -

-
-
-
- - - ); + return ( + + + + {previewText} + +
+ ThinkEx +
+ + You're invited to collaborate! + + + + {inviterName || "Someone"} + {" "} + has invited you to join the workspace{" "} + + “{workspaceName}” + + {permissionLevel ? ` as ${roleText}` : ""}. + +
+ +
+ + or copy and paste this link into your browser:{" "} + + {workspaceUrl} + + +
+ + The Workspace That Thinks With You + + + You received this email because {inviterName || "someone"} invited + you to collaborate on ThinkEx. If you believe this was sent in + error, you can safely ignore this email. + + + © {new Date().getFullYear()} ThinkEx + +
+ + + ); } export default InviteEmailTemplate; diff --git a/src/components/ui/MobileWarning.tsx b/src/components/ui/MobileWarning.tsx index f822b1fe..419bd9de 100644 --- a/src/components/ui/MobileWarning.tsx +++ b/src/components/ui/MobileWarning.tsx @@ -1,69 +1,190 @@ "use client"; -import { useIsMobile } from "@/hooks/ui/use-mobile"; -import { Smartphone } from "lucide-react"; -import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ThinkExLogo } from "@/components/ui/thinkex-logo"; +import { useIsMobile } from "@/hooks/ui/use-mobile"; +import { useIsMobileDevice } from "@/hooks/ui/use-mobile-device"; +import { CheckCircle2, Loader2, Monitor } from "lucide-react"; +import { type FormEvent, useEffect, useState } from "react"; +import { toast } from "sonner"; -export function MobileWarning() { - const isMobile = useIsMobile(); - const [isMounted, setIsMounted] = useState(false); +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - useEffect(() => { - setIsMounted(true); - }, []); +function MobileLandingPage() { + const [email, setEmail] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSent, setIsSent] = useState(false); - // Don't render on server or if not mobile - if (!isMounted || !isMobile) { - return null; - } + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + const normalizedEmail = email.trim().toLowerCase(); + if (!EMAIL_REGEX.test(normalizedEmail)) { + toast.error("Please enter a valid email address"); + return; + } + + setIsSubmitting(true); + + try { + const response = await fetch("/api/mobile-reminder", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: normalizedEmail }), + }); - const emailSubject = encodeURIComponent("Mobile Support Request"); - const emailBody = encodeURIComponent(`Hi, + const data = (await response.json().catch(() => null)) as { + error?: string; + } | null; -I'm interested in mobile support for ThinkEx. + if (!response.ok) { + throw new Error(data?.error || "Failed to send email"); + } -`); + setIsSent(true); + setEmail(normalizedEmail); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to send email", + ); + } finally { + setIsSubmitting(false); + } + }; return ( -
-
-
-
- +
+
+
+
+ + + ThinkEx +
+
-
-

Mobile Not Supported

-

- ThinkEx is currently optimized for desktop use only. For the best experience, please access ThinkEx from a desktop or laptop computer. +

+
+

+ ThinkEx is built for desktop +

+

+ Watch what you can do on your computer

-
+
+ +
+ +
+

+ Get the link in your inbox +

+ + {isSent ? ( +
+ +

+ Check your inbox! +

+

{email}

+
+ ) : ( +
+ setEmail(event.target.value)} + disabled={isSubmitting} + className="h-11" + required + /> + +
+ )} +
+ +

+ © {new Date().getFullYear()} ThinkEx +

+
+
+
+ ); +} + +function SmallScreenWarning() { + return ( +
+
+
+
+ +
+
+

+ Window too small +

- Interested in mobile support? Let us know at{" "} - - support@thinkex.app - + Please resize your browser window to continue using ThinkEx.

- -
- -
); } + +export function MobileWarning() { + const isMobileDevice = useIsMobileDevice(); + const isSmallScreen = useIsMobile(); + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return null; + } + + if (isMobileDevice) { + return ; + } + + if (isSmallScreen) { + return ; + } + + return null; +} diff --git a/src/hooks/ui/use-mobile-device.ts b/src/hooks/ui/use-mobile-device.ts new file mode 100644 index 00000000..614176e0 --- /dev/null +++ b/src/hooks/ui/use-mobile-device.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const MOBILE_UA_REGEX = + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|Tablet/i; + +export function useIsMobileDevice(): boolean { + const [isMobileDevice, setIsMobileDevice] = useState(false); + + useEffect(() => { + const ua = navigator.userAgent; + const isMobileUA = MOBILE_UA_REGEX.test(ua); + const isIPad = /Macintosh/i.test(ua) && navigator.maxTouchPoints > 1; + + setIsMobileDevice(isMobileUA || isIPad); + }, []); + + return isMobileDevice; +} From 472082c1f3503fd01989e984866c7ab783941f76 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:32:29 +0000 Subject: [PATCH 2/3] Add rate limiting to mobile reminder API --- src/app/api/mobile-reminder/route.ts | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/app/api/mobile-reminder/route.ts b/src/app/api/mobile-reminder/route.ts index c570f4de..89cde176 100644 --- a/src/app/api/mobile-reminder/route.ts +++ b/src/app/api/mobile-reminder/route.ts @@ -3,6 +3,25 @@ import { NextRequest, NextResponse } from "next/server"; import { Resend } from "resend"; const resend = new Resend(process.env.RESEND_API_KEY); +const WINDOW_MS = 60 * 60 * 1000; +const rateLimitMap = new Map(); + +function isRateLimited(key: string, maxAttempts: number): boolean { + const now = Date.now(); + const entry = rateLimitMap.get(key); + + if (!entry || now > entry.resetAt) { + rateLimitMap.set(key, { count: 1, resetAt: now + WINDOW_MS }); + return false; + } + + if (entry.count >= maxAttempts) { + return true; + } + + entry.count++; + return false; +} export async function POST(request: NextRequest) { try { @@ -21,6 +40,19 @@ export async function POST(request: NextRequest) { } const normalizedEmail = email.trim().toLowerCase(); + const ipAddress = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + "unknown"; + + if ( + isRateLimited(`email:${normalizedEmail}`, 2) || + isRateLimited(`ip:${ipAddress}`, 10) + ) { + return NextResponse.json( + { error: "Too many requests. Please try again later." }, + { status: 429 }, + ); + } const { error } = await resend.emails.send({ from: "ThinkEx ", From fbf2c95dc0be564e413e504767f346f91b9b82cd Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Wed, 15 Apr 2026 02:25:29 +0000 Subject: [PATCH 3/3] Harden mobile reminder validation --- src/app/api/mobile-reminder/route.ts | 30 +++++++++++++++++-- .../email/desktop-reminder-email.tsx | 2 +- src/components/email/invite-email.tsx | 8 +++-- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/app/api/mobile-reminder/route.ts b/src/app/api/mobile-reminder/route.ts index 89cde176..5f015f76 100644 --- a/src/app/api/mobile-reminder/route.ts +++ b/src/app/api/mobile-reminder/route.ts @@ -12,6 +12,13 @@ function isRateLimited(key: string, maxAttempts: number): boolean { if (!entry || now > entry.resetAt) { rateLimitMap.set(key, { count: 1, resetAt: now + WINDOW_MS }); + if (rateLimitMap.size > 100) { + for (const [existingKey, value] of rateLimitMap) { + if (now > value.resetAt) { + rateLimitMap.delete(existingKey); + } + } + } return false; } @@ -25,21 +32,38 @@ function isRateLimited(key: string, maxAttempts: number): boolean { export async function POST(request: NextRequest) { try { - const { email } = await request.json(); + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid request body" }, + { status: 400 }, + ); + } + + const { email } = body as { email?: unknown }; if (!email || typeof email !== "string") { return NextResponse.json({ error: "Email is required" }, { status: 400 }); } + const normalizedEmail = email.trim().toLowerCase(); + if (normalizedEmail.length > 254) { + return NextResponse.json( + { error: "Invalid email address" }, + { status: 400 }, + ); + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email.trim())) { + if (!emailRegex.test(normalizedEmail)) { return NextResponse.json( { error: "Invalid email address" }, { status: 400 }, ); } - const normalizedEmail = email.trim().toLowerCase(); const ipAddress = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; diff --git a/src/components/email/desktop-reminder-email.tsx b/src/components/email/desktop-reminder-email.tsx index 7a5ad968..1456bee8 100644 --- a/src/components/email/desktop-reminder-email.tsx +++ b/src/components/email/desktop-reminder-email.tsx @@ -15,7 +15,7 @@ import { export function DesktopReminderEmail() { return ( - + + {workspaceUrl}