diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f09005f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +# Keep host build artefacts and platform-specific binaries out of the image; +# dependencies are installed fresh inside the container by `npm ci`. +node_modules +.output +.nitro +.tanstack +.wrangler +dist +.git +.claude +.lovable +.vscode +.idea +*.log +.env +.env.* +!.env.example +Dockerfile +.dockerignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0af2717 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Contact form email delivery (https://resend.com). Without RESEND_API_KEY the +# form falls back to opening the visitor's email app. +RESEND_API_KEY= +# Where enquiries are delivered (defaults to hello@kapfoundry.com). +CONTACT_TO_EMAIL= +# Sender; must be on a domain verified in Resend, e.g. "KapFoundry ". +CONTACT_FROM_EMAIL= diff --git a/.gitignore b/.gitignore index d24df8a..c50cb50 100644 --- a/.gitignore +++ b/.gitignore @@ -24,9 +24,15 @@ dist-ssr .vscode/* !.vscode/extensions.json .idea +.claude .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? + +# Local secrets +.env +.env.* +!.env.example diff --git a/.prettierrc b/.prettierrc index 90abee2..dab1afe 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,5 +2,6 @@ "printWidth": 100, "semi": true, "singleQuote": false, - "trailingComma": "all" + "trailingComma": "all", + "endOfLine": "auto" } diff --git a/AGENTS.md b/AGENTS.md index 36eb109..f59511a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,5 @@ + > [!IMPORTANT] > This project is connected to [Lovable](https://lovable.dev). Avoid rewriting > published git history — force pushing, or rebasing/amending/squashing commits @@ -7,4 +8,5 @@ > > Commits you push to the connected branch sync back to Lovable and show up in > the editor, so keep the branch in a working state. + diff --git a/Dockerfile b/Dockerfile index e1a224f..093e11a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ COPY --from=build /app/.output ./.output EXPOSE 3000 +ENV NODE_ENV=production ENV PORT=3000 ENV HOST=0.0.0.0 diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index 2d1953f..0000000 --- a/nginx.conf +++ /dev/null @@ -1,18 +0,0 @@ -events {} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - server { - listen 3000; - server_name localhost; - - location / { - root /usr/share/nginx/html; - index index.html index.htm; - # This line forces Nginx to pass routing back to React Router - try_files $uri $uri/ /index.html; - } - } -} diff --git a/package-lock.json b/package-lock.json index 5cbe200..7991165 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5978,16 +5978,6 @@ } } }, - "node_modules/nitro/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "extraneous": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/nitro/node_modules/unstorage": { "version": "2.0.0-alpha.7", "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-2.0.0-alpha.7.tgz", diff --git a/public/assets/1.png b/public/assets/1.png new file mode 100644 index 0000000..2f8146c Binary files /dev/null and b/public/assets/1.png differ diff --git a/public/assets/2.png b/public/assets/2.png new file mode 100644 index 0000000..0464aca Binary files /dev/null and b/public/assets/2.png differ diff --git a/public/assets/3.jpg b/public/assets/3.jpg new file mode 100644 index 0000000..21b88b8 Binary files /dev/null and b/public/assets/3.jpg differ diff --git a/public/assets/4.jpeg b/public/assets/4.jpeg new file mode 100644 index 0000000..63c0eb2 Binary files /dev/null and b/public/assets/4.jpeg differ diff --git a/src/lib/contact.ts b/src/lib/contact.ts new file mode 100644 index 0000000..849144c --- /dev/null +++ b/src/lib/contact.ts @@ -0,0 +1,65 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; + +export const CONTACT_EMAIL = "hello@kapfoundry.com"; + +export const INTERESTS = [ + "Investing in the studio", + "Co-founding a venture", + "Partnership or collaboration", + "General enquiry", +] as const; + +const contactSchema = z.object({ + name: z.string().trim().min(1).max(200), + organization: z.string().trim().max(200).default(""), + email: z.string().trim().email().max(320), + interest: z.enum(INTERESTS), + message: z.string().trim().max(5000).default(""), + // Honeypot: hidden from people, so only bots fill it in. + website: z.string().default(""), +}); + +export type ContactInput = z.input; + +export type ContactResult = { ok: true } | { ok: false; reason: "not-configured" | "send-failed" }; + +/** + * Emails an enquiry to the studio via Resend (https://resend.com). + * Env: RESEND_API_KEY (required to send), CONTACT_TO_EMAIL, CONTACT_FROM_EMAIL. + * Without an API key it reports "not-configured" so the page can fall back to mailto. + */ +export const sendContactEnquiry = createServerFn({ method: "POST" }) + .validator((data: ContactInput) => contactSchema.parse(data)) + .handler(async ({ data }): Promise => { + if (data.website) return { ok: true }; + + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) return { ok: false, reason: "not-configured" }; + + const text = [ + `Name: ${data.name}`, + `Organization: ${data.organization || "-"}`, + `Email: ${data.email}`, + `Interested in: ${data.interest}`, + "", + data.message || "(no message)", + ].join("\n"); + + const res = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + from: process.env.CONTACT_FROM_EMAIL ?? "KapFoundry Website ", + to: [process.env.CONTACT_TO_EMAIL ?? CONTACT_EMAIL], + reply_to: data.email, + subject: `Website enquiry: ${data.interest} (${data.name})`, + text, + }), + }); + if (!res.ok) { + console.error("Contact email failed", res.status, await res.text()); + return { ok: false, reason: "send-failed" }; + } + return { ok: true }; + }); diff --git a/src/routes/README.md b/src/routes/README.md index 441a4e8..0990d2f 100644 --- a/src/routes/README.md +++ b/src/routes/README.md @@ -7,15 +7,15 @@ is `src/routes/__root.tsx`. ## Conventions -| File | URL | -| --- | --- | -| `index.tsx` | `/` | -| `about.tsx` | `/about` | -| `users/index.tsx` | `/users` | -| `users/$id.tsx` | `/users/:id` (dynamic — bare `$`, no curly braces) | -| `posts/{-$category}.tsx` | `/posts/:category?` (optional segment) | -| `files/$.tsx` | `/files/*` (splat — read via `_splat` param, never `*`) | -| `_layout.tsx` | layout route (renders children via ``) | -| `__root.tsx` | app shell — wraps every page; preserve `` | +| File | URL | +| ------------------------ | ------------------------------------------------------- | +| `index.tsx` | `/` | +| `about.tsx` | `/about` | +| `users/index.tsx` | `/users` | +| `users/$id.tsx` | `/users/:id` (dynamic — bare `$`, no curly braces) | +| `posts/{-$category}.tsx` | `/posts/:category?` (optional segment) | +| `files/$.tsx` | `/files/*` (splat — read via `_splat` param, never `*`) | +| `_layout.tsx` | layout route (renders children via ``) | +| `__root.tsx` | app shell — wraps every page; preserve `` | `routeTree.gen.ts` is auto-generated. Don't edit it by hand. diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 6e708a7..812fc61 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -47,12 +47,13 @@ function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {

This page didn't load

-

- Please refresh or head back home. -

+

Please refresh or head back home.

+
+ + + {/* Full-screen menu for phones and tablets: a circle that opens from the menu button. */} +