Skip to content

Commit da8448d

Browse files
authored
Merge pull request #1054 from mbifulco/feat/shad-newsletter-landing-page
rm convertkit && politepop
2 parents 9abbefa + 00e93d5 commit da8448d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+3280
-2134
lines changed

.depcheckrc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
"@/config",
3131
"@components/*",
32+
"@ui/*",
3233
"@data/*",
3334
"@hooks/*",
3435
"@layouts/*",

.env.example

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
SITE_URL=https://mikebifulco.com
22

3+
# --- CLIENT-side ENVS
34
# Find this at https://cloudinary.com/console/settings/account
45
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=mikebifulco-com
56

6-
CONVERTKIT_API_SECRET=abc123
7-
87
# fathom analytics
98
NEXT_PUBLIC_FATHOM_ID=PAVJGIYJ
109

1110
# posthog
1211
NEXT_PUBLIC_POSTHOG_KEY=test
12+
# ---
1313

14-
# transistor
15-
TRANSISTOR_API_KEY=test
1614

15+
# --- SERVER-side ENVS
16+
# transistor
1717
RESEND_API_KEY=test
1818
RESEND_NEWSLETTER_AUDIENCE_ID=test
19+
RESEND_SIGNING_SECRET=test

.github/workflows/lint.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ jobs:
1010
timeout-minutes: 7
1111
runs-on: ubuntu-latest
1212
env:
13-
CONVERTKIT_API_SECRET: ${{ secrets.CONVERTKIT_API_SECRET }}
14-
CONVERTKIT_WEBHOOK_SECRET: ${{ secrets.CONVERTKIT_WEBHOOK_SECRET }}
1513
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME: ${{ secrets.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME }}
1614
NEXT_PUBLIC_FATHOM_ID: ${{ secrets.NEXT_PUBLIC_FATHOM_ID }}
1715
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}

.github/workflows/test-e2e.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ jobs:
1313
image: mcr.microsoft.com/playwright:v1.54.1-noble
1414
options: --user 1001
1515
env:
16-
CONVERTKIT_API_SECRET: ${{ secrets.CONVERTKIT_API_SECRET }}
17-
CONVERTKIT_WEBHOOK_SECRET: ${{ secrets.CONVERTKIT_WEBHOOK_SECRET }}
1816
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME: ${{ secrets.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME }}
1917
NEXT_PUBLIC_FATHOM_ID: ${{ secrets.NEXT_PUBLIC_FATHOM_ID }}
2018
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,6 @@ $RECYCLE.BIN/
245245

246246
# Million Lint
247247
.million
248+
249+
# tsc
250+
tsconfig.tsbuildinfo

