diff --git a/next.config.ts b/next.config.ts
index 3dda3600..245e98d6 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -23,6 +23,12 @@ const nextConfig: NextConfig = {
pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"],
async redirects() {
return [
+ {
+ source: "/contact",
+ destination: "/support",
+ // Keep this temporary so /contact can be restored without cached redirects.
+ permanent: false,
+ },
{
source: "/slack",
destination:
diff --git a/package.json b/package.json
index 677eaafc..4ba07daf 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,6 @@
"katex": "^0.16.32",
"next": "^16.1.6",
"next-mdx-remote": "^6.0.0",
- "node-fetch": "^3.3.2",
"prism-react-renderer": "^2.4.1",
"react": "^19.2.4",
"react-bootstrap": "^2.10.10",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7e579740..1f521ad6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -56,9 +56,6 @@ importers:
next-mdx-remote:
specifier: ^6.0.0
version: 6.0.0(@types/react@19.2.17)(react@19.2.4)
- node-fetch:
- specifier: ^3.3.2
- version: 3.3.2
prism-react-renderer:
specifier: ^2.4.1
version: 2.4.1(react@19.2.4)
@@ -1174,10 +1171,6 @@ packages:
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
- data-uri-to-buffer@4.0.1:
- resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
- engines: {node: '>= 12'}
-
data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'}
@@ -1494,10 +1487,6 @@ packages:
picomatch:
optional: true
- fetch-blob@3.2.0:
- resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
- engines: {node: ^12.20 || >= 14.13}
-
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@@ -1521,10 +1510,6 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
- formdata-polyfill@4.0.10:
- resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
- engines: {node: '>=12.20.0'}
-
framer-motion@12.34.3:
resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==}
peerDependencies:
@@ -2180,11 +2165,6 @@ packages:
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
- node-domexception@1.0.0:
- resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
- engines: {node: '>=10.5.0'}
- deprecated: Use your platform's native DOMException instead
-
node-emoji@2.2.0:
resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==}
engines: {node: '>=18'}
@@ -2193,10 +2173,6 @@ packages:
resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
engines: {node: '>= 0.4'}
- node-fetch@3.3.2:
- resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
- engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
-
node-releases@2.0.47:
resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
engines: {node: '>=18'}
@@ -2778,10 +2754,6 @@ packages:
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
- web-streams-polyfill@3.3.3:
- resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
- engines: {node: '>= 8'}
-
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -3805,8 +3777,6 @@ snapshots:
damerau-levenshtein@1.0.8: {}
- data-uri-to-buffer@4.0.1: {}
-
data-view-buffer@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -4282,11 +4252,6 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
- fetch-blob@3.2.0:
- dependencies:
- node-domexception: 1.0.0
- web-streams-polyfill: 3.3.3
-
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
@@ -4311,10 +4276,6 @@ snapshots:
dependencies:
is-callable: 1.2.7
- formdata-polyfill@4.0.10:
- dependencies:
- fetch-blob: 3.2.0
-
framer-motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
motion-dom: 12.40.0
@@ -5320,8 +5281,6 @@ snapshots:
node-addon-api@7.1.1:
optional: true
- node-domexception@1.0.0: {}
-
node-emoji@2.2.0:
dependencies:
'@sindresorhus/is': 4.6.0
@@ -5336,12 +5295,6 @@ snapshots:
object.entries: 1.1.9
semver: 6.3.1
- node-fetch@3.3.2:
- dependencies:
- data-uri-to-buffer: 4.0.1
- fetch-blob: 3.2.0
- formdata-polyfill: 4.0.10
-
node-releases@2.0.47: {}
object-assign@4.1.1: {}
@@ -6192,8 +6145,6 @@ snapshots:
web-namespaces@2.0.1: {}
- web-streams-polyfill@3.3.3: {}
-
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
diff --git a/src/components/ContactUsForm.tsx b/src/components/ContactUsForm.tsx
deleted file mode 100644
index 3fbba428..00000000
--- a/src/components/ContactUsForm.tsx
+++ /dev/null
@@ -1,206 +0,0 @@
-import React from "react";
-
-import Stack from "./Stack";
-
-type HTMLInputProps = React.AllHTMLAttributes;
-
-interface InputProps {
- component?: "input" | "textarea";
- id: string;
- label: string;
- onChange: (value: string) => void;
- type?: HTMLInputProps["type"];
- value: string;
-}
-
-const Input: React.FC = ({
- component: Component = "input",
- id,
- label,
- onChange,
- type,
- value,
-}) => (
-
-
- ,
- ) => onChange(e.target.value)}
- type={type}
- id={id}
- />
-
-);
-
-interface SelectProps {
- id: string;
- label: string;
- onChange: (value: string) => void;
- options: string[];
- value: string;
-}
-
-const Select: React.FC = ({
- id,
- label,
- onChange,
- options,
- value,
-}) => (
-
-
-
-
-);
-
-type SubmissionState = "unsubmitted" | "submitting" | "submitted" | "error";
-
-interface ContactUsFormProps {
- showHeader?: boolean;
-}
-
-export const ContactUsForm: React.FC = ({
- showHeader = true,
-}) => {
- const [firstName, setFirstName] = React.useState("");
- const [lastName, setLastName] = React.useState("");
- const [email, setEmail] = React.useState("");
- const [organization, setOrganization] = React.useState("");
- const [role, setRole] = React.useState("");
- const [message, setMessage] = React.useState("");
-
- const [submissionState, setSubmissionState] =
- React.useState("unsubmitted");
-
- const submit = () => {
- setSubmissionState("submitting");
- fetch("/api/contact", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- firstName,
- lastName,
- email,
- organization,
- role,
- message,
- url: document.location.href,
- }),
- })
- .then((data) => setSubmissionState(data.ok ? "submitted" : "error"))
- .catch(() => setSubmissionState("error"));
- };
-
- return (
-
-
- {submissionState !== "submitted" && (
-
- {showHeader && (
-
-
Contact us
-
- Tell us how we can help and we'll be in touch soon.
-
-
- )}
-
-
-
-
-
-
-
-
- {submissionState === "error" && (
-
- Sorry, something went wrong. Try again in a moment.
-
- )}
-
- )}
- {submissionState === "submitted" && (
-
-
Thanks!
-
- Someone from our team will be in touch with you soon.
-
-
- )}
-
-
- );
-};
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index 8d5a37e8..2ac81771 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -74,9 +74,6 @@ export const Footer: React.FC = () => {
Accessibility
-
- Contact
-
diff --git a/src/pages/api/contact.tsx b/src/pages/api/contact.tsx
deleted file mode 100644
index d79f01c9..00000000
--- a/src/pages/api/contact.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import fetch from "node-fetch";
-import type { NextApiRequest, NextApiResponse } from "next";
-
-export default async function handler(
- req: NextApiRequest,
- res: NextApiResponse,
-) {
- if (req.method !== "POST") {
- res.status(405).send({ error: "Method Not Allowed" });
- return;
- }
-
- // We should have a Slack token provided via an environment variable.
- // If for some reason we don't, 500.
- // If not, silently do nothing?
- const { SLACK_WEBHOOK_CONTACT_US } = process.env;
- if (!SLACK_WEBHOOK_CONTACT_US) {
- res.status(500).send({ error: "Could not send message" });
- return;
- }
-
- const { firstName, lastName, email, organization, role, message, url } =
- req.body;
-
- await fetch(SLACK_WEBHOOK_CONTACT_US, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- text:
- `*First name*: ${firstName}\n` +
- `*Last name*: ${lastName}\n` +
- `*Email*: \n` +
- `*Organization*: ${organization}\n` +
- `*Role*: ${role}\n` +
- `*Message*: ${message}\n` +
- `*URL*: ${url}`,
- }),
- });
-
- res.status(204).send("");
-}
diff --git a/src/pages/contact/index.module.scss b/src/pages/contact/index.module.scss
deleted file mode 100644
index 411d66ad..00000000
--- a/src/pages/contact/index.module.scss
+++ /dev/null
@@ -1,6 +0,0 @@
-@import "~bootstrap/scss/_functions.scss";
-@import "~bootstrap/scss/_variables.scss";
-
-.container {
- background-color: $gray-200;
-}
diff --git a/src/pages/contact/index.tsx b/src/pages/contact/index.tsx
deleted file mode 100644
index b09e8bdf..00000000
--- a/src/pages/contact/index.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import React from "react";
-import Head from "next/head";
-import classnames from "classnames";
-
-import { ContactUsForm } from "../../components/ContactUsForm";
-import { PageBanner } from "../../components/Banner";
-import { BannerCTA } from "../../components/CallToActionBanner";
-
-import styles from "./index.module.scss";
-
-export default function Contact() {
- return (
-
-
- Contact us | PrairieLearn
-
-
-
-
-
-
-
- Need to schedule a demo? Want to have access to your own course
- space? Let us know below and someone from our team will be in
- touch soon.
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/pages/pricing/index.tsx b/src/pages/pricing/index.tsx
index 8f87550c..e81e8854 100644
--- a/src/pages/pricing/index.tsx
+++ b/src/pages/pricing/index.tsx
@@ -89,7 +89,7 @@ const FAQS = [
Yes! PrairieLearn is always free for instructors, and the full platform
is always free for courses with up to 20 students in each term, with no
limit on the number of terms. If you have more than that,{" "}
- contact us to arrange a free trial for one
+ contact us to arrange a free trial for one
term.
),
@@ -146,7 +146,7 @@ const FAQS = [
function ContactUsButton({ className }: { className?: string }) {
return (
Contact us
diff --git a/src/pages/security/index.mdx b/src/pages/security/index.mdx
index ae083218..e0649b71 100644
--- a/src/pages/security/index.mdx
+++ b/src/pages/security/index.mdx
@@ -8,7 +8,7 @@ The PrairieLearn team takes the security of our products and services seriously.
## Third-party audits
-- **SOC 2 Type I**: PrairieLearn, Inc. has a SOC 2 Type I attestation for Security in accordance with the AICPA Trust Service Criteria. [Contact us](/contact) for access to our report.
+- **SOC 2 Type I**: PrairieLearn, Inc. has a SOC 2 Type I attestation for Security in accordance with the AICPA Trust Service Criteria. [Contact us](/support) for access to our report.
## Product security
diff --git a/src/pages/support/index.module.scss b/src/pages/support/index.module.scss
index 301b8f46..50178f9a 100644
--- a/src/pages/support/index.module.scss
+++ b/src/pages/support/index.module.scss
@@ -1,12 +1,34 @@
@import "~bootstrap/scss/_functions.scss";
@import "~bootstrap/scss/_variables.scss";
+.contact {
+ height: 100%;
+ border-color: $blue-100;
+ background: linear-gradient(135deg, $blue-100, $white 75%);
+}
+
.grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
- grid-gap: 1rem;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 1.5rem;
+}
+
+@media (min-width: 768px) {
+ .grid {
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+
+ > * {
+ grid-column: span 3;
+ }
+ }
+
+ .contact {
+ grid-row: span 2;
+ }
}
-.container {
- background-color: $gray-200;
+@media (min-width: 992px) {
+ .grid > .resource {
+ grid-column: span 2;
+ }
}
diff --git a/src/pages/support/index.tsx b/src/pages/support/index.tsx
index f739bf3f..4602bbd1 100644
--- a/src/pages/support/index.tsx
+++ b/src/pages/support/index.tsx
@@ -3,7 +3,6 @@ import React from "react";
import Link from "next/link";
import classnames from "classnames";
import { Heading } from "../../components/Heading";
-import { ContactUsForm } from "../../components/ContactUsForm";
import styles from "./index.module.scss";
import { RequestCourseModal } from "../../components/RequestCourseModal";
@@ -18,7 +17,7 @@ interface HelpCardProps {
const HelpCard: React.FC = ({ icon, title, href, children }) => {
return (
-
+
@@ -43,12 +42,60 @@ export default function Support() {
-