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
# 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

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 { 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 (
<html lang="en">
<body className={`font-sans ${GeistSans.variable} ${GeistMono.variable} antialiased`}>
<I18nProvider>
<ThemeProvider>
<NotificationProvider>
<Suspense fallback={<LoadingScreen text="GoHorse Jobs" />}>{children}</Suspense>
<Toaster
position="top-right"
richColors
closeButton
expand={false}
duration={4000}
/>
</NotificationProvider>
</ThemeProvider>
</I18nProvider>
<ConfigProvider>
<I18nProvider>
<ThemeProvider>
<NotificationProvider>
<Suspense fallback={<LoadingScreen text="GoHorse Jobs" />}>{children}</Suspense>
<Toaster
position="top-right"
richColors
closeButton
expand={false}
duration={4000}
/>
</NotificationProvider>
</ThemeProvider>
</I18nProvider>
</ConfigProvider>
{process.env.NODE_ENV === "production" && shouldLoadAnalytics && <Analytics />}
</body>
</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 { 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<T>(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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem("token");
@ -579,7 +579,7 @@ async function backofficeRequest<T>(endpoint: string, options: RequestInit = {})
...options.headers,
};
const response = await fetch(`${BACKOFFICE_URL}${endpoint}`, {
const response = await fetch(`${getBackofficeUrl()}${endpoint}`, {
...options,
headers,
});

View file

@ -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<User | null> {
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<void> {
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<void>
admin_email: data.email,
};
const res = await fetch(`${API_URL}/companies`, {
const res = await fetch(`${getApiV1Url()}/companies`, {
method: "POST",
headers: {
"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
*/
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<DownloadUrlResponse>
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<void> {
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}`,