diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51dffc7..cc09d5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: + branches: - main pull_request: branches: @@ -25,8 +25,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version-file: '.nvmrc' - cache: 'npm' + node-version-file: ".nvmrc" + cache: "npm" - name: Install dependencies run: npm ci @@ -49,6 +49,10 @@ jobs: env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} steps: - name: Check out code uses: actions/checkout@v4 @@ -58,12 +62,12 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version-file: '.nvmrc' - cache: 'npm' + node-version-file: ".nvmrc" + cache: "npm" - name: Install dependencies run: npm ci - + - name: Cache Playwright browsers id: playwright-cache uses: actions/cache@v3 @@ -85,4 +89,3 @@ jobs: name: playwright-report-web path: ./apps/web/playwright-report/ retention-days: 2 - \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1035324..7f90e53 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ dist # vercel .vercel .env.vercel +.env # typescript *.tsbuildinfo @@ -42,5 +43,7 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -.cursor/mcp.json - +# ai coding agents +.cursor +CLAUDE.md +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 1a1c2f0..033d959 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,19 @@ The main goal is to provide a better way to explore tech companies in Portugal. ## Monorepo structure 📦 -- `apps/web`: The main web app -- `packages/analytics`: Analytics utils -- `tooling/typescript`: TypeScript configuration -- `tooling/tailwind`: Tailwind configuration +``` +tech-companies-portugal-app/ +├── apps/ +│ ├── web/ +│ └── ... +├── packages/ +│ ├── analytics/ +│ └── ... +├── tooling/ +│ ├── typescript/ +│ └── tailwind/ +│ └── ... +``` ## Features 🚀 @@ -29,9 +38,16 @@ The main goal is to provide a better way to explore tech companies in Portugal. - [Biome](https://biomejs.dev/) - Formatting and linting - [Motion](https://motion.dev/) - Animation library - [Nuqs](https://nuqs.47ng.com) - URL query state management (client and server support + some other cool features out of the box) +- [Plunk](https://useplunk.com/) - Email service - [Turbo](https://turbo.build/) - Monorepo build system - [Vercel](https://vercel.com/) - Hosting and CI/CD -- [PostHog](https://posthog.com/) - Analytics (replaces [OpenPanel](https://openpanel.dev/) due to removal of free tier) +- [PostHog](https://posthog.com/) - Analytics. Coming next, usage of MCP for automatic analytics dashboard +- [Supabase](https://supabase.com/) - Auth, DB, MCP +- [React Email](https://react.email/) - Email components +- [PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps) - Basic support for PWA. Coming next, usage of [next-pwa](https://github.com/shadowwalker/next-pwa) to add more features +- [LLMs.txt](https://llmstxt.org/) - Support for the proposed standard that acts as a guide for large language models (LLMs) +- [Arcjet](https://arcjet.com/) - Rate limiting + ## How to contribute 🤝 diff --git a/apps/web/.env.example b/apps/web/.env.example index 3459575..ad25f75 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,3 +1,12 @@ # For turbo builds, prefixes with NEXT_PUBLIC_ are automatically considered to force a rebuild NEXT_PUBLIC_POSTHOG_KEY= -NEXT_PUBLIC_POSTHOG_HOST= \ No newline at end of file +NEXT_PUBLIC_POSTHOG_HOST= +NEXT_PUBLIC_SUPABASE_URL= +# Make sure to enable RLS and policies to use this key in client +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_URL= +PLUNK_API_KEY= +CONTACT_FROM_EMAIL= +CONTACT_TO_EMAIL= +ARCJET_KEY= \ No newline at end of file diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 6dcc5d8..5d8a7d0 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -3,6 +3,18 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { reactStrictMode: true, transpilePackages: ["@tech-companies-portugal/analytics"], + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "lh3.googleusercontent.com", + }, + { + protocol: "https", + hostname: "avatars.githubusercontent.com", + }, + ], + }, }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index c4cbcf9..0df5c38 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,20 +6,36 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "biome check . --write", + "lint": "biome check . --write --unsafe", "format": "biome format --write .", "check-types": "tsc --noEmit", "clean": "git clean -xdf .next .turbo node_modules playwright-report test-results", "test": "echo 'No tests to run'", "test:e2e": "start-server-and-test start http://localhost:3000 'playwright test'", - "test:e2e:ui": "start-server-and-test start http://localhost:3000 'playwright test --ui'" + "test:e2e:ui": "start-server-and-test start http://localhost:3000 'playwright test --ui'", + "db:types": "dotenv -e .env.local -- npx supabase gen types typescript --project-id $PROJECT_REF --schema public > ./src/lib/supabase/database.types.ts", + "email:dev": "email dev --port 3002 --dir src/emails/templates" }, "dependencies": { + "@arcjet/next": "1.0.0-beta.13", + "@hookform/resolvers": "5.2.1", + "@plunk/node": "3.0.3", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-collapsible": "1.1.11", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-dropdown-menu": "2.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-select": "2.2.5", "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-tabs": "1.1.12", + "@react-email/components": "0.5.1", + "@react-email/render": "1.3.1", + "@supabase/ssr": "0.6.1", + "@supabase/supabase-js": "2.52.0", + "@tanstack/react-query": "5.84.1", "@tech-companies-portugal/analytics": "*", + "@vercel/functions": "3.1.1", "cheerio": "1.0.0-rc.12", "class-variance-authority": "0.7.1", "clsx": "2.1.0", @@ -27,27 +43,32 @@ "geist": "1.3.1", "lucide-react": "0.539.0", "motion": "11.17.0", - "next": "15.4.6", + "next": "15.4.7", "nuqs": "2.3.1", "react": "19.1.1", - "react-countup": "6.5.3", "react-dom": "19.1.1", + "react-hook-form": "7.62.0", "recharts": "2.15.0", "sharp": "0.33.2", "slugify": "1.6.6", + "sonner": "2.0.7", "tailwind-merge": "2.2.1", "tailwindcss-animate": "1.0.7", - "use-debounce": "10.0.4" + "vaul": "1.1.2", + "zod": "4.0.14" }, "devDependencies": { "@playwright/test": "1.50.0", + "@react-email/preview-server": "4.2.8", "@tech-companies-portugal/tailwind": "*", "@tech-companies-portugal/typescript": "*", "@types/node": "^20", "@types/react": "19.1.1", "@types/react-dom": "19.1.1", "autoprefixer": "^10.0.1", + "dotenv-cli": "10.0.0", "postcss": "^8", + "react-email": "4.2.8", "start-server-and-test": "2.0.10", "tailwindcss": "^3.3.0", "typescript": "^5" diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 3ca9757..7610e01 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -8,6 +8,13 @@ import { defineConfig, devices } from "@playwright/test"; // import path from 'path'; // dotenv.config({ path: path.resolve(__dirname, '.env') }); +// Set mock Supabase environment variables for testing +process.env.NEXT_PUBLIC_SUPABASE_URL = + process.env.NEXT_PUBLIC_SUPABASE_URL || "https://mock-project.supabase.co"; +process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ4.mock-anon-key-for-testing"; + /** * See https://playwright.dev/docs/test-configuration. */ diff --git a/apps/web/public/assets/images/email/github.png b/apps/web/public/assets/images/email/github.png new file mode 100644 index 0000000..ae9565d Binary files /dev/null and b/apps/web/public/assets/images/email/github.png differ diff --git a/apps/web/public/assets/images/email/x.png b/apps/web/public/assets/images/email/x.png new file mode 100644 index 0000000..e69c119 Binary files /dev/null and b/apps/web/public/assets/images/email/x.png differ diff --git a/apps/web/src/actions/send-contact-message-action.ts b/apps/web/src/actions/send-contact-message-action.ts new file mode 100644 index 0000000..5465b91 --- /dev/null +++ b/apps/web/src/actions/send-contact-message-action.ts @@ -0,0 +1,90 @@ +"use server"; + +import { emailService } from "@/lib/email"; +import { createClient } from "@/lib/supabase/server"; +import arcjet, { request, slidingWindow } from "@arcjet/next"; + +// Per user rate limiter: 3 requests per day per user. +// This is to prevent abuse of the contact form. +const rateLimiter = arcjet({ + key: process.env.ARCJET_KEY!, + rules: [ + slidingWindow({ + mode: "LIVE", // will block requests. Use "DRY_RUN" to log only + characteristics: ["userId"], + interval: "24h", // 24 hour sliding window + max: 3, // allow a maximum of 3 requests per day per IP address + }), + ], +}); + +export const sendContactMessageAction = async (formData: FormData) => { + const supabase = await createClient(); + + // Get the authenticated user's email from the server session + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user?.email) { + throw new Error("User not authenticated"); + } + + const req = await request(); + const decision = await rateLimiter.protect(req, { userId: user.id }); + + if (decision.isDenied()) + throw new Error("Rate limit exceeded. Please try again later."); + + const message = formData.get("message")?.toString().trim(); + + if (!message || message.length === 0) { + throw new Error("Message is required"); + } + + const escapedMessage = escapeString(message); + const fromEmail = process.env.CONTACT_FROM_EMAIL; + const toEmail = process.env.CONTACT_TO_EMAIL; + + if (!fromEmail || !toEmail) { + throw new Error("Email configuration is missing"); + } + + const firstPartEmail = user.email.split("@")[0]; + + await emailService.sendEmail({ + from: fromEmail, + name: "Tech Companies Portugal", + subject: "New message received — Tech Companies Portugal", + body: ` +
Hello 👋
+You have received a new message through the Tech Companies Portugal website.
+ +From: ${firstPartEmail}
+ +Message:
+++ +${escapedMessage}
+
+ Automated message from Tech Companies Portugal — ${new Date().toLocaleDateString()}. +
+ `, + to: toEmail, + }); + + return { success: true }; +}; + +// Escape a string to prevent XSS attacks +const escapeString = (str: string) => { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +}; diff --git a/apps/web/src/app/(companies-list)/page.tsx b/apps/web/src/app/(companies-list)/page.tsx index e6b0817..8fb5331 100644 --- a/apps/web/src/app/(companies-list)/page.tsx +++ b/apps/web/src/app/(companies-list)/page.tsx @@ -3,12 +3,8 @@ import { SideBar } from "@/components/SideBar"; import { getParsedCompaniesData } from "@/lib/parser/companies"; export default async function CompaniesPage() { - const { - availableCategories, - availableLocations, - companies, - updatedAtISODate, - } = await getParsedCompaniesData(); + const { availableCategories, availableLocations, companies } = + await getParsedCompaniesData(); return (