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() { -
    +
    +
    +
    + Contact us +

    + Have a question about pricing, getting started, or using + PrairieLearn or PrairieTest? Our team is here to help. +

    + + Email support +