Skip to content
Merged
Show file tree
Hide file tree
Changes from 58 commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
e3eb38c
chore: update README to include TODOs
alexmarqs Jul 21, 2025
f1a05c3
feat (wip): start integrating supabase + user menu, settings page
alexmarqs Aug 6, 2025
7e6030a
feat: integrate Sonner for toast notifications and enhance user profi…
alexmarqs Aug 7, 2025
9d0fd70
refactor: clean up imports and remove unused components in layout and…
alexmarqs Aug 7, 2025
4a81b33
chore: enhance form handling in AccountName component with validation…
alexmarqs Aug 8, 2025
2b08271
refactor: simplify form submission logic in AccountName component and…
alexmarqs Aug 10, 2025
d5393af
refactor: update DeleteAccount component layout and styling for impro…
alexmarqs Aug 13, 2025
40e2ff4
chore: update dependencies and improve CI workflow for better perform…
alexmarqs Aug 15, 2025
c6363f7
feat: implement alert dialog for account deletion and update dependen…
alexmarqs Aug 15, 2025
cb23602
feat: add logo to login page and refactor search query params usage a…
alexmarqs Aug 17, 2025
e88a273
feat: integrate session management in UserMenu component and enhance …
alexmarqs Aug 18, 2025
3e4a65c
feat: wrap GoHomeLoginButton in Suspense due to usage of search params
alexmarqs Aug 18, 2025
6857a94
feat: enhance user profile mutation with success and error notificati…
alexmarqs Aug 22, 2025
4fdcaf6
fix: update label in SearchSideBar component from "Search term" to "S…
alexmarqs Aug 22, 2025
c6bcb4f
wip: implement avatar upload functionality in AccountAvatar component…
alexmarqs Aug 23, 2025
2bdb1e3
feat: enhance AccountAvatar component with file upload handling, prev…
alexmarqs Aug 31, 2025
80bd37d
feat: implement user deletion API and enhance AccountAvatar and UserM…
alexmarqs Aug 31, 2025
c339ad4
chore: update .env.example to include missing placeholders SUPABASE_S…
alexmarqs Aug 31, 2025
8d0e7c2
chore: update db:types command in package.json for improved dotenv us…
alexmarqs Sep 1, 2025
2af814c
fix: improve avatar handling in UserMenu and AccountAvatar components…
alexmarqs Sep 10, 2025
c788a05
feat: add email components for welcome and footer sections, update pa…
alexmarqs Sep 14, 2025
17ef619
chore: add CLAUDE.md to .gitignore to prevent tracking of the file
alexmarqs Sep 14, 2025
ad64840
chore: update @react-email/preview-server dependency to version 4.2.8…
alexmarqs Sep 14, 2025
834ba74
chore: update .gitignore to include AGENTS.md and retain CLAUDE.md
alexmarqs Sep 30, 2025
cc9f366
feat: integrate Plunk email service, add email utility functions, and…
alexmarqs Oct 1, 2025
9d4f148
feat: add welcome email functionality and integrate @vercel/functions…
alexmarqs Oct 4, 2025
403798b
refactor: remove unused email type and subscription fields from email…
alexmarqs Oct 4, 2025
1b5b179
feat: add Google login component to the login page
alexmarqs Oct 4, 2025
f92579d
refactor: simplify CompaniesList component by removing updatedAtISODa…
alexmarqs Oct 5, 2025
f12afcc
test: add mock Supabase environment variables for Playwright testing …
alexmarqs Oct 8, 2025
55ccb5c
chore: update CI workflow to include additional Supabase environment …
alexmarqs Oct 8, 2025
587f7c8
fix: increase loading timeout in GithubLogin and GoogleLogin componen…
alexmarqs Oct 12, 2025
97262a4
feat: add static GET route for generating llms.txt with company, cate…
alexmarqs Oct 12, 2025
105b831
feat: integrate analytics tracking for user interactions in FeaturedS…
alexmarqs Oct 12, 2025
767e442
feat: replace button in FeaturedSideSection with RequestFeaturedButto…
alexmarqs Oct 12, 2025
f45ad50
feat: enhance image handling in Next.js config and add analytics trac…
alexmarqs Oct 12, 2025
c4280b2
fix: improve loading state management in GithubLogin and GoogleLogin …
alexmarqs Oct 12, 2025
4cea79c
fix: correct Supabase URL assignment in CI workflow and simplify data…
alexmarqs Oct 12, 2025
7eb356c
fix: update GitHub repository links and improve error handling in ema…
alexmarqs Oct 13, 2025
7aea44b
feat: enhance BackButton component with browser history navigation an…
alexmarqs Oct 13, 2025
78dabd4
fix: correct typo in welcome email template for notifications
alexmarqs Oct 13, 2025
3b1b4d2
fix: ensure router replace for logout is consistently called in UserM…
alexmarqs Oct 13, 2025
1ef59eb
fix: revoke blob URL on unmount in AccountAvatar component to prevent…
alexmarqs Oct 13, 2025
e9031a7
fix: remove unnecessary remote pattern for Supabase images in Next.js…
alexmarqs Oct 13, 2025
5ab6549
feat: add contact form functionality with email sending capability an…
alexmarqs Oct 18, 2025
3ffcd52
fix: restrict ContactButton visibility based on user authentication s…
alexmarqs Oct 18, 2025
0cec3e6
feat: implement PWA support with install banner and manifest for enha…
alexmarqs Oct 18, 2025
34b163f
feat: update ContactButton UI and enhance error handling in contact f…
alexmarqs Oct 18, 2025
fd77dbb
feat: enhance contact form validation and improve email sending logic…
alexmarqs Oct 18, 2025
ab1ff30
fix: update footer text from "Crafted by" to "Built by" and adjust re…
alexmarqs Oct 18, 2025
da13352
fix: remove unused size prop from FiltersButton component
alexmarqs Oct 18, 2025
988e7c0
fix: update rate limiting TODO comments in sendContactMessageAction a…
alexmarqs Oct 18, 2025
5bf4c38
fix: update PWAInstallBanner to prevent prompt from showing if dismis…
alexmarqs Oct 19, 2025
38cf584
fix: update manifest and PWAInstallBanner for improved functionality …
alexmarqs Oct 19, 2025
a63f178
docs: update README to enhance PWA support description and add LLMs.t…
alexmarqs Oct 19, 2025
4a682de
refactor: remove react-countup dependency and update email formatting…
alexmarqs Oct 19, 2025
f79204d
feat: integrate Arcjet for rate limiting in contact form submissions …
alexmarqs Oct 19, 2025
df73c6b
refactor: simplify PWAInstallBanner logic for dismissing prompts and …
alexmarqs Oct 19, 2025
3dd0ca3
fix: escape user messages in sendContactMessageAction to prevent XSS …
alexmarqs Oct 19, 2025
2bccec8
fix: remove TODO comment for rate limiting in auth callback route
alexmarqs Oct 19, 2025
361a567
docs: add Arcjet reference for rate limiting in README
alexmarqs Oct 19, 2025
685ade3
docs: update README to provide a clearer monorepo structure overview
alexmarqs Oct 19, 2025
985e546
docs: remove redundant line from README regarding monorepo structure
alexmarqs Oct 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: CI

