Skip to content

Commit 1e13ba6

Browse files
fix: resolve merge conflicts, remove unused radix deps, fix type annotation
- Resolve merge conflicts with main in hooks/use-toast.ts, tsconfig.json, pnpm-lock.yaml, and components/ui/sheet.tsx - Remove @radix-ui/react-separator, @radix-ui/react-toggle, and @radix-ui/react-tooltip from package.json (components using them were deleted) - Use proper `boolean` type instead of `any` for onOpenChange callback - Keep e2e and playwright.config.ts in tsconfig exclude entries from main - Delete unused sheet.tsx component
2 parents d2f5c92 + c354063 commit 1e13ba6

40 files changed

Lines changed: 1314 additions & 464 deletions

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,10 @@ jobs:
5757

5858
- name: Build
5959
run: pnpm build
60+
61+
- name: Lighthouse CI
62+
uses: treosh/lighthouse-ci-action@v12
63+
with:
64+
configPath: './lighthouserc.json'
65+
uploadArtifacts: true
66+
temporaryPublicStorage: true

.github/workflows/e2e.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: E2E Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
e2e:
11+
runs-on: ubuntu-latest
12+
timeout-minutes: 15
13+
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
18+
- name: Setup Node.js
19+
uses: actions/setup-node@v4
20+
with:
21+
node-version: 20
22+
cache: npm
23+
24+
- name: Install dependencies
25+
run: npm ci
26+
27+
- name: Install Playwright browsers
28+
run: npx playwright install --with-deps chromium
29+
30+
- name: Run E2E tests
31+
run: npm run test:e2e
32+
33+
- name: Upload test results
34+
uses: actions/upload-artifact@v4
35+
if: ${{ !cancelled() }}
36+
with:
37+
name: playwright-report
38+
path: playwright-report/
39+
retention-days: 14

.gitignore

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,15 @@ node_modules
22
.next
33
.env
44
.env.local
5-
*.tsbuildinfo
5+
*.tsbuildinfo
6+
7+
# Lock files (pnpm only — no npm or yarn)
8+
package-lock.json
9+
yarn.lock
10+
11+
# Playwright
12+
/test-results/
13+
/playwright-report/
14+
/blob-report/
15+
/playwright/.cache/
16+
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { render, screen, waitFor } from "@testing-library/react";
3+
4+
import { WaitlistModal } from "@/components/organisms/waitlist-modal";
5+
import userEvent from "@testing-library/user-event";
6+
7+
// ---- MOCKS ----
8+
9+
// Mock useWaitlist
10+
vi.mock("@/components/providers/waitlist-provider", () => ({
11+
useWaitlist: () => ({
12+
isOpen: true, // FORCE MODAL OPEN
13+
closeWaitlist: vi.fn(),
14+
}),
15+
}));
16+
17+
// Mock toast
18+
vi.mock("@/hooks/use-toast", () => ({
19+
toast: vi.fn(),
20+
}));
21+
22+
// Mock next env
23+
vi.stubEnv("NEXT_PUBLIC_WAITLIST_API_URL", "https://test.api");
24+
25+
// ---- HELPERS ----
26+
27+
const setup = () => {
28+
return render(<WaitlistModal />);
29+
};
30+
31+
// ---- TESTS ----
32+
33+
describe("WaitlistModal", () => {
34+
beforeEach(() => {
35+
vi.clearAllMocks();
36+
});
37+
38+
// Modal renders
39+
it("renders when open", () => {
40+
setup();
41+
42+
expect(screen.getByText(/join the waitlist/i)).toBeInTheDocument();
43+
});
44+
45+
// Invalid email
46+
it("shows error for invalid email", async () => {
47+
setup();
48+
49+
const emailInput = screen.getByPlaceholderText(/jane@example.com/i);
50+
const submitBtn = screen.getByRole("button", { name: /join waitlist/i });
51+
52+
await userEvent.type(emailInput, "invalid-email");
53+
await userEvent.click(submitBtn);
54+
55+
expect(await screen.findByText(/please enter a valid email address/i)).toBeInTheDocument();
56+
});
57+
58+
// Empty email
59+
it("shows error for empty email", async () => {
60+
setup();
61+
62+
const submitBtn = screen.getByRole("button", { name: /join waitlist/i });
63+
64+
await userEvent.click(submitBtn);
65+
66+
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
67+
});
68+
69+
// Success flow
70+
it("shows success state on successful submission", async () => {
71+
vi.stubGlobal(
72+
"fetch",
73+
vi.fn(() =>
74+
Promise.resolve({
75+
ok: true,
76+
} as Response)
77+
)
78+
);
79+
80+
setup();
81+
82+
const emailInput = screen.getByPlaceholderText(/jane@example.com/i);
83+
const submitBtn = screen.getByRole("button", { name: /join waitlist/i });
84+
85+
await userEvent.type(emailInput, "test@example.com");
86+
await userEvent.click(submitBtn);
87+
88+
expect(await screen.findByText(/you're on the list!/i)).toBeInTheDocument();
89+
});
90+
91+
// API error
92+
it("shows error message when API fails", async () => {
93+
vi.stubGlobal(
94+
"fetch",
95+
vi.fn(() =>
96+
Promise.resolve({
97+
ok: false,
98+
} as Response)
99+
)
100+
);
101+
102+
setup();
103+
104+
const emailInput = screen.getByPlaceholderText(/jane@example.com/i);
105+
const submitBtn = screen.getByRole("button", { name: /join waitlist/i });
106+
107+
await userEvent.type(emailInput, "test@example.com");
108+
await userEvent.click(submitBtn);
109+
110+
expect(await screen.findByText(/failed to join waitlist/i)).toBeInTheDocument();
111+
});
112+
113+
// Accessibility
114+
it("is accessible (aria + focus)", async () => {
115+
setup();
116+
117+
const dialog = screen.getByRole("dialog");
118+
expect(dialog).toBeInTheDocument();
119+
120+
// Focus should land on first input
121+
const inputs = screen.getAllByRole("textbox");
122+
await waitFor(() => expect(inputs[0]).toHaveFocus());
123+
});
124+
});

__tests__/hooks/use-mobile.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { renderHook, act } from "@testing-library/react";
2+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3+
4+
import { useIsMobile } from "@/hooks/use-mobile";
5+
6+
describe("useIsMobile", () => {
7+
beforeEach(() => {
8+
// Mock window.innerWidth
9+
Object.defineProperty(window, "innerWidth", {
10+
writable: true,
11+
configurable: true,
12+
value: 1024,
13+
});
14+
15+
// Mock window.matchMedia
16+
Object.defineProperty(window, "matchMedia", {
17+
writable: true,
18+
configurable: true,
19+
value: vi.fn().mockImplementation((query) => ({
20+
matches: false,
21+
media: query,
22+
onchange: null,
23+
addListener: vi.fn(), // deprecated
24+
removeListener: vi.fn(), // deprecated
25+
addEventListener: vi.fn(),
26+
removeEventListener: vi.fn(),
27+
dispatchEvent: vi.fn(),
28+
})),
29+
});
30+
});
31+
32+
afterEach(() => {
33+
vi.clearAllMocks();
34+
});
35+
36+
it("should return false when viewport width is >= 768px", () => {
37+
Object.defineProperty(window, "innerWidth", { value: 1024 });
38+
const { result } = renderHook(() => useIsMobile());
39+
expect(result.current).toBe(false);
40+
});
41+
42+
it("should return true when viewport width is < 768px", () => {
43+
Object.defineProperty(window, "innerWidth", { value: 500 });
44+
const { result } = renderHook(() => useIsMobile());
45+
expect(result.current).toBe(true);
46+
});
47+
48+
it("should return false when viewport width is exactly 768px (boundary condition)", () => {
49+
Object.defineProperty(window, "innerWidth", { value: 768 });
50+
const { result } = renderHook(() => useIsMobile());
51+
expect(result.current).toBe(false);
52+
});
53+
54+
it("should update when window is resized", () => {
55+
Object.defineProperty(window, "innerWidth", { value: 1024, writable: true });
56+
57+
let changeCallback: () => void = () => {};
58+
59+
// Mock matchMedia to capture the change callback
60+
(window.matchMedia as any).mockImplementation((query: string) => ({
61+
matches: false,
62+
media: query,
63+
onchange: null,
64+
addEventListener: vi.fn((event, cb) => {
65+
if (event === "change") changeCallback = cb;
66+
}),
67+
removeEventListener: vi.fn(),
68+
}));
69+
70+
const { result } = renderHook(() => useIsMobile());
71+
expect(result.current).toBe(false);
72+
73+
// Simulate resize to mobile width
74+
act(() => {
75+
(window as any).innerWidth = 500;
76+
changeCallback();
77+
});
78+
expect(result.current).toBe(true);
79+
80+
// Simulate resize back to desktop width
81+
act(() => {
82+
(window as any).innerWidth = 1024;
83+
changeCallback();
84+
});
85+
expect(result.current).toBe(false);
86+
});
87+
88+
it("should clean up event listener on unmount", () => {
89+
const removeEventListenerMock = vi.fn();
90+
91+
(window.matchMedia as any).mockImplementation((query: string) => ({
92+
matches: false,
93+
media: query,
94+
onchange: null,
95+
addEventListener: vi.fn(),
96+
removeEventListener: removeEventListenerMock,
97+
}));
98+
99+
const { unmount } = renderHook(() => useIsMobile());
100+
unmount();
101+
102+
expect(removeEventListenerMock).toHaveBeenCalledWith("change", expect.any(Function));
103+
});
104+
});

app/api/csrf/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { NextResponse } from "next/server";
2+
import { generateCsrfToken } from "@/lib/csrf";
3+
4+
export async function GET() {
5+
const token = await generateCsrfToken();
6+
return NextResponse.json({ token });
7+
}

app/api/waitlist/route.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { NextRequest } from "next/server";
2+
import { NextResponse } from "next/server";
3+
import { validateCsrfToken } from "@/lib/csrf";
4+
5+
export async function POST(request: NextRequest) {
6+
// 1. Validate CSRF token
7+
const isValid = await validateCsrfToken(request);
8+
if (!isValid) {
9+
return NextResponse.json({ error: "Invalid CSRF token" }, { status: 403 });
10+
}
11+
12+
try {
13+
const values = await request.json();
14+
15+
// 2. Process the waitlist (e.g., forward it to the real API)
16+
const apiUrl = process.env.NEXT_PUBLIC_WAITLIST_API_URL;
17+
18+
if (apiUrl) {
19+
const response = await fetch(apiUrl, {
20+
method: "POST",
21+
headers: {
22+
"Content-Type": "application/json",
23+
},
24+
body: JSON.stringify(values),
25+
});
26+
27+
if (!response.ok) {
28+
throw new Error("Failed to forward waitlist entry to external API");
29+
}
30+
31+
return NextResponse.json({ success: true });
32+
} else {
33+
// Demo mode/fallback
34+
console.log("Local Waitlist Submission (Demo Mode):", values);
35+
return NextResponse.json({
36+
success: true,
37+
message: "Demo mode: Submission received locally",
38+
});
39+
}
40+
} catch (error) {
41+
console.error("Waitlist submission error:", error);
42+
return NextResponse.json({ error: "Failed to process waitlist" }, { status: 500 });
43+
}
44+
}

app/layout.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const inter = Inter({
2020
});
2121

2222
export const metadata: Metadata = {
23-
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!),
23+
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || "https://intmoney.com"),
2424
title: "IntMoney - AI-Powered Cross-Border Payments",
2525
description:
2626
"The AI-powered mobile wallet for seamless cross-border payments using simple chat or voice commands. Built on Stellar.",
@@ -90,6 +90,9 @@ export default function RootLayout({
9090
}>) {
9191
return (
9292
<html lang="en" suppressHydrationWarning>
93+
<head>
94+
<link rel="preload" href="/icon.svg" as="image" />
95+
</head>
9396
<body className={`${inter.variable} ${geist.variable} font-body antialiased`}>
9497
<SkipToContent />
9598
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>

0 commit comments

Comments
 (0)