diff --git a/index.html b/index.html index f3fcefe..d5deb95 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,20 @@ ECE Pathmaker + + + + + + + + + + + + + +
diff --git a/jest.setup.ts b/jest.setup.ts index da11f69..3aaf4cb 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,3 +1,10 @@ import "@testing-library/jest-dom"; +import type { ReactNode } from "react"; -jest.mock('lucide-react'); \ No newline at end of file +jest.mock('lucide-react'); + +// react-helmet-async needs a HelmetProvider in the tree; stub it out for tests +jest.mock('react-helmet-async', () => ({ + Helmet: () => null, + HelmetProvider: ({ children }: { children: ReactNode }) => children, +})); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3a2ecce..0174620 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "react": "^18.3.1", "react-colorful": "^5.6.1", "react-dom": "^18.3.1", + "react-helmet-async": "^3.0.0", "react-icons": "^5.4.0", "react-router-dom": "^7.1.1", "tailwind-merge": "^2.6.0", @@ -59,7 +60,8 @@ "ts-node": "^10.9.2", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vite-plugin-sitemap": "^0.8.2" } }, "node_modules/@adobe/css-tools": { @@ -7392,6 +7394,15 @@ "node": ">=12" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -9676,6 +9687,26 @@ "react": "^18.3.1" } }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-helmet-async": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-3.0.0.tgz", + "integrity": "sha512-nA3IEZfXiclgrz4KLxAhqJqIfFDuvzQwlKwpdmzZIuC1KNSghDEIXmyU0TKtbM+NafnkICcwx8CECFrZ/sL/1w==", + "license": "Apache-2.0", + "dependencies": { + "invariant": "^2.2.4", + "react-fast-compare": "^3.2.2", + "shallowequal": "^1.1.0" + }, + "peerDependencies": { + "react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-icons": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", @@ -10086,6 +10117,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -11057,6 +11094,12 @@ } } }, + "node_modules/vite-plugin-sitemap": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/vite-plugin-sitemap/-/vite-plugin-sitemap-0.8.2.tgz", + "integrity": "sha512-bqIw6NVOXg6je81lzX8Lm0vjf8/QSAp8di8fYQzZ3ZdVicOm8+6idBGALJiy1R1FiXNIK8rgORO6HBqXyHW+iQ==", + "dev": true + }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", diff --git a/package.json b/package.json index 8eddf75..1ed3b64 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "react": "^18.3.1", "react-colorful": "^5.6.1", "react-dom": "^18.3.1", + "react-helmet-async": "^3.0.0", "react-icons": "^5.4.0", "react-router-dom": "^7.1.1", "tailwind-merge": "^2.6.0", @@ -62,6 +63,7 @@ "ts-node": "^10.9.2", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vite-plugin-sitemap": "^0.8.2" } } diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..085fd12 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..623aacf Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..b748d45 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "ECE Pathmaker", + "short_name": "Pathmaker", + "description": "ECEs, plan your courses with drag-and-drop - no loading and no fuss", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#379683", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/public/og/default.png b/public/og/default.png new file mode 100644 index 0000000..9006e2c Binary files /dev/null and b/public/og/default.png differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..9ebedc4 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://ecepathmaker.com/sitemap.xml diff --git a/src/components/forms/CourseForm.tsx b/src/components/forms/CourseForm.tsx index 6968b3e..48fa3a5 100644 --- a/src/components/forms/CourseForm.tsx +++ b/src/components/forms/CourseForm.tsx @@ -192,7 +192,7 @@ const CourseForm: FC = ({ }; const [justSubmitted, setJustSubmitted] = useState(false); - const timeoutRef = useRef(); + const timeoutRef = useRef>(); useEffect(() => { if (justSubmitted) { diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 84f3292..2692eb9 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -15,6 +15,27 @@ import { emptyGrid } from "../../utils/emptyGrid"; import mockCourses from "../../data/mockCourses"; import { saveCourses, saveCoursesOnGrid, saveCoursesUsed, saveDependencies, saveLayouts } from "../../firebase/firestore"; import { auth } from "../../firebase/firebase"; +import Seo from "../seo/Seo"; +import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "../../lib/site"; + +const siteJsonLd: Record[] = [ + { + "@context": "https://schema.org", + "@type": "WebApplication", + name: SITE_NAME, + url: SITE_URL, + description: SITE_DESCRIPTION, + applicationCategory: "EducationalApplication", + operatingSystem: "Web", + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + }, + { + "@context": "https://schema.org", + "@type": "WebSite", + name: SITE_NAME, + url: SITE_URL, + }, +]; interface LayoutProps { children: ReactNode; @@ -121,6 +142,7 @@ const Layout: FC = ({ children }) => { setSavedLayouts, }} > +
diff --git a/src/components/seo/Seo.tsx b/src/components/seo/Seo.tsx new file mode 100644 index 0000000..7f16a8d --- /dev/null +++ b/src/components/seo/Seo.tsx @@ -0,0 +1,63 @@ +import { Helmet } from "react-helmet-async"; +import { + DEFAULT_OG_IMAGE, + SITE_DESCRIPTION, + SITE_NAME, + SITE_URL, +} from "../../lib/site"; + +interface SeoProps { + title?: string; + description?: string; + path?: string; + image?: string; + jsonLd?: Record | Record[]; + noindex?: boolean; +} + +const Seo = ({ + title, + description, + path, + image, + jsonLd, + noindex, +}: SeoProps) => { + const fullTitle = title ? `${title} - ${SITE_NAME}` : SITE_NAME; + const metaDescription = description ?? SITE_DESCRIPTION; + const canonical = `${SITE_URL}${path ?? "/"}`; + const ogImage = image ?? DEFAULT_OG_IMAGE; + const absoluteImage = ogImage.startsWith("http") + ? ogImage + : `${SITE_URL}${ogImage}`; + + return ( + + {fullTitle} + + {noindex && } + + + + + + + + + + + + + + + + {jsonLd && ( + + )} + + ); +}; + +export default Seo; diff --git a/src/lib/site.ts b/src/lib/site.ts new file mode 100644 index 0000000..505a9d0 --- /dev/null +++ b/src/lib/site.ts @@ -0,0 +1,5 @@ +export const SITE_URL = "https://ecepathmaker.com"; +export const SITE_NAME = "ECE Pathmaker"; +export const SITE_DESCRIPTION = + "ECEs, plan your courses with drag-and-drop - no loading and no fuss"; +export const DEFAULT_OG_IMAGE = "/og/default.png"; diff --git a/src/main.tsx b/src/main.tsx index 4438c98..dc4e3a6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' import { AuthProvider } from './contexts/AuthContext.tsx'; +import { HelmetProvider } from 'react-helmet-async'; const storedTheme = localStorage.getItem('theme'); if (storedTheme === 'dark') { @@ -13,8 +14,10 @@ if (storedTheme === 'dark') { createRoot(document.getElementById('root')!).render( - - - + + + + + , ) diff --git a/src/pages/Courses.tsx b/src/pages/Courses.tsx index 42b9651..40315dd 100644 --- a/src/pages/Courses.tsx +++ b/src/pages/Courses.tsx @@ -16,6 +16,22 @@ import { createPortal } from "react-dom"; import { FilterState } from "../types/types"; import { mockCourses } from "../utils/dataImports"; import { CourseCard, Filter } from "../utils/componentImports"; +import Seo from "../components/seo/Seo"; +import { SITE_URL } from "../lib/site"; + +const coursesJsonLd: Record = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Home", item: SITE_URL }, + { + "@type": "ListItem", + position: 2, + name: "Courses", + item: `${SITE_URL}/courses`, + }, + ], +}; const Courses = () => { const sensors = useSensors( @@ -99,6 +115,12 @@ const Courses = () => { return (
+