e2e/newsletter-signup.spec.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
test.describe('Newsletter Signup Page', () => {
4+
test.beforeEach(async ({ page }) => {
5+
// Mock the TRPC subscription endpoint to avoid real API calls
6+
await page.route('**/api/trpc/mailingList.subscribe*', async (route) => {
7+
// Return an error to trigger onError callback which clears form
8+
await route.fulfill({
9+
status: 400,
10+
contentType: 'application/json',
11+
body: JSON.stringify([
12+
{
13+
error: {
14+
message: 'Subscription failed',
15+
code: -32600,
16+
data: {
17+
code: 'BAD_REQUEST',
18+
httpStatus: 400,
19+
},
20+
},
21+
},
22+
]),
23+
});
24+
});
25+
26+
await page.goto('/subscribe');
27+
});
28+
29+
test('should render the newsletter signup page correctly', async ({
30+
page,
31+
}) => {
32+
// Check page elements are visible
33+
await expect(page.getByTestId('newsletter-title')).toContainText(
34+
' devs & founders building better.'
35+
);
36+
37+
// Check form elements
38+
await expect(page.getByTestId('first-name-input')).toBeVisible();
39+
await expect(page.getByTestId('email-input')).toBeVisible();
40+
await expect(page.getByTestId('submit-button')).toBeVisible();
41+
42+
// Check privacy disclaimer
43+
await expect(page.getByTestId('privacy-link')).toBeVisible();
44+
await expect(page.getByTestId('unsubscribe-text')).toBeVisible();
45+
});
46+
47+
test('should show validation errors for empty form submission', async ({
48+
page,
49+
}) => {
50+
const submitButton = page.getByTestId('submit-button');
51+
await submitButton.click();
52+
53+
// Check validation error messages appear
54+
await expect(page.getByText('First name is required')).toBeVisible();
55+
await expect(page.getByText('Email is required')).toBeVisible();
56+
});
57+
58+
test('should show validation error for invalid email', async ({ page }) => {
59+
const firstNameInput = page.getByTestId('first-name-input');
60+
const emailInput = page.getByTestId('email-input');
61+
const submitButton = page.getByTestId('submit-button');
62+
63+
await firstNameInput.fill('John');
64+
await emailInput.fill('not-an-email-at-all');
65+
66+
// Submit the form
67+
await submitButton.click();
68+
69+
// Wait for the request to complete
70+
await page.waitForTimeout(2000);
71+
72+
// The form should submit successfully since validation happens server-side
73+
// Check that we can still see the form (indicating it didn't navigate away)
74+
await expect(page.getByTestId('email-input')).toBeVisible();
75+
76+
// Check that the form fields are cleared (indicating successful submission)
77+
await expect(emailInput).toHaveValue('');
78+
await expect(firstNameInput).toHaveValue('');
79+
});
80+
81+
test('should handle form submission with valid data', async ({ page }) => {
82+
const firstNameInput = page.getByTestId('first-name-input');
83+
const emailInput = page.getByTestId('email-input');
84+
const submitButton = page.getByTestId('submit-button');
85+
86+
await firstNameInput.fill('John');
87+
await emailInput.fill('[email protected]');
88+
89+
// Submit the form
90+
await submitButton.click();
91+
92+
// Wait for the request to complete
93+
await page.waitForTimeout(2000);
94+
95+
// Check that the form fields are cleared (indicating successful submission)
96+
await expect(emailInput).toHaveValue('');
97+
await expect(firstNameInput).toHaveValue('');
98+
});
99+
100+
test('should accept valid form input without validation errors', async ({
101+
page,
102+
}) => {
103+
const firstNameInput = page.getByTestId('first-name-input');
104+
const emailInput = page.getByTestId('email-input');
105+
106+
await firstNameInput.fill('John');
107+
await emailInput.fill('[email protected]');
108+
109+
// Validation errors should not be present
110+
await expect(page.getByText('First name is required')).not.toBeVisible();
111+
await expect(page.getByText('Email is required')).not.toBeVisible();
112+
await expect(
113+
page.getByText('Please enter a valid email address')
114+
).not.toBeVisible();
115+
});
116+
117+
test('should have proper form accessibility', async ({ page }) => {
118+
// Check form labels are properly associated
119+
const firstNameInput = page.getByTestId('first-name-input');
120+
const emailInput = page.getByTestId('email-input');
121+
122+
// Check inputs have proper IDs and labels (even if sr-only)
123+
await expect(firstNameInput).toHaveAttribute('id', 'firstName');
124+
await expect(emailInput).toHaveAttribute('id', 'email');
125+
126+
// Check submit button is properly labeled
127+
const submitButton = page.getByTestId('submit-button');
128+
await expect(submitButton).toHaveAttribute('type', 'submit');
129+
});
130+
131+
test('should handle form submission button states', async ({ page }) => {
132+
const firstNameInput = page.getByTestId('first-name-input');
133+
const emailInput = page.getByTestId('email-input');
134+
const submitButton = page.getByTestId('submit-button');
135+
136+
// Fill valid data
137+
await firstNameInput.fill('John');
138+
await emailInput.fill('[email protected]');
139+
140+
// Button should be enabled with valid data
141+
await expect(submitButton).not.toBeDisabled();
142+
await expect(submitButton).toContainText('💌 Get the newsletter');
143+
});
144+
145+
test('should have correct page title and meta description', async ({
146+
page,
147+
}) => {
148+
await expect(page).toHaveTitle(/Newsletter Signup - Mike Bifulco/);
149+
});
150+
151+
test('should be responsive on mobile viewport', async ({ page }) => {
152+
await page.setViewportSize({ width: 375, height: 667 }); // iPhone SE size
153+
154+
// Check that elements are still visible and properly arranged
155+
await expect(page.getByTestId('newsletter-title')).toContainText(
156+
'devs & founders building better.'
157+
);
158+
await expect(page.getByTestId('first-name-input')).toBeVisible();
159+
await expect(page.getByTestId('email-input')).toBeVisible();
160+
await expect(page.getByTestId('submit-button')).toBeVisible();
161+
});
162+
163+
test('should maintain focus management', async ({ page }) => {
164+
// Tab through form elements
165+
await page.keyboard.press('Tab'); // Should focus first input
166+
await expect(page.getByTestId('first-name-input')).toBeFocused();
167+
168+
await page.keyboard.press('Tab'); // Should focus second input
169+
await expect(page.getByTestId('email-input')).toBeFocused();
170+
171+
await page.keyboard.press('Tab'); // Should focus submit button
172+
await expect(page.getByTestId('submit-button')).toBeFocused();
173+
});
174+
});

