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:
parent
2e8a12682c
commit
0da936550b
8 changed files with 280 additions and 31 deletions
|
|
@ -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
|
||||
|
|
|
|||
25
frontend/src/app/api/config/route.ts
Normal file
25
frontend/src/app/api/config/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
90
frontend/src/contexts/ConfigContext.tsx
Normal file
90
frontend/src/contexts/ConfigContext.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
128
frontend/src/lib/config.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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}`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue