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
+
+
+
+
+
+ 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 */}
-
-
-
- You're invited to collaborate!
-
-
- {inviterName || 'Someone'} has invited you to join the workspace{' '}
- “{workspaceName}”
- {permissionLevel ? ` as ${roleText}` : ''}.
-
-
-
-
- {/* CTA Button */}
-
-
-
- Join Workspace
-
-
-
-
- {/* Fallback URL */}
-
-
-
- or copy and paste this link into your browser:
-
-
- {workspaceUrl}
-
-
-
-
+ inviterName,
+ workspaceName,
+ workspaceUrl,
+ permissionLevel,
+}: Readonly) {
+ const roleText = permissionLevel === "viewer" ? "a Viewer" : "an Editor";
+ const previewText = `${inviterName || "Someone"} invited you to "${workspaceName}" on ThinkEx`;
- {/* Footer */}
-
-
-
- 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}
+
+
+
+
+
+ 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 (
-
-
-
-
-
+
+
+
-
-
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
-
+
+
+ Your browser does not support the video tag.
+
+
+
+
+
+ Get the link in your inbox
+
+
+ {isSent ? (
+
+
+
+ Check your inbox!
+
+
{email}
+
+ ) : (
+
+ )}
+
+
+
+ © {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.
-
-
- {
- window.location.href = `mailto:support@thinkex.app?subject=${emailSubject}&body=${emailBody}`;
- }}
- className="w-full"
- >
- I'm interested
-
-
);
}
+
+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}