From 0da936550bfbfe852c9a965f3aa23bfe77725f26 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Wed, 31 Dec 2025 08:48:32 -0300 Subject: [PATCH] feat(frontend): add runtime config for environment variables - Add /api/config endpoint for runtime env var fetching - Add config.ts service with sync getters (getApiUrl, getBackofficeUrl, etc.) - Add ConfigContext for React components - Update api.ts, auth.ts, storage.ts to use runtime config - Update layout.tsx to wrap app with ConfigProvider - Fix Dockerfile default port from 8080 to 8521 This allows the frontend to read environment variables at runtime instead of baking them in during build time. --- frontend/Dockerfile | 2 +- frontend/src/app/api/config/route.ts | 25 +++++ frontend/src/app/layout.tsx | 31 +++--- frontend/src/contexts/ConfigContext.tsx | 90 +++++++++++++++++ frontend/src/lib/api.ts | 14 +-- frontend/src/lib/auth.ts | 11 +- frontend/src/lib/config.ts | 128 ++++++++++++++++++++++++ frontend/src/lib/storage.ts | 10 +- 8 files changed, 280 insertions(+), 31 deletions(-) create mode 100644 frontend/src/app/api/config/route.ts create mode 100644 frontend/src/contexts/ConfigContext.tsx create mode 100644 frontend/src/lib/config.ts diff --git a/frontend/Dockerfile b/frontend/Dockerfile index c349b1c..89b3f2d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -38,7 +38,7 @@ COPY src ./src COPY messages ./messages # Build arguments -ARG NEXT_PUBLIC_API_URL=http://localhost:8080 +ARG NEXT_PUBLIC_API_URL=http://localhost:8521 ARG NEXT_PUBLIC_BACKOFFICE_URL=http://localhost:3001 ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_BACKOFFICE_URL=$NEXT_PUBLIC_BACKOFFICE_URL diff --git a/frontend/src/app/api/config/route.ts b/frontend/src/app/api/config/route.ts new file mode 100644 index 0000000..6b447f3 --- /dev/null +++ b/frontend/src/app/api/config/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server'; + +/** + * Runtime Configuration API + * + * This endpoint returns environment variables that can be read at runtime, + * allowing the application to use different configurations without rebuilding. + * + * Usage: Fetch /api/config on app initialization and use the returned values. + */ +export async function GET() { + const config = { + apiUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8521', + backofficeUrl: process.env.NEXT_PUBLIC_BACKOFFICE_URL || 'http://localhost:3001', + seederApiUrl: process.env.NEXT_PUBLIC_SEEDER_API_URL || 'http://localhost:3002', + scraperApiUrl: process.env.NEXT_PUBLIC_SCRAPER_API_URL || 'http://localhost:3003', + }; + + return NextResponse.json(config, { + headers: { + // Cache for 5 minutes to avoid too many requests + 'Cache-Control': 'public, max-age=300, s-maxage=300', + }, + }); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index db06dff..7ef37c4 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -6,6 +6,7 @@ import { Analytics } from "@vercel/analytics/next" import { Toaster } from "sonner" import { NotificationProvider } from "@/contexts/notification-context" import { ThemeProvider } from "@/contexts/ThemeContext" +import { ConfigProvider } from "@/contexts/ConfigContext" import { I18nProvider } from "@/lib/i18n" import "./globals.css" import { Suspense } from "react" @@ -33,20 +34,22 @@ export default function RootLayout({ return ( - - - - }>{children} - - - - + + + + + }>{children} + + + + + {process.env.NODE_ENV === "production" && shouldLoadAnalytics && } diff --git a/frontend/src/contexts/ConfigContext.tsx b/frontend/src/contexts/ConfigContext.tsx new file mode 100644 index 0000000..a64791b --- /dev/null +++ b/frontend/src/contexts/ConfigContext.tsx @@ -0,0 +1,90 @@ +'use client'; + +import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react'; + +export interface RuntimeConfig { + apiUrl: string; + backofficeUrl: string; + seederApiUrl: string; + scraperApiUrl: string; +} + +const defaultConfig: RuntimeConfig = { + apiUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8521', + backofficeUrl: process.env.NEXT_PUBLIC_BACKOFFICE_URL || 'http://localhost:3001', + seederApiUrl: process.env.NEXT_PUBLIC_SEEDER_API_URL || 'http://localhost:3002', + scraperApiUrl: process.env.NEXT_PUBLIC_SCRAPER_API_URL || 'http://localhost:3003', +}; + +interface ConfigContextType { + config: RuntimeConfig; + isLoading: boolean; +} + +const ConfigContext = createContext({ + config: defaultConfig, + isLoading: true, +}); + +export function ConfigProvider({ children }: { children: ReactNode }) { + const [config, setConfig] = useState(defaultConfig); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + async function fetchConfig() { + try { + const response = await fetch('/api/config'); + if (response.ok) { + const data = await response.json(); + setConfig(data); + } + } catch (error) { + console.warn('[Config] Failed to fetch runtime config, using defaults:', error); + } finally { + setIsLoading(false); + } + } + + fetchConfig(); + }, []); + + return ( + + {children} + + ); +} + +export function useConfig(): RuntimeConfig { + const { config } = useContext(ConfigContext); + return config; +} + +export function useConfigContext(): ConfigContextType { + return useContext(ConfigContext); +} + +// Helper function for non-React contexts (like lib files) +// This returns a promise that resolves to the config +let cachedConfig: RuntimeConfig | null = null; + +export async function getConfig(): Promise { + if (cachedConfig) return cachedConfig; + + try { + const response = await fetch('/api/config'); + if (response.ok) { + cachedConfig = await response.json(); + return cachedConfig!; + } + } catch { + console.warn('[Config] Failed to fetch config, using defaults'); + } + + return defaultConfig; +} + +// Sync getter for when you need immediate access (uses cached or default) +export function getConfigSync(): RuntimeConfig { + return cachedConfig || defaultConfig; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f38c87f..1a01b6b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,9 +1,9 @@ import { toast } from "sonner"; import { Job } from "./types"; +import { getApiUrl, getBackofficeUrl } from "./config"; -// API Base URL - endpoints already include /api/v1 prefix -// NEXT_PUBLIC_API_URL should be just the domain, e.g. https://api.gohorsejobs.com -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || ""; +// API Base URL - now uses runtime config +// Fetched from /api/config at runtime, falls back to build-time env or defaults /** * Helper to log CRUD actions for the 'Activity Log' or console @@ -24,7 +24,7 @@ async function apiRequest(endpoint: string, options: RequestInit = {}): Promi ...options.headers, }; - const response = await fetch(`${API_BASE_URL}${endpoint}`, { + const response = await fetch(`${getApiUrl()}${endpoint}`, { ...options, headers, credentials: "include", // Enable cookie sharing @@ -552,7 +552,7 @@ export const profileApi = { // For now, assuming saving the key is what's requested ("salvando as chaves"). // We use the generic updateProfile method. const token = localStorage.getItem("token"); - const res = await fetch(`${API_BASE_URL}/api/v1/users/me/profile`, { + const res = await fetch(`${getApiUrl()}/api/v1/users/me/profile`, { method: "PATCH", headers: { "Content-Type": "application/json", @@ -569,7 +569,7 @@ export const profileApi = { // ============================================================================= // BACKOFFICE API (Stripe, Admin Dashboard, etc.) // ============================================================================= -const BACKOFFICE_URL = process.env.NEXT_PUBLIC_BACKOFFICE_URL || ""; +// Backoffice URL - now uses runtime config async function backofficeRequest(endpoint: string, options: RequestInit = {}): Promise { const token = localStorage.getItem("token"); @@ -579,7 +579,7 @@ async function backofficeRequest(endpoint: string, options: RequestInit = {}) ...options.headers, }; - const response = await fetch(`${BACKOFFICE_URL}${endpoint}`, { + const response = await fetch(`${getBackofficeUrl()}${endpoint}`, { ...options, headers, }); diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 7293b9d..a5ce716 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -1,9 +1,10 @@ import { User } from "./types"; +import { getApiUrl } from "./config"; export type { User }; const AUTH_KEY = "job-portal-auth"; -const BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8521"; -const API_URL = `${BASE_URL}/api/v1`; +// API URL now uses runtime config +const getApiV1Url = () => `${getApiUrl()}/api/v1`; interface LoginResponse { token: string; @@ -24,7 +25,7 @@ export async function login( ): Promise { try { console.log("%c[AUTH] Attempting login...", "color: #3b82f6; font-weight: bold", { email }); - const res = await fetch(`${API_URL}/auth/login`, { + const res = await fetch(`${getApiV1Url()}/auth/login`, { method: "POST", headers: { "Content-Type": "application/json", @@ -123,7 +124,7 @@ export interface RegisterCandidateData { export async function registerCandidate(data: RegisterCandidateData): Promise { console.log('[registerCandidate] Sending request:', { ...data, password: '***' }); - const res = await fetch(`${API_URL}/auth/register`, { + const res = await fetch(`${getApiV1Url()}/auth/register`, { method: "POST", headers: { "Content-Type": "application/json", @@ -171,7 +172,7 @@ export async function registerCompany(data: RegisterCompanyData): Promise admin_email: data.email, }; - const res = await fetch(`${API_URL}/companies`, { + const res = await fetch(`${getApiV1Url()}/companies`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts new file mode 100644 index 0000000..ad0c645 --- /dev/null +++ b/frontend/src/lib/config.ts @@ -0,0 +1,128 @@ +/** + * Runtime Configuration Service + * + * This service fetches and caches configuration from the /api/config endpoint, + * allowing the application to use runtime environment variables instead of + * build-time values. + * + * Usage: + * import { getApiUrl, getBackofficeUrl, initConfig } from '@/lib/config'; + * + * // Initialize once at app startup (optional, auto-initializes on first use) + * await initConfig(); + * + * // Get URLs (returns cached values or defaults) + * const apiUrl = getApiUrl(); + */ + +export interface RuntimeConfig { + apiUrl: string; + backofficeUrl: string; + seederApiUrl: string; + scraperApiUrl: string; +} + +// Default values (fallback to build-time env or hardcoded defaults) +const defaultConfig: RuntimeConfig = { + apiUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8521', + backofficeUrl: process.env.NEXT_PUBLIC_BACKOFFICE_URL || 'http://localhost:3001', + seederApiUrl: process.env.NEXT_PUBLIC_SEEDER_API_URL || 'http://localhost:3002', + scraperApiUrl: process.env.NEXT_PUBLIC_SCRAPER_API_URL || 'http://localhost:3003', +}; + +let cachedConfig: RuntimeConfig | null = null; +let isInitialized = false; +let initPromise: Promise | null = null; + +/** + * Initialize the config from the server. + * Safe to call multiple times - only fetches once. + */ +export async function initConfig(): Promise { + // If already initialized, return cached config + if (isInitialized && cachedConfig) { + return cachedConfig; + } + + // If already initializing, wait for that promise + if (initPromise) { + return initPromise; + } + + // Start initialization + initPromise = (async () => { + try { + // Only fetch if we're in the browser + if (typeof window !== 'undefined') { + const response = await fetch('/api/config'); + if (response.ok) { + cachedConfig = await response.json(); + console.log('[Config] Loaded runtime config:', cachedConfig); + } else { + console.warn('[Config] Failed to fetch config, using defaults'); + cachedConfig = defaultConfig; + } + } else { + // Server-side: use defaults + cachedConfig = defaultConfig; + } + } catch (error) { + console.warn('[Config] Error fetching config, using defaults:', error); + cachedConfig = defaultConfig; + } + + isInitialized = true; + return cachedConfig!; + })(); + + return initPromise; +} + +/** + * Get the full config object. + * Returns cached config if initialized, otherwise defaults. + */ +export function getConfig(): RuntimeConfig { + // Trigger async init in background if not initialized + if (!isInitialized && typeof window !== 'undefined') { + initConfig(); + } + return cachedConfig || defaultConfig; +} + +/** + * Get the API base URL. + * @returns The API URL (e.g., "https://api.gohorsejobs.com") + */ +export function getApiUrl(): string { + return getConfig().apiUrl; +} + +/** + * Get the full API URL with /api/v1 suffix. + * @returns The full API URL (e.g., "https://api.gohorsejobs.com/api/v1") + */ +export function getApiV1Url(): string { + return `${getConfig().apiUrl}/api/v1`; +} + +/** + * Get the Backoffice URL. + */ +export function getBackofficeUrl(): string { + return getConfig().backofficeUrl; +} + +/** + * Get the Seeder API URL. + */ +export function getSeederApiUrl(): string { + return getConfig().seederApiUrl; +} + +/** + * Get the Scraper API URL. + */ +export function getScraperApiUrl(): string { + return getConfig().scraperApiUrl; +} diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index dd8b607..f21faf9 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -2,7 +2,9 @@ * Storage service for S3 file uploads via pre-signed URLs */ -const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8521/api/v1"; +import { getApiV1Url } from "./config"; + +// API_URL getter - uses runtime config interface UploadUrlResponse { uploadUrl: string; @@ -30,7 +32,7 @@ export async function getUploadUrl( throw new Error('Not authenticated'); } - const response = await fetch(`${API_URL}/storage/upload-url`, { + const response = await fetch(`${getApiV1Url()}/storage/upload-url`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -81,7 +83,7 @@ export async function getDownloadUrl(key: string): Promise throw new Error('Not authenticated'); } - const response = await fetch(`${API_URL}/storage/download-url`, { + const response = await fetch(`${getApiV1Url()}/storage/download-url`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -129,7 +131,7 @@ export async function deleteFile(key: string): Promise { throw new Error('Not authenticated'); } - const response = await fetch(`${API_URL}/storage/files?key=${encodeURIComponent(key)}`, { + const response = await fetch(`${getApiV1Url()}/storage/files?key=${encodeURIComponent(key)}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}`,