From ed64cd6755fa2393bc27c5e93ff386340cdbc310 Mon Sep 17 00:00:00 2001 From: Tiebe Groosman Date: Sat, 11 Apr 2026 17:00:30 +0200 Subject: [PATCH] feat: add student discount page with academic email verification Add /request-student-account page where students log in with academic email, verify via API, and receive HTTP Toolkit Pro free for 1 year (renewable). Non-academic emails get a fallback contact form. - Add student-account-content.tsx client component with login/verify/success/fallback states - Add page.tsx server component with metadata - Add REQUEST_STUDENT_ACCOUNT route to routes.ts - Add Student Discount link to footer Product column --- src/app/request-student-account/page.tsx | 27 ++ .../student-account-content.tsx | 290 ++++++++++++++++++ .../sections/contact-form/index.tsx | 47 ++- src/content/data/footer-columns.ts | 3 +- src/lib/constants/routes.ts | 4 + 5 files changed, 364 insertions(+), 7 deletions(-) create mode 100644 src/app/request-student-account/page.tsx create mode 100644 src/app/request-student-account/student-account-content.tsx diff --git a/src/app/request-student-account/page.tsx b/src/app/request-student-account/page.tsx new file mode 100644 index 00000000..447b9062 --- /dev/null +++ b/src/app/request-student-account/page.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from 'next/types'; + +import { Container } from '@/components/elements/container'; +import { Section } from '@/components/elements/section'; +import { Layout } from '@/components/layout'; +import { LoginModal } from '@/components/modules/login-modal'; +import { buildMetadata } from '@/lib/utils/build-metadata'; +import { StudentAccountContent } from './student-account-content'; + +export const metadata: Metadata = buildMetadata({ + title: 'Student Discount | HTTP Toolkit', + description: + 'HTTP Toolkit Pro is free for students and faculty at accredited universities and colleges. Renew each year while you study.', +}); + +export default function RequestStudentAccountPage() { + return ( + + +
+ + + +
+
+ ); +} diff --git a/src/app/request-student-account/student-account-content.tsx b/src/app/request-student-account/student-account-content.tsx new file mode 100644 index 00000000..6d1f3b10 --- /dev/null +++ b/src/app/request-student-account/student-account-content.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { observer } from 'mobx-react-lite'; +import { styled } from '@linaria/react'; + +import { screens } from '@/styles/tokens'; +import { Button } from '@/components/elements/button'; +import { Gradient } from '@/components/elements/gradient'; +import { Heading } from '@/components/elements/heading'; +import { Spinner } from '@/components/elements/icon'; +import Stack from '@/components/elements/stack'; +import { Text } from '@/components/elements/text'; +import { ContactForm } from '@/components/sections/contact-form'; +import { SuccessHero } from '@/components/sections/success-hero'; +import { accountStore } from '@/lib/store/account-store'; + +const ACCOUNTS_API_BASE = process.env.NEXT_PUBLIC_ACCOUNTS_API + ?? 'https://accounts.httptoolkit.tech/api'; + +type PageState = + | 'initial' + | 'verifying' + | 'success' + | 'not_academic' + | 'already_active' + | 'error'; + +interface VerificationResult { + school?: string; + expiry?: number; +} + +const StyledPageWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 32px; + max-width: 620px; + margin: 0 auto; +`; + +const StyledSpinner = styled.div` + display: flex; + align-items: center; + justify-content: center; + + & svg { + width: 48px; + height: 48px; + animation: student-spin 1s linear infinite; + } + + @keyframes student-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } +`; + +const StyledGradientLeft = styled.div` + position: absolute; + max-width: 100%; + top: -180px; + left: 0; + height: 780px; + pointer-events: none; + + @media (min-width: ${screens['lg']}) { + top: -7px; + } +`; + +const StyledFallbackWrapper = styled.div` + width: 100%; + max-width: 620px; + margin: 0 auto; +`; + +function getAccessToken(): string | undefined { + // Read directly from localStorage because @httptoolkit/accounts does not + // export its internal getToken() helper. This mirrors how the package + // itself stores and reads tokens (see auth.js line 34). + try { + const raw = localStorage.getItem('tokens'); + if (!raw) return undefined; + return JSON.parse(raw)?.accessToken; + } catch { + return undefined; + } +} + +function formatExpiry(timestamp?: number): string { + if (!timestamp) return ''; + return new Date(timestamp).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); +} + +export const StudentAccountContent = observer(() => { + const [pageState, setPageState] = useState('initial'); + const [result, setResult] = useState({}); + const [errorMessage, setErrorMessage] = useState(''); + + const requestStudentAccount = useCallback(async () => { + setPageState('verifying'); + + const accessToken = getAccessToken(); + if (!accessToken) { + setPageState('error'); + setErrorMessage('No authentication token found. Please try logging in again.'); + return; + } + + try { + const response = await fetch(`${ACCOUNTS_API_BASE}/request-student-account`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + setResult({ school: data.school, expiry: data.expiry }); + setPageState('success'); + } else if (response.status === 403) { + setPageState('not_academic'); + } else if (response.status === 409) { + const data = await response.json().catch(() => ({})); + setResult({ expiry: data.expiry }); + setPageState('already_active'); + } else { + setPageState('error'); + setErrorMessage('Something went wrong. Please try again later.'); + } + } catch { + setPageState('error'); + setErrorMessage('Could not reach the server. Please check your connection and try again.'); + } + }, []); + + useEffect(() => { + if (accountStore.isLoggedIn && pageState === 'initial') { + requestStudentAccount(); + } + }, [accountStore.isLoggedIn, pageState, requestStudentAccount]); + + const handleLoginClick = useCallback(() => { + accountStore.login(); + }, []); + + return ( + <> + + + + + {pageState === 'initial' && ( + + + + Student Discount + + + HTTP Toolkit Pro is free for students and faculty at accredited + universities and colleges. Log in with your academic email + address (.edu, .ac.uk, etc.) to get started. Your access lasts + one year and can be renewed as long as you're still studying. + + + + + )} + + {pageState === 'verifying' && ( + + + + Verifying your email... + + + Checking whether your email is associated with an academic institution. + + + + + + + )} + + {pageState === 'success' && ( + + + Your academic email has been verified + {result.school ? ` (${result.school})` : ''} and + your HTTP Toolkit Pro subscription is now active + {result.expiry ? ` until ${formatExpiry(result.expiry)}` : ' for one year'}. + + + When your access expires, come back to this page to renew it + for another year. Download HTTP Toolkit and log in with your + account to get started. + + + } + callToAction={ + + } + /> + )} + + {pageState === 'already_active' && ( + + You already have an active student subscription + {result.expiry ? ` until ${formatExpiry(result.expiry)}` : ''}. + You can renew when less than 2 months remain. Download HTTP + Toolkit and log in with your account to use it. + + } + callToAction={ + + } + /> + )} + + {pageState === 'not_academic' && ( + + + + + + Email not recognized + + + We couldn't verify your email ({accountStore.user.email}) as belonging to + an academic institution. If you believe this is a mistake, + use the form below to contact us and we'll review it manually. + + + + + + + + )} + + {pageState === 'error' && ( + + + + Something went wrong + + + {errorMessage} + + + + + )} + + ); +}); diff --git a/src/components/sections/contact-form/index.tsx b/src/components/sections/contact-form/index.tsx index 514bc581..7ef61d8f 100644 --- a/src/components/sections/contact-form/index.tsx +++ b/src/components/sections/contact-form/index.tsx @@ -20,20 +20,55 @@ const StyledContactFormWrapper = styled.div` } `; -export const ContactForm = () => { +interface ContactFormProps { + action?: string; + submitLabel?: string; + defaultValues?: { + name?: string; + email?: string; + message?: string; + }; + placeholders?: { + name?: string; + email?: string; + message?: string; + }; +} + +export const ContactForm = ({ + action = 'https://accounts.httptoolkit.tech/api/contact-form', + submitLabel = 'Submit the form', + defaultValues, + placeholders, +}: ContactFormProps) => { return ( -
+ - - + +
{
diff --git a/src/content/data/footer-columns.ts b/src/content/data/footer-columns.ts index bc860349..1cf33298 100644 --- a/src/content/data/footer-columns.ts +++ b/src/content/data/footer-columns.ts @@ -21,6 +21,7 @@ const { PROD_FOR_LINUX, PROD_FOR_MAC_OS, PROD_FOR_WINDOW, + REQUEST_STUDENT_ACCOUNT, } = pageRoutes; export interface FooterColumn { @@ -37,7 +38,7 @@ export interface FooterColumn { export const footerColumns: FooterColumn[] = [ { title: 'Product', - links: [PROD_FOR_MAC_OS, PROD_FOR_WINDOW, PROD_FOR_LINUX, PRICING], + links: [PROD_FOR_MAC_OS, PROD_FOR_WINDOW, PROD_FOR_LINUX, PRICING, REQUEST_STUDENT_ACCOUNT], subHeading: [ { title: 'Projects', diff --git a/src/lib/constants/routes.ts b/src/lib/constants/routes.ts index 1af68106..40cacfe7 100644 --- a/src/lib/constants/routes.ts +++ b/src/lib/constants/routes.ts @@ -99,6 +99,10 @@ export const pageRoutes = { href: '/http-toolkit-for-linux/', label: 'HTTP Toolkit for Linux', }, + REQUEST_STUDENT_ACCOUNT: { + href: '/request-student-account/', + label: 'Student Discount', + }, }; export interface PageRoute {