🗂️ Explore and rearrange courses (all the courses on Magellan and more)!

diff --git a/src/pages/FAQ.tsx b/src/pages/FAQ.tsx index 9065c45..fc918e7 100644 --- a/src/pages/FAQ.tsx +++ b/src/pages/FAQ.tsx @@ -1,6 +1,17 @@ import { FC, useEffect, useState } from 'react'; import { siteFaqData, courseFaqData } from "../data/faqData"; import { FAQCard } from "../utils/componentImports"; +import Seo from "../components/seo/Seo"; +import { SITE_URL } from "../lib/site"; + +const faqJsonLd: Record = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Home", item: SITE_URL }, + { "@type": "ListItem", position: 2, name: "FAQ", item: `${SITE_URL}/faq` }, + ], +}; const FAQ: FC = () => { const [showButton, setShowButton] = useState(false); @@ -29,6 +40,12 @@ const FAQ: FC = () => { return (
+

FAQs

    diff --git a/src/pages/Maker.tsx b/src/pages/Maker.tsx index 03a37fe..af29020 100644 --- a/src/pages/Maker.tsx +++ b/src/pages/Maker.tsx @@ -6,6 +6,7 @@ import { LoadLayout, SaveLayout, } from "../utils/componentImports"; +import Seo from "../components/seo/Seo"; const Maker = () => { @@ -28,6 +29,10 @@ const Maker = () => { return (
    + { return (
    +

    404 Not Found

    The page you are looking for does not exist, but maybe you'll find it in your heart.

    Chill Guy diff --git a/vite.config.ts b/vite.config.ts index 7f70b0a..9a1c443 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,20 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import sitemap from 'vite-plugin-sitemap' import path from "path" // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + sitemap({ + hostname: "https://ecepathmaker.com", + dynamicRoutes: ["/courses", "/faq"], + readable: true, + // Keep our hand-written public/robots.txt instead of generating one. + generateRobotsTxt: false, + }), + ], resolve: { alias: { "@": path.resolve(__dirname, "./src"),