on:
push:
branches:
branches:
- main
pull_request:
branches:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -85,4 +89,3 @@ jobs:
name: playwright-report-web
path: ./apps/web/playwright-report/
retention-days: 2

7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dist
# vercel
.vercel
.env.vercel
.env

# typescript
*.tsbuildinfo
Expand All @@ -42,5 +43,7 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

.cursor/mcp.json

# ai coding agents
.cursor
CLAUDE.md
AGENTS.md
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,15 @@ 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)


## How to contribute 🤝

Expand Down
11 changes: 10 additions & 1 deletion apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -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=
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=
12 changes: 12 additions & 0 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
31 changes: 26 additions & 5 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,48 +6,69 @@
"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",
"date-fns": "3.3.1",
"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"
Expand Down
7 changes: 7 additions & 0 deletions apps/web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Binary file added apps/web/public/assets/images/email/github.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/assets/images/email/x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
80 changes: 80 additions & 0 deletions apps/web/src/actions/send-contact-message-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"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 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];
const fullName = user.user_metadata?.full_name;

await emailService.sendEmail({
from: fromEmail,
name: "Tech Companies Portugal",
subject: "New message received — Tech Companies Portugal",
body: `
<p>Hello 👋</p>
<p>You have received a new message through the <strong>Tech Companies Portugal</strong> website.</p>

<p><strong>From:</strong> ${firstPartEmail}${fullName ? ` (${fullName})` : ""}</p>

<p><strong>Message:</strong></p>
<blockquote style="border-left:3px solid #ccc;padding-left:8px;margin:10px 0;">
<p>${message}</p>
</blockquote>

<hr>
<p style="font-size:12px;color:#666;">
Automated message from Tech Companies Portugal — ${new Date().toLocaleDateString()}.
</p>
`,
to: toEmail,
});

