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 (
@@ -17,10 +13,7 @@ export default async function CompaniesPage() { locationOptions={availableLocations} /> - +
); } diff --git a/apps/web/src/app/api/auth/callback/route.ts b/apps/web/src/app/api/auth/callback/route.ts new file mode 100644 index 0000000..6de9244 --- /dev/null +++ b/apps/web/src/app/api/auth/callback/route.ts @@ -0,0 +1,71 @@ +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) { + 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 + 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, + }); +}; diff --git a/apps/web/src/app/api/delete-user/route.ts b/apps/web/src/app/api/delete-user/route.ts new file mode 100644 index 0000000..2c9cbb5 --- /dev/null +++ b/apps/web/src/app/api/delete-user/route.ts @@ -0,0 +1,41 @@ +import { createAdminClient, createClient } from "@/lib/supabase/server"; +import { NextResponse } from "next/server"; + +export async function POST() { + try { + const supabase = await createClient(); + const { data } = await supabase.auth.getClaims(); + + const user = data?.claims; + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const supabaseAdmin = await createAdminClient(); + + try { + const { data: files, error: listError } = await supabaseAdmin.storage + .from("avatars") + .list(user.sub); + + if (!listError && files && files.length > 0) { + const pathsToRemove = files.map((f) => `${user.sub}/${f.name}`); + await supabaseAdmin.storage.from("avatars").remove(pathsToRemove); + } + } catch {} + + const { error } = await supabaseAdmin.auth.admin.deleteUser(user.sub); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + return new Response(null, { status: 204 }); + } catch (err: unknown) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/og/_utils.ts b/apps/web/src/app/api/og/_utils.ts index 56df311..cb05031 100644 --- a/apps/web/src/app/api/og/_utils.ts +++ b/apps/web/src/app/api/og/_utils.ts @@ -12,7 +12,7 @@ export async function loadGoogleFont(font: string, text: string) { if (resource?.[1]) { const response = await fetch(resource[1]); - if (response.status == 200) { + if (response.status === 200) { return await response.arrayBuffer(); } } diff --git a/apps/web/src/app/api/og/route.tsx b/apps/web/src/app/api/og/route.tsx index f3c0c6c..6429e53 100644 --- a/apps/web/src/app/api/og/route.tsx +++ b/apps/web/src/app/api/og/route.tsx @@ -119,7 +119,7 @@ export async function GET(request: Request) { }, ); } catch (e) { - return new Response(`Failed to generate the og image`, { + return new Response("Failed to generate the og image", { status: 500, }); } diff --git a/apps/web/src/app/category/[category]/layout.tsx b/apps/web/src/app/category/[category]/layout.tsx index 89437e2..258c199 100644 --- a/apps/web/src/app/category/[category]/layout.tsx +++ b/apps/web/src/app/category/[category]/layout.tsx @@ -1,6 +1,5 @@ import Footer from "@/components/Footer"; import type { LayoutProps } from "@/lib/types"; -import { Suspense } from "react"; export default function CategoryPageLayout({ children }: LayoutProps) { return ( diff --git a/apps/web/src/app/category/[category]/page.tsx b/apps/web/src/app/category/[category]/page.tsx index 7e8fcb3..6ab3967 100644 --- a/apps/web/src/app/category/[category]/page.tsx +++ b/apps/web/src/app/category/[category]/page.tsx @@ -67,7 +67,7 @@ export default async function CategoryPage({ const category = decodeURIComponent(categoryParam); - const { companies, updatedAtISODate } = await getParsedCompaniesData(); + const { companies } = await getParsedCompaniesData(); const filteredCompanies = companies.filter((company) => company.categories.includes(category), @@ -78,12 +78,7 @@ export default async function CategoryPage({

Tech Companies | {category}

}> - +
diff --git a/apps/web/src/app/category/sitemap.ts b/apps/web/src/app/category/sitemap.ts index c9eb9a4..71210b8 100644 --- a/apps/web/src/app/category/sitemap.ts +++ b/apps/web/src/app/category/sitemap.ts @@ -6,7 +6,7 @@ export default async function sitemap(): Promise { const { availableCategories, updatedAtISODate } = await getParsedCompaniesData(); - let categoriesRoutes = availableCategories.map((category) => ({ + const categoriesRoutes = availableCategories.map((category) => ({ url: `${APP_URL}/category/${category}`, lastModified: updatedAtISODate, })); diff --git a/apps/web/src/app/company/[slug]/page.tsx b/apps/web/src/app/company/[slug]/page.tsx index 0db3322..a79d7a0 100644 --- a/apps/web/src/app/company/[slug]/page.tsx +++ b/apps/web/src/app/company/[slug]/page.tsx @@ -30,7 +30,7 @@ export async function generateMetadata({ params, }: { params: NextParams<{ slug: string }>; -}): Promise { +}): Promise { const { slug } = await params; const company = await getParsedCompanyBySlug(slug); @@ -124,7 +124,6 @@ export default async function CompanyPage({ ); } - const LinkUrlButton = ({ url, icon, @@ -144,7 +143,7 @@ const LinkUrlButton = ({ className="h-8 px-2 text-xs text-foreground" asChild > - +
{icon} {label} diff --git a/apps/web/src/app/company/sitemap.ts b/apps/web/src/app/company/sitemap.ts index 99094b7..8c03d37 100644 --- a/apps/web/src/app/company/sitemap.ts +++ b/apps/web/src/app/company/sitemap.ts @@ -5,7 +5,7 @@ import type { MetadataRoute } from "next"; export default async function sitemap(): Promise { const { companies, updatedAtISODate } = await getParsedCompaniesData(); - let companiesRoutes = companies.map((company) => ({ + const companiesRoutes = companies.map((company) => ({ url: `${APP_URL}/company/${company.slug}`, lastModified: updatedAtISODate, })); diff --git a/apps/web/src/app/error.tsx b/apps/web/src/app/error.tsx index d040c6f..19d163a 100644 --- a/apps/web/src/app/error.tsx +++ b/apps/web/src/app/error.tsx @@ -2,7 +2,7 @@ import { EmptyState } from "@/components/EmptyState"; -export default function Error() { +export default function ErrorPage() { return ( - - - - {children} - - - + + + + + + {children} + + + + + + + ); diff --git a/apps/web/src/app/llms.txt/route.ts b/apps/web/src/app/llms.txt/route.ts new file mode 100644 index 0000000..125e29c --- /dev/null +++ b/apps/web/src/app/llms.txt/route.ts @@ -0,0 +1,69 @@ +import { APP_URL } from "@/lib/metadata"; +import { getParsedCompaniesData } from "@/lib/parser/companies"; + +export const dynamic = "force-static"; +// by default now the GET routes are dynamic, but we can force them to be static +// then the fetch revalidate option will force the ISR + +export async function GET() { + const { companies, availableCategories, availableLocations } = + await getParsedCompaniesData(); + + const companiesData = companies.map((company) => { + return { + name: company.name, + description: `${company.name} - explore company details, careers and social links.`, + url: `${APP_URL}/company/${company.slug}`, + }; + }); + + const categoriesData = availableCategories.map((category) => { + return { + name: category, + description: `Explore tech companies specialized in ${category}.`, + url: `${APP_URL}/category/${category}`, + }; + }); + + const locationsData = availableLocations.map((location) => { + return { + name: location, + description: `Discover tech companies in ${location}.`, + url: `${APP_URL}/location/${location}`, + }; + }); + + const llmsTxt = `# TechCompaniesPortugal + +> TechCompaniesPortugal is a comprehensive directory for technology companies based in Portugal. + +## Key Features +- Discover tech companies in Portugal by name, description, location, or category. +- Sign up to get access to advanced features such as notifications and personalized settings. +- Promote or feature your company within the directory. + +## Home Directory Page +- [Home Page](${APP_URL}): Browse all companies and use search or filters to find specific profiles. + +## Company Profile Pages +${companiesData.map((url) => `- [${url.name}](${url.url}): ${url.description}`).join("\n")} + +## Location Pages +${locationsData.map((url) => `- [${url.name}](${url.url}): ${url.description}`).join("\n")} + +## Category Pages +${categoriesData.map((url) => `- [${url.name}](${url.url}): ${url.description}`).join("\n")} + +## Resources +- [Web App GitHub Repository](https://github.com/alexmarqs/tech-companies-portugal-app): The github repository for the web app. +- [Data GitHub Repository](https://github.com/marmelo/tech-companies-in-portugal): The github repository where the source data comes from. + +## About Author +- [Alexandre Marques](https://alexandremarques.io): Creator of TechCompaniesPortugal, a project dedicated to highlighting the Portuguese technology ecosystem.`; + + return new Response(llmsTxt, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + }); +} diff --git a/apps/web/src/app/location/[location]/layout.tsx b/apps/web/src/app/location/[location]/layout.tsx index 47c9887..a4fa0bf 100644 --- a/apps/web/src/app/location/[location]/layout.tsx +++ b/apps/web/src/app/location/[location]/layout.tsx @@ -1,7 +1,5 @@ import Footer from "@/components/Footer"; -import { Skeleton } from "@/components/ui/skeleton"; import type { LayoutProps } from "@/lib/types"; -import { Suspense } from "react"; export default function LocationPageLayout({ children }: LayoutProps) { return ( diff --git a/apps/web/src/app/location/[location]/page.tsx b/apps/web/src/app/location/[location]/page.tsx index ee95130..9397f1d 100644 --- a/apps/web/src/app/location/[location]/page.tsx +++ b/apps/web/src/app/location/[location]/page.tsx @@ -67,7 +67,7 @@ export default async function LocationPage({ const location = decodeURIComponent(locationParam); - const { companies, updatedAtISODate } = await getParsedCompaniesData(); + const { companies } = await getParsedCompaniesData(); const filteredCompanies = companies.filter((company) => company.locations.includes(location), @@ -79,12 +79,7 @@ export default async function LocationPage({

Tech Companies in {location}

}> - +
diff --git a/apps/web/src/app/location/sitemap.ts b/apps/web/src/app/location/sitemap.ts index a4e29ce..fffb039 100644 --- a/apps/web/src/app/location/sitemap.ts +++ b/apps/web/src/app/location/sitemap.ts @@ -6,7 +6,7 @@ export default async function sitemap(): Promise { const { availableLocations, updatedAtISODate } = await getParsedCompaniesData(); - let locationsRoutes = availableLocations.map((location) => ({ + const locationsRoutes = availableLocations.map((location) => ({ url: `${APP_URL}/location/${location}`, lastModified: updatedAtISODate, })); diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx new file mode 100644 index 0000000..93709ac --- /dev/null +++ b/apps/web/src/app/login/page.tsx @@ -0,0 +1,78 @@ +import { GithubLogin } from "@/components/GithubLogin"; +import { GoHomeLoginButton } from "@/components/GoHomeLoginButton"; +import { GoogleLogin } from "@/components/GoogleLogin"; +import { RetroContainer } from "@/components/ui/retro-container"; +import { + APP_URL, + defaultMetadata, + defaultOpenGraphMetadata, + defaultTwitterMetadata, +} from "@/lib/metadata"; +import Image from "next/image"; +import type { Metadata } from "next/types"; +import { Suspense } from "react"; +import logo from "../../../public/assets/images/logo.png"; + +const title = "Login | Tech Companies Portugal"; +const description = + "Sign in to your account and join the Portuguese tech community. Access company profiles, discover career opportunities, and stay updated with the latest tech companies in Portugal."; +const keywords = + "login, sign in, Portuguese tech community, tech companies Portugal, careers, account access"; + +export const metadata: Metadata = { + ...defaultMetadata, + title, + description, + keywords, + alternates: { + canonical: `${APP_URL}/login`, + }, + openGraph: { + ...defaultOpenGraphMetadata, + title, + description, + url: `${APP_URL}/login`, + images: [`api/og?title=${title}&description=${description}`], + }, + twitter: { + ...defaultTwitterMetadata, + title, + description, + images: [`api/og?title=${title}&description=${description}`], + }, +}; + +export default function LoginPage() { + return ( +
+
+ + + + +
+
+ Tech Companies Portugal Logo +
+
+

+ Join the community and be up to date with the latest tech + companies in Portugal. +

+
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/manifest.ts b/apps/web/src/app/manifest.ts new file mode 100644 index 0000000..4a9c3de --- /dev/null +++ b/apps/web/src/app/manifest.ts @@ -0,0 +1,40 @@ +import type { MetadataRoute } from "next"; + +// special Next.js route that will generate and cached (by the default) +// this will link to the /manifest.json file, header is automatically set + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "Tech Companies Portugal", + short_name: "Tech Companies PT", + description: + "Explore a comprehensive directory of tech companies in Portugal, featuring innovative startups and established industry leaders. Access descriptions, visit their websites, explore career opportunities, and connect through their digital presence.", + start_url: "/", + scope: "/", + display: "standalone", + orientation: "portrait-primary", // evaluate change to "any"? + background_color: "#ffffff", + theme_color: "#ffffff", + categories: ["business", "technology"], + icons: [ + { + src: "/assets/images/logo.png", + sizes: "192x192", + type: "image/png", + purpose: "any", + }, + { + src: "/assets/images/logo.png", + sizes: "384x384", + type: "image/png", + purpose: "any", + }, + { + src: "/assets/images/logo.png", + sizes: "512x512", + type: "image/png", + purpose: "any", + }, + ], + }; +} diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx new file mode 100644 index 0000000..29e8256 --- /dev/null +++ b/apps/web/src/app/settings/page.tsx @@ -0,0 +1,11 @@ +import { Settings } from "@/components/settings"; + +export default function SettingsPage() { + // let's keep this page here for SSR, possibly to prefech some data on the server side, suspense queries etc. + + return ( + <> + + + ); +} diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index f7dac71..2b9317b 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -2,7 +2,7 @@ import { APP_URL } from "@/lib/metadata"; import type { MetadataRoute } from "next/types"; export default function sitemap(): MetadataRoute.Sitemap { - let routes = [""].map((route) => ({ + const routes = [""].map((route) => ({ url: `${APP_URL}${route}`, lastModified: new Date().toISOString(), })); diff --git a/apps/web/src/components/AnimateNumber.tsx b/apps/web/src/components/AnimateNumber.tsx deleted file mode 100644 index 1ebf72e..0000000 --- a/apps/web/src/components/AnimateNumber.tsx +++ /dev/null @@ -1,9 +0,0 @@ -"use client"; - -import CountUp, { type CountUpProps } from "react-countup"; - -type AnimateNumberProps = CountUpProps; - -export const AnimateNumber = ({ ...props }: AnimateNumberProps) => { - return ; -}; diff --git a/apps/web/src/components/BackButton.tsx b/apps/web/src/components/BackButton.tsx new file mode 100644 index 0000000..2b494e3 --- /dev/null +++ b/apps/web/src/components/BackButton.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { ArrowLeft } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { Button, type ButtonProps } from "./ui/button"; + +type BackButtonProps = Omit & { + href?: string; + label?: string; + /** + * If true, uses browser history navigation (router.back()). + * If false or no history, falls back to href or "/". + * @default false + */ + useBrowserHistory?: boolean; +}; + +export const BackButton = ({ + href, + label, + useBrowserHistory = false, + ...props +}: BackButtonProps) => { + const router = useRouter(); + + const handleClick = () => { + if (useBrowserHistory && window.history.length > 1) { + router.back(); + } else { + router.push(href || "/"); + } + }; + + return ( + + ); +}; diff --git a/apps/web/src/components/CompaniesHeader.tsx b/apps/web/src/components/CompaniesHeader.tsx index 30951b0..a8ae1b5 100644 --- a/apps/web/src/components/CompaniesHeader.tsx +++ b/apps/web/src/components/CompaniesHeader.tsx @@ -1,29 +1,25 @@ -import { PlayCircle } from "lucide-react"; -import { AnimateNumber } from "./AnimateNumber"; - export default function CompaniesHeader() { return (
-
- - - - + Tech Companies - -
-
-

- Find top tech companies in - Portugal +

+ + Find top tech companies{" "} + in Portugal +

+

- Discover the best tech companies hiring in Portugal, from startups - to established enterprises, all in one place. + Discover the best tech companies hiring in Portugal — from startups + to established enterprises — all in one place.

diff --git a/apps/web/src/components/CompaniesList.tsx b/apps/web/src/components/CompaniesList.tsx index fc2643a..e61fd63 100644 --- a/apps/web/src/components/CompaniesList.tsx +++ b/apps/web/src/components/CompaniesList.tsx @@ -4,27 +4,23 @@ import type { Company } from "@/lib/types"; import { matchCompanies } from "@/lib/utils"; import { motion } from "motion/react"; import { useMemo } from "react"; +import { useSearchQueryParams } from "../hooks/useSearchQueryParams"; import CompaniesListFooter from "./CompaniesListFooter"; import { CompaniesListHeader } from "./CompaniesListHeader"; import CompanyItem from "./CompanyItem"; import { EmptyState } from "./EmptyState"; import FeaturedSideSection from "./FeaturedSideSection"; -import { useSearchQueryParams } from "./hooks/useSearchQueryParams"; const PAGE_SIZE = 15; type CompaniesListProps = { allCompanies: Company[]; - updatedAtISODate: string; isDedicatedPage?: boolean; - hideUpdatedAt?: boolean; }; export default function CompaniesList({ allCompanies, - updatedAtISODate, isDedicatedPage = false, - hideUpdatedAt, }: CompaniesListProps) { const { searchParams: { query, category, location, page }, @@ -68,10 +64,8 @@ export default function CompaniesList({ transition={{ duration: 0.3 }} > )} diff --git a/apps/web/src/components/CompaniesListFooter.tsx b/apps/web/src/components/CompaniesListFooter.tsx index b459139..d6a5ea1 100644 --- a/apps/web/src/components/CompaniesListFooter.tsx +++ b/apps/web/src/components/CompaniesListFooter.tsx @@ -5,7 +5,7 @@ import { ChevronsLeft, ChevronsRight, } from "lucide-react"; -import { useSearchQueryParams } from "./hooks/useSearchQueryParams"; +import { useSearchQueryParams } from "../hooks/useSearchQueryParams"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; @@ -30,7 +30,10 @@ export default function CompaniesListFooter({ data-testid="companies-list-footer" >
- + Page {currentPage} of {totalPages}
@@ -38,7 +41,7 @@ export default function CompaniesListFooter({ + + + + Talk to us + + Do you have any questions? We are looking for sponsors and + advertisers. Feel free to reach out to us. + + + + + + ); + } + + return ( + + + + + +
+ + Talk to us + + Do you have any questions? We are looking for sponsors and + advertisers. Feel free to reach out to us. + + +
+ +
+
+ + + + + +
+
+ ); +}; + +const ContactForm = () => { + const [isSubmitted, setSubmitted] = useState(false); + const [isPending, startTransition] = useTransition(); + const { data: userProfile } = useGetUserProfile(); + const userEmail = userProfile?.email; + + if (!userEmail) { + return ( +
+ +
+ ); + } + + return isSubmitted ? ( +
+ Message sent successfully 🎉 +
+ ) : ( +
{ + startTransition(async () => { + try { + await sendContactMessageAction(formData); + setSubmitted(true); + } catch (error) { + console.error(error); + toast.error("Failed to send message. Try again later."); + } finally { + setTimeout(() => setSubmitted(false), 2500); + } + }); + }} + > +