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.
This commit is contained in:
Tiago Yamamoto 2025-12-31 08:48:32 -03:00
parent 2e8a12682c
commit 0da936550b
8 changed files with 280 additions and 31 deletions

View file

@ -38,7 +38,7 @@ COPY src ./src
COPY messages ./messages COPY messages ./messages
# Build arguments # 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 ARG NEXT_PUBLIC_BACKOFFICE_URL=http://localhost:3001
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_BACKOFFICE_URL=$NEXT_PUBLIC_BACKOFFICE_URL ENV NEXT_PUBLIC_BACKOFFICE_URL=$NEXT_PUBLIC_BACKOFFICE_URL

View file

@ -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',
},
});
}

View file

@ -6,6 +6,7 @@ import { Analytics } from "@vercel/analytics/next"
import { Toaster } from "sonner" import { Toaster } from "sonner"
import { NotificationProvider } from "@/contexts/notification-context" import { NotificationProvider } from "@/contexts/notification-context"
import { ThemeProvider } from "@/contexts/ThemeContext" import { ThemeProvider } from "@/contexts/ThemeContext"
import { ConfigProvider } from "@/contexts/ConfigContext"
import { I18nProvider } from "@/lib/i18n" import { I18nProvider } from "@/lib/i18n"
import "./globals.css" import "./globals.css"
import { Suspense } from "react" import { Suspense } from "react"
@ -33,6 +34,7 @@ export default function RootLayout({
return ( return (
<html lang="en"> <html lang="en">
<body className={`font-sans ${GeistSans.variable} ${GeistMono.variable} antialiased`}> <body className={`font-sans ${GeistSans.variable} ${GeistMono.variable} antialiased`}>
<ConfigProvider>
<I18nProvider> <I18nProvider>
<ThemeProvider> <ThemeProvider>
<NotificationProvider> <NotificationProvider>
@ -47,6 +49,7 @@ export default function RootLayout({
</NotificationProvider> </NotificationProvider>
</ThemeProvider> </ThemeProvider>
</I18nProvider> </I18nProvider>
</ConfigProvider>
{process.env.NODE_ENV === "production" && shouldLoadAnalytics && <Analytics />} {process.env.NODE_ENV === "production" && shouldLoadAnalytics && <Analytics />}
</body> </body>
</html> </html>

View file

@ -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<ConfigContextType>({
config: defaultConfig,
isLoading: true,
});
export function ConfigProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<RuntimeConfig>(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 (
<ConfigContext.Provider value={{ config, isLoading }}>
{children}
</ConfigContext.Provider>
);
}
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<RuntimeConfig> {
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;
}

View file

@ -1,9 +1,9 @@
import { toast } from "sonner"; import { toast } from "sonner";
import { Job } from "./types"; import { Job } from "./types";
import { getApiUrl, getBackofficeUrl } from "./config";
// API Base URL - endpoints already include /api/v1 prefix // API Base URL - now uses runtime config
// NEXT_PUBLIC_API_URL should be just the domain, e.g. https://api.gohorsejobs.com // Fetched from /api/config at runtime, falls back to build-time env or defaults
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
/** /**
* Helper to log CRUD actions for the 'Activity Log' or console * Helper to log CRUD actions for the 'Activity Log' or console
@ -24,7 +24,7 @@ async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promi
...options.headers, ...options.headers,
}; };
const response = await fetch(`${API_BASE_URL}${endpoint}`, { const response = await fetch(`${getApiUrl()}${endpoint}`, {
...options, ...options,
headers, headers,
credentials: "include", // Enable cookie sharing 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"). // For now, assuming saving the key is what's requested ("salvando as chaves").
// We use the generic updateProfile method. // We use the generic updateProfile method.
const token = localStorage.getItem("token"); 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", method: "PATCH",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -569,7 +569,7 @@ export const profileApi = {
// ============================================================================= // =============================================================================
// BACKOFFICE API (Stripe, Admin Dashboard, etc.) // BACKOFFICE API (Stripe, Admin Dashboard, etc.)
// ============================================================================= // =============================================================================
const BACKOFFICE_URL = process.env.NEXT_PUBLIC_BACKOFFICE_URL || ""; // Backoffice URL - now uses runtime config
async function backofficeRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> { async function backofficeRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
@ -579,7 +579,7 @@ async function backofficeRequest<T>(endpoint: string, options: RequestInit = {})
...options.headers, ...options.headers,
}; };
const response = await fetch(`${BACKOFFICE_URL}${endpoint}`, { const response = await fetch(`${getBackofficeUrl()}${endpoint}`, {
...options, ...options,
headers, headers,
}); });

View file

@ -1,9 +1,10 @@
import { User } from "./types"; import { User } from "./types";
import { getApiUrl } from "./config";
export type { User }; export type { User };
const AUTH_KEY = "job-portal-auth"; const AUTH_KEY = "job-portal-auth";
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8521"; // API URL now uses runtime config
const API_URL = `${BASE_URL}/api/v1`; const getApiV1Url = () => `${getApiUrl()}/api/v1`;
interface LoginResponse { interface LoginResponse {
token: string; token: string;
@ -24,7 +25,7 @@ export async function login(
): Promise<User | null> { ): Promise<User | null> {
try { try {
console.log("%c[AUTH] Attempting login...", "color: #3b82f6; font-weight: bold", { email }); 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", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -123,7 +124,7 @@ export interface RegisterCandidateData {
export async function registerCandidate(data: RegisterCandidateData): Promise<void> { export async function registerCandidate(data: RegisterCandidateData): Promise<void> {
console.log('[registerCandidate] Sending request:', { ...data, password: '***' }); console.log('[registerCandidate] Sending request:', { ...data, password: '***' });
const res = await fetch(`${API_URL}/auth/register`, { const res = await fetch(`${getApiV1Url()}/auth/register`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -171,7 +172,7 @@ export async function registerCompany(data: RegisterCompanyData): Promise<void>
admin_email: data.email, admin_email: data.email,
}; };
const res = await fetch(`${API_URL}/companies`, { const res = await fetch(`${getApiV1Url()}/companies`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

128
frontend/src/lib/config.ts Normal file
View file

@ -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<RuntimeConfig> | null = null;
/**
* Initialize the config from the server.
* Safe to call multiple times - only fetches once.
*/
export async function initConfig(): Promise<RuntimeConfig> {
// 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;
}

View file

@ -2,7 +2,9 @@
* Storage service for S3 file uploads via pre-signed URLs * 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 { interface UploadUrlResponse {
uploadUrl: string; uploadUrl: string;
@ -30,7 +32,7 @@ export async function getUploadUrl(
throw new Error('Not authenticated'); throw new Error('Not authenticated');
} }
const response = await fetch(`${API_URL}/storage/upload-url`, { const response = await fetch(`${getApiV1Url()}/storage/upload-url`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -81,7 +83,7 @@ export async function getDownloadUrl(key: string): Promise<DownloadUrlResponse>
throw new Error('Not authenticated'); throw new Error('Not authenticated');
} }
const response = await fetch(`${API_URL}/storage/download-url`, { const response = await fetch(`${getApiV1Url()}/storage/download-url`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -129,7 +131,7 @@ export async function deleteFile(key: string): Promise<void> {
throw new Error('Not authenticated'); 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', method: 'DELETE',
headers: { headers: {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,