diff --git a/.dockerignore b/.dockerignore index 24e571e..0f3a30e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,6 @@ .git .github -.env -.next +.env* .svelte-kit backend build diff --git a/.gitignore b/.gitignore index f842bc9..b33fbb6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,9 +10,7 @@ /coverage # framework output -/.next/ /.svelte-kit/ -/out/ # production /build @@ -30,12 +28,8 @@ yarn-error.log* .env*.local .env -# vercel -.vercel - # typescript *.tsbuildinfo -next-env.d.ts .idea /backend/ diff --git a/README.md b/README.md index fb145f2..1e949c2 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,60 @@ # MethodConf -The MethodConf frontend is being migrated to SvelteKit. This branch only contains the temporary SvelteKit shell and tooling base. The old Next.js source stays in the repo as reference until the page migration layers replace it. - -The Umbraco CMS lives in [`sgfdevs/cms.methodconf.com`](https://github.com/sgfdevs/cms.methodconf.com). +The MethodConf frontend uses Svelte 5 and SvelteKit 3. Server-rendered conference pages, schedules, speakers, sponsors, and registration content come from the [Umbraco CMS](https://github.com/sgfdevs/cms.methodconf.com). Static assets and self-hosted fonts live in `static/`; CMS media uses the streaming proxy and image optimizer. ## Local development -1. Copy `.env.example` to `.env` when working on routes that need CMS or newsletter configuration. -2. Install dependencies and start the development server: +Use the Node version in `.nvmrc`. Copy `.env.example` to `.env`, configure your local CMS, then run: ```bash npm ci npm run dev ``` -Useful checks for this foundation layer: +Vite prints the development URL. To check the app: ```bash npm run lint npm run check -npm run test +npm test +``` + +`npm test` runs unit tests, builds the app, and checks routes against local CMS and newsletter fixtures. `npm run test:unit` runs only unit tests. + +## Runtime configuration + +SvelteKit reads the private server variables declared in `src/env.ts` at runtime. Do not expose them through public environment variables. + +- `UMBRACO_BASE_URL` must point to the Umbraco API used by page loaders and the media proxy. +- `CMS_PUBLIC_URL` is the public CMS URL for the `/umbraco/` redirect. +- `SITE_URL` is the canonical frontend URL, including its scheme. +- `SEARCH_INDEXING_ENABLED=true` allows indexing. Any other value disables it. +- `NEWSLETTER_ENDPOINT` and `NEWSLETTER_LIST_ID` configure the server-side newsletter subscription request. Without both, subscriptions return a configuration error. For local work, use a fake local endpoint and list ID so form submissions cannot reach the live service. + +Keep credentials in local environment files or deployment configuration, not in Git. Production Node processes need these variables in their environment; `npm start` does not load `.env` automatically. + +## Production and Docker + +```bash npm run build +npm start ``` -The production smoke image can be built locally with: +The Node adapter writes the server and assets to `build/`. Set `HOST` and `PORT` for the listener and `ORIGIN` when needed behind a reverse proxy. These adapter settings are separate from the canonical `SITE_URL`. ```bash docker build -t methodconf.com . +docker run --rm --env-file .env -p 3000:3000 methodconf.com +``` + +The Dockerfile builds the app, installs production dependencies, and runs `node build` as a non-root user on port 3000. Environment files stay outside the image. The `frontend-prod` Compose profile loads `.env`; `APP_PORT` controls the host port. The `frontend-dev` profile mounts the project for development commands inside the container. + +## Umbraco schemas + +With `UMBRACO_BASE_URL` pointing to the intended CMS, run: + +```bash +npm run generate:umbraco-api-client ``` + +The script reads the delivery and default OpenAPI schemas and writes `src/lib/umbraco/deliveryApiSchema.d.ts` and `src/lib/umbraco/defaultApiSchema.d.ts`. Commit schema changes when the CMS contract changes. diff --git a/eslint.config.mjs b/eslint.config.mjs index e700832..11260e3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,20 +7,11 @@ import tseslint from 'typescript-eslint'; export default tseslint.config( { ignores: [ - '.next/**', '.svelte-kit/**', 'build/**', 'coverage/**', 'node_modules/**', - 'next-env.d.ts', - 'next.config.ts', - 'src/app/**', - 'src/components/**', - 'src/config.ts', - 'src/data/**', 'src/lib/umbraco/*ApiSchema.d.ts', - 'src/serverConfig.ts', - 'src/util.ts', ], }, js.configs.recommended, diff --git a/next.config.ts b/next.config.ts deleted file mode 100644 index 0abfe32..0000000 --- a/next.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { NextConfig } from 'next'; - -const nextConfig: NextConfig = { - output: 'standalone', - trailingSlash: true, - images: { - localPatterns: [{ pathname: '/cms-media/**' }], - qualities: [75, 100], - }, - productionBrowserSourceMaps: true, -}; - -export default nextConfig; diff --git a/src/app/[conference]/[...slug]/layout.tsx b/src/app/[conference]/[...slug]/layout.tsx deleted file mode 100644 index 7eb57da..0000000 --- a/src/app/[conference]/[...slug]/layout.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { ReactNode } from 'react'; -import { DefaultNav } from '@/components/DefaultNav'; -import { getConference } from '@/data/getConference'; -import { notFound } from 'next/navigation'; -import { getSponsors } from '@/data/getSponsors'; -import { SponsorsBlock } from '@/components/contentBlocks/SponsorsBlock'; - -export interface PageLayoutProps { - children: ReactNode; - params: Promise<{ - conference: string; - }>; -} - -export default async function PageLayout({ - children, - params, -}: PageLayoutProps) { - const resolvedParams = await params; - const conference = await getConference(resolvedParams.conference); - - if (!conference) { - return notFound(); - } - - const sponsors = await getSponsors(conference.id); - - return ( - <> - -
- {children} - {sponsors ? : null} -
- - ); -} diff --git a/src/app/[conference]/[...slug]/page.tsx b/src/app/[conference]/[...slug]/page.tsx deleted file mode 100644 index cdc9f39..0000000 --- a/src/app/[conference]/[...slug]/page.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import type { Metadata } from 'next'; -import dynamic from 'next/dynamic'; -import { notFound } from 'next/navigation'; -import { getConference } from '@/data/getConference'; -import { getItemByPathOrDefault } from '@/data/umbraco/getItemByPath'; -import { generateMetadata as generateSpeakerMetadata } from '@/components/pageTypes/SpeakerDetailPage'; -import { generateMetadata as generateGenericMetadata } from '@/components/pageTypes/GenericPage'; - -export interface PageProps { - params: Promise<{ - conference: string; - slug: string[]; - }>; -} - -export async function generateMetadata({ - params, -}: PageProps): Promise { - const resolvedParams = await params; - const { conference, item } = await getData(resolvedParams); - - if (!conference || !item) { - return notFound(); - } - - switch (item.contentType) { - case 'speaker': - return await generateSpeakerMetadata({ - conference, - speaker: item, - }); - case 'page': - return await generateGenericMetadata({ - params: resolvedParams, - conference, - page: item, - }); - } - - return {}; -} - -const SpeakerDetailPage = dynamic(() => - import('@/components/pageTypes/SpeakerDetailPage').then( - (mod) => mod.SpeakerDetailPage, - ), -); - -const GenericPage = dynamic(() => - import('@/components/pageTypes/GenericPage').then((mod) => mod.GenericPage), -); - -export default async function Page({ params }: PageProps) { - const resolvedParams = await params; - const { conference, item } = await getData(resolvedParams); - - if (!conference || !item) { - return notFound(); - } - - switch (item.contentType) { - case 'speaker': - return ; - case 'page': - return ( - - ); - default: - return notFound(); - } -} - -async function getData(params: Awaited) { - const conference = await getConference(params.conference); - - if (!conference) { - return {}; - } - - const item = await getItemByPathOrDefault( - `${params.conference}/${params.slug.join('/')}`, - ); - - if (!item) { - return { conference }; - } - - return { conference, item }; -} diff --git a/src/app/[conference]/page.tsx b/src/app/[conference]/page.tsx deleted file mode 100644 index 9d02be3..0000000 --- a/src/app/[conference]/page.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { HomeNav } from '@/components/HomeNav'; -import { getConference } from '@/data/getConference'; -import { notFound } from 'next/navigation'; -import { - generateMetadata as generateGenericMetadata, - GenericPage, -} from '@/components/pageTypes/GenericPage'; -import { getItemByPathOrDefault } from '@/data/umbraco/getItemByPath'; -import type { Metadata } from 'next'; - -export interface RootPageProps { - params: Promise<{ conference: string }>; -} - -export async function generateMetadata({ - params, -}: RootPageProps): Promise { - const { conference: conferenceSlug } = await params; - const { conference, homeContent } = await getHomePageData(conferenceSlug); - - if (!conference || !homeContent) { - return notFound(); - } - - return await generateGenericMetadata({ - params: { - conference: conferenceSlug, - slug: [], - }, - conference, - page: homeContent, - }); -} - -export default async function Home({ params }: RootPageProps) { - const resolvedParams = await params; - const { conference, homeContent } = await getHomePageData( - resolvedParams.conference, - ); - - if (!conference || !homeContent) { - return notFound(); - } - - return ( - <> - -
- -
- - ); -} - -async function getHomePageData(conferenceSlug: string) { - const conference = await getConference(conferenceSlug); - - if (!conference) { - return {}; - } - - const homeContent = await getItemByPathOrDefault(`${conferenceSlug}/home`); - - if (homeContent?.contentType !== 'home') { - return { conference }; - } - - return { conference, homeContent }; -} diff --git a/src/app/[conference]/register/layout.tsx b/src/app/[conference]/register/layout.tsx deleted file mode 100644 index 6143083..0000000 --- a/src/app/[conference]/register/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import PageLayout from '@/app/[conference]/[...slug]/layout'; - -export default PageLayout; diff --git a/src/app/[conference]/register/page.tsx b/src/app/[conference]/register/page.tsx deleted file mode 100644 index 0c49005..0000000 --- a/src/app/[conference]/register/page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { notFound } from 'next/navigation'; -import { getConference } from '@/data/getConference'; -import { OveItEmbed } from '@/components/OveItEmbed'; - -export interface RegisterProps { - params: Promise<{ - conference: string; - }>; -} - -export default async function Register({ params }: RegisterProps) { - const { conference: conferenceSlug } = await params; - const conference = await getConference(conferenceSlug); - - const registerUrl = conference?.properties.registerUrl; - - if (!registerUrl) { - return notFound(); - } - - return ( -
-
- -
-
- ); -} diff --git a/src/app/[conference]/register/success/page.tsx b/src/app/[conference]/register/success/page.tsx deleted file mode 100644 index 223ad0c..0000000 --- a/src/app/[conference]/register/success/page.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { Metadata } from 'next'; -import Link from 'next/link'; - -export interface SuccessPageProps { - params: Promise<{ - conference: string; - }>; -} - -export const metadata: Metadata = { - robots: { index: false }, -}; - -export default async function SuccessPage({ params }: SuccessPageProps) { - const { conference } = await params; - - return ( -
-
-

- Thank You for Registering -

-

- We’ve sent a confirmation email to your inbox. -

-

- If you don{`'`}t receive that email or have any other issues - please email{' '} - - info@methodconf.com - -

-
- - Back Home - -
-
- ); -} diff --git a/src/app/[conference]/speak/route.ts b/src/app/[conference]/speak/route.ts deleted file mode 100644 index 6002dbd..0000000 --- a/src/app/[conference]/speak/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { notFound, redirect } from 'next/navigation'; -import { parseUrl } from '@/util'; -import { getConference } from '@/data/getConference'; - -export async function GET( - request: Request, - { params }: { params: Promise<{ conference: string }> }, -) { - const { conference: conferenceSlug } = await params; - const conference = await getConference(conferenceSlug); - - const url = parseUrl(conference?.properties?.callForSpeakersUrl); - - if (!url) { - return notFound(); - } - - redirect(url.toString()); -} diff --git a/src/app/[conference]/tickets/route.ts b/src/app/[conference]/tickets/route.ts deleted file mode 100644 index e397bd1..0000000 --- a/src/app/[conference]/tickets/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { permanentRedirect } from 'next/navigation'; - -export async function GET( - request: Request, - { params }: { params: Promise<{ conference: string }> }, -) { - const { conference } = await params; - permanentRedirect(`/${conference}/register/`); -} diff --git a/src/app/api/newsletter/route.ts b/src/app/api/newsletter/route.ts deleted file mode 100644 index ea876f8..0000000 --- a/src/app/api/newsletter/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { getNewsletterConfig } from '@/serverConfig'; - -export async function POST(request: Request): Promise { - const { endpoint, listId } = getNewsletterConfig(); - - if (!endpoint || !listId) { - return Response.json({ success: false }, { status: 500 }); - } - - const { name, email, nullCheck } = await request.json(); - - if (!nullCheck) { - try { - await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name, - email, - list_uuids: [listId], - }), - }).then((res) => res.json()); - } catch { - return Response.json({ success: false }, { status: 500 }); - } - } - - return Response.json({ success: true }); -} diff --git a/src/app/cms-media/[...path]/route.ts b/src/app/cms-media/[...path]/route.ts deleted file mode 100644 index 08cdece..0000000 --- a/src/app/cms-media/[...path]/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { getUmbracoBaseUrl } from '@/serverConfig'; - -interface RouteContext { - params: Promise<{ path: string[] }>; -} - -async function proxyCmsMedia( - request: Request, - { params }: RouteContext, -): Promise { - const { path } = await params; - const upstreamUrl = new URL( - path.map(encodeURIComponent).join('/'), - `${getUmbracoBaseUrl().toString().replace(/\/$/, '')}/`, - ); - upstreamUrl.search = new URL(request.url).search; - - const headers = new Headers(); - const accept = request.headers.get('accept'); - const range = request.headers.get('range'); - - if (accept) headers.set('accept', accept); - if (range) headers.set('range', range); - - const response = await fetch(upstreamUrl, { - method: request.method, - headers, - next: { revalidate: 60 }, - }); - - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); -} - -export const GET = proxyCmsMedia; -export const HEAD = proxyCmsMedia; diff --git a/src/app/globals.css b/src/app/globals.css deleted file mode 100644 index 19bea03..0000000 --- a/src/app/globals.css +++ /dev/null @@ -1,44 +0,0 @@ -@import "tailwindcss"; -@plugin "@tailwindcss/typography"; - -@theme { - --color-primary: #D72F32; - --color-secondary: #1E1E1E; -} - -@layer components { - .button { - @apply text-white bg-black p-4 uppercase font-bold tracking-wider text-sm hover:-translate-y-1 transition-transform - } - - .button.secondary { - @apply bg-primary - } - - .content-container { - @apply container; - } - - .large-content-container { - max-width: 1900px; - } - - .small-content-container { - max-width: 800px; - } - - .content-container, - .large-content-container, - .small-content-container { - @apply mx-auto; - width: calc(100% - calc(var(--spacing) * 8)); - - @media (width >= theme(--breakpoint-md)) { - width: calc(100% - calc(var(--spacing) * 20)); - } - } - - .links a { - @apply text-primary underline - } -} diff --git a/src/app/icon.png b/src/app/icon.png deleted file mode 100644 index 3a90ecd..0000000 Binary files a/src/app/icon.png and /dev/null differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx deleted file mode 100644 index de5b8da..0000000 --- a/src/app/layout.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { Metadata } from 'next'; -import { connection } from 'next/server'; -import type { ReactNode } from 'react'; -import { Source_Sans_3 } from 'next/font/google'; -import PlausibleProvider from 'next-plausible'; -import { config } from '@fortawesome/fontawesome-svg-core'; -import '@fortawesome/fontawesome-svg-core/styles.css'; -import './globals.css'; -import { Footer } from '@/components/Footer'; -import { getSiteUrl, isSearchIndexingEnabled } from '@/serverConfig'; - -config.autoAddCss = false; - -const sourceSans = Source_Sans_3({ subsets: ['latin'] }); - -export async function generateMetadata(): Promise { - await connection(); - - return { - title: { - template: 'Method Conference - %s', - default: 'Method Conference - October 12th 2024 - Springfield, MO', - }, - metadataBase: getSiteUrl(), - robots: isSearchIndexingEnabled() - ? undefined - : { index: false, follow: false }, - }; -} - -export default function RootLayout({ - children, -}: Readonly<{ - children: ReactNode; -}>) { - return ( - - - - {children} -