return { success: true };
};
13 changes: 3 additions & 10 deletions apps/web/src/app/(companies-list)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<section className="relative flex flex-1 flex-col gap-6 md:flex-row">
Expand All @@ -17,10 +13,7 @@ export default async function CompaniesPage() {
locationOptions={availableLocations}
/>

<CompaniesList
allCompanies={companies}
updatedAtISODate={updatedAtISODate}
/>
<CompaniesList allCompanies={companies} />
</section>
);
}
73 changes: 73 additions & 0 deletions apps/web/src/app/api/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import WelcomeEmail from "@/emails/templates/welcome";
import { emailService } from "@/lib/email";
import { createClient } from "@/lib/supabase/server";
import { render } from "@react-email/components";
import { waitUntil } from "@vercel/functions";
import { differenceInSeconds } from "date-fns";
import { NextResponse } from "next/server";

export async function GET(request: Request) {
// TODO: Rate limit

const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
// if "next" is in params, use it as the redirect URL
const next = searchParams.get("next") ?? "/";

if (code) {
const supabase = await createClient();
const { error, data } = await supabase.auth.exchangeCodeForSession(code);

if (!error) {
const forwardedHost = request.headers.get("x-forwarded-host");
const isLocalEnv = process.env.NODE_ENV === "development";

// if the user was created in the last 15 seconds, send the welcome email
Comment on lines +20 to +23
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Don’t trust X-Forwarded-Host blindly; restrict to an allowlist.

Attackers can send a forged header to force redirects off-site. Parse the first value and only honor it if it matches your known host; otherwise fall back to origin.

Apply:

-      const forwardedHost = request.headers.get("x-forwarded-host");
+      // use first value only; some proxies send comma-separated list
+      const forwardedHostHeader =
+        request.headers.get("x-forwarded-host") || "";
+      const forwardedHost = forwardedHostHeader.split(",")[0].trim();
+      const allowedHost = new URL(
+        process.env.NEXT_PUBLIC_APP_URL || origin,
+      ).host;
-      if (forwardedHost) {
+      if (forwardedHost && forwardedHost === allowedHost) {
         // in case we user load balancer or proxy, we need to redirect to the correct host
-        return NextResponse.redirect(`https://${forwardedHost}${next}`);
+        return NextResponse.redirect(`https://${forwardedHost}${sanitizedNext}`);
       }

Also applies to: 46-49

🤖 Prompt for AI Agents
In apps/web/src/app/api/auth/callback/route.ts around lines 20-23 and 46-49,
don’t trust the X-Forwarded-Host header blindly: parse the header, take the
first comma-separated value, trim and lowercase it, then only use it if it
exactly matches an allowlist (e.g. a single expected hostname or a list from
process.env.ALLOWED_HOSTS); otherwise fall back to the request origin or a
configured default host. Implement the allowlist check, sanitize the header
value, and use the safe host for any redirect/construction logic.

if (
data?.session?.user.created_at &&
differenceInSeconds(
new Date(),
new Date(data.session.user.created_at),
) < 15 &&
data?.session?.user.email
) {
// this will be run in the background
waitUntil(
sendWelcomeEmail(
data?.session?.user.email,
data?.session?.user.user_metadata.full_name,
),
);
}

if (isLocalEnv) {
// we can be sure that there is no load balancer in between, so no need to watch for X-Forwarded-Host
return NextResponse.redirect(`${origin}${next}`);
}

if (forwardedHost) {
// in case we user load balancer or proxy, we need to redirect to the correct host
return NextResponse.redirect(`https://${forwardedHost}${next}`);
}

return NextResponse.redirect(`${origin}${next}`);
}
}

// if there is an error or no code, redirect to the auth code error page with possible instructions
return NextResponse.redirect(`${origin}/auth/auth-code-error`);
}

const sendWelcomeEmail = async (email: string, name = "there") => {
const emailHtml = await render(
WelcomeEmail({
userFirstname: name,
}),
);

await emailService.sendEmail({
to: email,
subject: "Welcome to Tech Companies Portugal",
body: emailHtml,
});
};
Loading