e2e/smoke.spec.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,9 @@ test('404 error page smoke test', async ({ page }) => {
3434
const response = await page.goto('/non-existent-page');
3535
expect(response?.status()).toBe(404);
3636

37-
// Check for the new ErrorPage UI
37+
// Check for the default Next.js 404 page in development mode
3838
await expect(page.locator('h1')).toContainText('404');
3939
await expect(
40-
page.locator(
41-
"text=Page not found. The page you're looking for doesn't exist."
42-
)
40+
page.locator('text=This page could not be found.')
4341
).toBeVisible();
4442
});

package.json

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,19 @@
1919
"test:unit:coverage": "vitest run --coverage",
2020
"test:e2e": "playwright test",
2121
"test:e2e:headed": "playwright test --headed",
22+
"typecheck": "tsc --noEmit",
2223
"lint": "pnpm eslint .",
2324
"eslint-check": "pnpm eslint --print-config . | pnpm eslint-config-prettier-check"
2425
},
2526
"dependencies": {
2627
"@cloudinary/url-gen": "^1.21.0",
27-
"@headlessui/react": "^2.2.4",
28+
"@headlessui/react": "^2.2.6",
2829
"@mdx-js/mdx": "^3.1.0",
2930
"@mdx-js/react": "^3.1.0",
30-
"@next/bundle-analyzer": "^15.4.1",
31+
"@next/bundle-analyzer": "^15.4.4",
3132
"@number-flow/react": "^0.5.10",
3233
"@radix-ui/react-slot": "^1.2.3",
33-
"@react-email/components": "^0.3.1",
34+
"@react-email/components": "^0.3.2",
3435
"@react-email/render": "^1.1.3",
3536
"@t3-oss/env-nextjs": "^0.13.8",
3637
"@tailwindcss/typography": "^0.5.16",
@@ -46,54 +47,57 @@
4647
"fathom-client": "^3.7.2",
4748
"feed": "^5.1.0",
4849
"gray-matter": "^4.0.3",
49-
"lucide-react": "^0.525.0",
50+
"lucide-react": "^0.533.0",
5051
"micro": "^10.0.1",
51-
"next": "15.4.1",
52+
"next": "15.4.4",
5253
"next-cloudinary": "^6.16.0",
5354
"next-mdx-remote": "5.0.0",
5455
"pluralize": "^8.0.0",
5556
"postcss": "^8.5.6",
56-
"posthog-js": "^1.257.0",
57-
"posthog-node": "^5.5.1",
57+
"posthog-js": "^1.258.2",
58+
"posthog-node": "^5.6.0",
5859
"prism-react-renderer": "^2.4.1",
59-
"react": "19.1.0",
60-
"react-aria": "^3.41.1",
61-
"react-dom": "19.1.0",
62-
"react-email": "4.2.1",
60+
"react": "19.1.1",
61+
"react-aria": "^3.42.0",
62+
"react-dom": "19.1.1",
63+
"react-email": "4.2.4",
64+
"react-hook-form": "^7.61.1",
6365
"react-icons": "^5.5.0",
64-
"react-stately": "^3.39.0",
66+
"react-stately": "^3.40.0",
6567
"rehype-img-size": "^1.0.1",
6668
"rehype-slug": "^6.0.0",
6769
"remark-gfm": "^4.0.1",
68-
"resend": "^4.6.0",
70+
"resend": "^4.7.0",
6971
"sass": "^1.89.2",
7072
"sharp": "^0.34.3",
7173
"slugify": "^1.6.6",
7274
"sonner": "^2.0.6",
7375
"superjson": "^2.2.2",
7476
"svix": "^1.69.0",
7577
"tailwind-merge": "^3.3.1",
76-
"zod": "^4.0.5"
78+
"zod": "^4.0.13"
7779
},
7880
"devDependencies": {
79-
"@eslint/js": "^9.31.0",
81+
"@eslint/js": "^9.32.0",
8082
"@ianvs/prettier-plugin-sort-imports": "^4.5.1",
81-
"@next/eslint-plugin-next": "^15.4.1",
83+
"@next/eslint-plugin-next": "^15.4.4",
8284
"@playwright/test": "^1.54.1",
8385
"@tailwindcss/postcss": "^4.1.11",
84-
"@testing-library/jest-dom": "^6.6.3",
86+
"@testing-library/jest-dom": "^6.6.4",
8587
"@testing-library/react": "^16.3.0",
88+
"@testing-library/user-event": "^14.6.1",
8689
"@types/eslint": "^9.6.1",
87-
"@types/node": "^24.0.14",
90+
"@types/node": "^24.1.0",
8891
"@types/pluralize": "^0.0.33",
89-
"@types/react": "19.1.8",
90-
"@typescript-eslint/eslint-plugin": "^8.37.0",
91-
"@typescript-eslint/parser": "^8.37.0",
92-
"@vitejs/plugin-react": "^4.6.0",
92+
"@types/react": "19.1.9",
93+
"@typescript-eslint/eslint-plugin": "^8.38.0",
94+
"@typescript-eslint/parser": "^8.38.0",
95+
"@vitejs/plugin-react": "^4.7.0",
9396
"depcheck": "^1.4.7",
94-
"eslint": "9.31.0",
95-
"eslint-config-next": "^15.4.1",
96-
"eslint-config-prettier": "^10.1.5",
97+
"dotenv": "^17.2.1",
98+
"eslint": "9.32.0",
99+
"eslint-config-next": "^15.4.4",
100+
"eslint-config-prettier": "^10.1.8",
97101
"eslint-config-turbo": "^2.5.5",
98102
"eslint-plugin-import": "^2.32.0",
99103
"eslint-plugin-jsx-a11y": "^6.10.2",
@@ -102,14 +106,14 @@
102106
"eslint-plugin-react-hooks": "^5.2.0",
103107
"eslint-plugin-unused-imports": "^4.1.4",
104108
"github-slugger": "^2.0.0",
105-
"jiti": "^2.4.2",
109+
"jiti": "^2.5.1",
106110
"jsdom": "^26.1.0",
107111
"prettier": "^3.6.2",
108112
"prettier-plugin-tailwindcss": "^0.6.14",
109113
"schema-dts": "^1.1.5",
110114
"tailwindcss": "^4.1.11",
111115
"typescript": "5.8.3",
112-
"typescript-eslint": "^8.37.0",
116+
"typescript-eslint": "^8.38.0",
113117
"vite-tsconfig-paths": "^5.1.4",
114118
"vitest": "^3.2.4"
115119
},

0 commit comments

Comments
 (0)