diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..c4044fa --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,17 @@ +node_modules +.next +out +.git +.gitignore +README.md +.env +.env.local +.env.development +.env.production +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store +*.pem +.idea +.vscode diff --git a/frontend/.env b/frontend/.env index ab32e0e..f2d6688 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,2 +1,2 @@ -NEXT_PUBLIC_BLOCKFROST_API_KEY=previewzwnjcGmHgYLFmLppEWCrmbhapNtCq4H7 +NEXT_PUBLIC_BLOCKFROST_API_KEY=preview6rf9Lym3f9XQrTDnxSBbAGwvz5mNafdz NETWORK=Preview \ No newline at end of file diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..13b610c --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,4 @@ +NEXT_PUBLIC_BLOCKFROST_API_KEY=preview6rf9Lym3f9XQrTDnxSBbAGwvz5mNafdz +NETWORK=Preview +# Leave empty in development to use Next.js rewrites (proxies to backend) +NEXT_PUBLIC_API_URL= diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..52d8c87 --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1,5 @@ +NEXT_PUBLIC_BLOCKFROST_API_KEY=preview6rf9Lym3f9XQrTDnxSBbAGwvz5mNafdz +NETWORK=Preview +# Production: Set this to your public API endpoint (e.g., https://api.programmabletokens.xyz) +# This should be the publicly accessible URL for your backend API +NEXT_PUBLIC_API_URL=https://preview-api.programmabletokens.xyz diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ea16a94 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,41 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source files +COPY . . + +# Accept build arguments +ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_BLOCKFROST_API_KEY +ARG NETWORK=Preview + +# Set environment variables for build +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} +ENV NEXT_PUBLIC_BLOCKFROST_API_KEY=${NEXT_PUBLIC_BLOCKFROST_API_KEY} +ENV NETWORK=${NETWORK} + +# Build the Next.js app (static export) +RUN npm run build + +# Production stage - serve with nginx +FROM nginx:alpine + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/nginx.conf + +# Copy built static files from builder stage +COPY --from=builder /app/out /usr/share/nginx/html + +# Expose port 80 +EXPOSE 80 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/build.sh b/frontend/build.sh new file mode 100755 index 0000000..2f6e0b8 --- /dev/null +++ b/frontend/build.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -x + +VERSION=$(git describe --tags --always --dirty) + +echo "Building version: ${VERSION}" + +DOCKER_IMAGE_NAME=easy1staking/programmable-tokens-ui +DOCKER_IMAGE="${DOCKER_IMAGE_NAME}:${VERSION}" +DOCKER_IMAGE_LATEST="${DOCKER_IMAGE_NAME}:latest" + +docker build -t "${DOCKER_IMAGE}" \ + -t "${DOCKER_IMAGE_LATEST}" \ + --build-arg NEXT_PUBLIC_API_URL=https://preview-api.programmabletokens.xyz \ + --build-arg NEXT_PUBLIC_BLOCKFROST_API_KEY=preview6rf9Lym3f9XQrTDnxSBbAGwvz5mNafdz \ + --build-arg NETWORK=Preview \ + --push \ + . diff --git a/frontend/docker-compose.yml b/frontend/docker-compose.yml new file mode 100644 index 0000000..41a3bd6 --- /dev/null +++ b/frontend/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3.8' + +services: + frontend: + build: + context: . + dockerfile: Dockerfile + args: + - NEXT_PUBLIC_API_URL=http://localhost:8080 + - NEXT_PUBLIC_BLOCKFROST_API_KEY=preview63R2hq5OToB1PRYyxDQ1NpmS23rbyS88 + - NETWORK=Preview + ports: + - "3000:80" + environment: + - NODE_ENV=production diff --git a/frontend/next.config.js b/frontend/next.config.js index 128a5c4..b1ca68a 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -52,22 +52,23 @@ module.exports = (phase, {defaultConfig}) => { ] }, async rewrites() { + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; return [ { source: '/api/v1/:path*', // Match all routes starting with /api/v1/ - destination: 'http://localhost:8080/api/v1/:path*', // Proxy to backend server + destination: `${apiUrl}/api/v1/:path*`, // Proxy to backend server + }, + ]; + }, + async redirects() { + return [ + { + source: '/', + destination: '/connected-wallet', + permanent: true, // Use true for a 301 redirect, false for 302 }, ]; }, - async redirects() { - return [ - { - source: '/', - destination: '/connected-wallet', - permanent: true, // Use true for a 301 redirect, false for 302 - }, - ]; - }, experimental: { esmExternals: true, // Ensure modern module support }, @@ -87,4 +88,4 @@ module.exports = (phase, {defaultConfig}) => { "@lucid-evolution/lucid" ] } -} +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..bc4b8ec --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,42 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + gzip on; + gzip_vary on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Redirect root to /mint-authority + location = / { + return 301 /mint-authority.html; + } + + # Enable SPA routing - serve HTML files for routes + location / { + try_files $uri $uri.html $uri/ =404; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + } +} diff --git a/frontend/src/app/[username]/index.tsx b/frontend/src/app/[username]/index.tsx index 092ec90..bd61c54 100644 --- a/frontend/src/app/[username]/index.tsx +++ b/frontend/src/app/[username]/index.tsx @@ -4,6 +4,7 @@ import React, { useContext, useEffect, useState, useMemo } from 'react'; //Axios imports import axios from 'axios'; +import { getApiUrl } from '../utils/apiConfig'; //Mui imports import { Box, Checkbox, CircularProgress, FormControl, FormControlLabel, InputLabel, MenuItem, Paper, Select, Typography } from '@mui/material'; @@ -365,7 +366,7 @@ export default function Profile() { }; try { const response = await axios.post( - '/api/v1/tx/programmable-token/transfer', + getApiUrl('/api/v1/tx/programmable-token/transfer'), requestData, { headers: { @@ -448,7 +449,7 @@ export default function Profile() { setIsRegistering(true); changeAlertInfo({ severity: 'info', message: 'Preparing registration transaction…', open: true, link: '' }); const response = await axios.post( - '/api/v1/tx/programmable-token/register-transfer-scripts', + getApiUrl('/api/v1/tx/programmable-token/register-transfer-scripts'), { issuer: issuerAddress }, { headers: { 'Content-Type': 'application/json;charset=utf-8' } } ); @@ -502,7 +503,7 @@ export default function Profile() { setIsInitializingBlacklist(true); changeAlertInfo({ severity: 'info', message: 'Preparing blacklist initialisation transaction…', open: true, link: '' }); const response = await axios.post( - '/api/v1/tx/programmable-token/blacklist-init', + getApiUrl('/api/v1/tx/programmable-token/blacklist-init'), { issuer: issuerAddress }, { headers: { 'Content-Type': 'application/json;charset=utf-8' } } ); @@ -595,7 +596,7 @@ export default function Profile() { setIsUserMinting(true); try { const response = await axios.post( - '/api/v1/tx/programmable-token/issue', + getApiUrl('/api/v1/tx/programmable-token/issue'), requestData, { headers: {'Content-Type': 'application/json;charset=utf-8'} @@ -641,7 +642,7 @@ export default function Profile() { setIsUserFreezing(true); try { const response = await axios.post( - '/api/v1/tx/programmable-token/blacklist', + getApiUrl('/api/v1/tx/programmable-token/blacklist'), requestData, { headers: {'Content-Type': 'application/json;charset=utf-8'} } ); @@ -694,7 +695,7 @@ export default function Profile() { setIsUserUnfreezing(true); try { const response = await axios.post( - '/api/v1/tx/programmable-token/unblacklist', + getApiUrl('/api/v1/tx/programmable-token/unblacklist'), requestData, { headers: {'Content-Type': 'application/json;charset=utf-8'} } ); @@ -747,7 +748,7 @@ export default function Profile() { setIsUserSeizing(true); try { const response = await axios.post( - '/api/v1/tx/programmable-token/seize', + getApiUrl('/api/v1/tx/programmable-token/seize'), requestData, { headers: {'Content-Type': 'application/json;charset=utf-8'} } ); diff --git a/frontend/src/app/lib/demoEnvironment.server.ts b/frontend/src/app/lib/demoEnvironment.server.ts index c2b0da5..590b8aa 100644 --- a/frontend/src/app/lib/demoEnvironment.server.ts +++ b/frontend/src/app/lib/demoEnvironment.server.ts @@ -9,6 +9,7 @@ let cachedDemoEnvironment: DemoEnvironment | null = null; const resolveBaseUrl = () => { const raw = + process.env.NEXT_PUBLIC_API_URL ?? // Production API URL process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? process.env.NEXT_PUBLIC_BACKEND_BASE_URL ?? diff --git a/frontend/src/app/utils/apiConfig.ts b/frontend/src/app/utils/apiConfig.ts new file mode 100644 index 0000000..950dd6d --- /dev/null +++ b/frontend/src/app/utils/apiConfig.ts @@ -0,0 +1,18 @@ +/** + * Get the API base URL + * - In development: returns empty string (uses Next.js rewrites) + * - In production: returns NEXT_PUBLIC_API_URL for direct API calls + */ +export const getApiBaseUrl = (): string => { + return process.env.NEXT_PUBLIC_API_URL || ''; +}; + +/** + * Build a full API endpoint URL + * @param path - The API path (e.g., '/api/v1/query/address/...') + * @returns Full URL for the API endpoint + */ +export const getApiUrl = (path: string): string => { + const baseUrl = getApiBaseUrl(); + return `${baseUrl}${path}`; +}; diff --git a/frontend/src/app/utils/walletUtils.ts b/frontend/src/app/utils/walletUtils.ts index c37f4e1..13f03fa 100644 --- a/frontend/src/app/utils/walletUtils.ts +++ b/frontend/src/app/utils/walletUtils.ts @@ -5,6 +5,7 @@ import axios, { AxiosResponse } from 'axios'; import { Address, Assets, Blockfrost, CML, credentialToAddress, credentialToRewardAddress, Lucid, LucidEvolution, makeTxSignBuilder, Network, paymentCredentialOf, scriptHashToCredential, toText, toUnit, TxSignBuilder, Unit, valueToAssets, walletFromSeed } from "@lucid-evolution/lucid"; import type { Credential as LucidCredential, ScriptHash } from "@lucid-evolution/core-types"; import { WalletBalance, DemoEnvironment } from '../store/types'; +import { getApiUrl } from './apiConfig'; export async function makeLucid(demoEnvironment: DemoEnvironment) { const API_KEY_ENV = process.env.NEXT_PUBLIC_BLOCKFROST_API_KEY; @@ -38,10 +39,10 @@ export type UserBalanceResponse = export async function getWalletBalance(demoEnv: DemoEnvironment, address: string): Promise { try { const response = await axios.get( - `/api/v1/query/user-funds/${address}`, + getApiUrl(`/api/v1/query/user-funds/${address}`), { headers: { - 'Content-Type': 'application/json;charset=utf-8', + 'Content-Type': 'application/json;charset=utf-8', }, } ); @@ -62,10 +63,10 @@ export async function getWalletBalance(demoEnv: DemoEnvironment, address: string export async function getBlacklist(address: string){ try { const response = await axios.get( - `/api/v1/query/blacklist/${address}`, + getApiUrl(`/api/v1/query/blacklist/${address}`), { headers: { - 'Content-Type': 'application/json;charset=utf-8', + 'Content-Type': 'application/json;charset=utf-8', }, } ); @@ -78,7 +79,7 @@ export async function getBlacklist(address: string){ } export async function getProgrammableTokenAddress(address: string): Promise { - const response = await axios.get(`/api/v1/query/address/${address}`, + const response = await axios.get(getApiUrl(`/api/v1/query/address/${address}`), { headers: { 'Content-Type': 'application/json;charset=utf-8', @@ -88,7 +89,7 @@ export async function getProgrammableTokenAddress(address: string): Promise { - const response = await axios.get(`/api/v1/query/freeze-policy-id/${address}`, + const response = await axios.get(getApiUrl(`/api/v1/query/freeze-policy-id/${address}`), { headers: { 'Content-Type': 'application/json;charset=utf-8', @@ -98,7 +99,7 @@ export async function getFreezePolicyId(address: string): Promise { } export async function getPolicyIssuer(policyId: string): Promise { - const response = await axios.get(`/api/v1/query/policy-issuer/${policyId}`, { + const response = await axios.get(getApiUrl(`/api/v1/query/policy-issuer/${policyId}`), { headers: { 'Content-Type': 'application/json;charset=utf-8', }, @@ -113,7 +114,7 @@ const trimTrailingSlash = (url: string) => url.replace(/\/+$/, ''); export async function getStakeScriptHashes(address: string): Promise { try { - const response = await axios.get(`/api/v1/query/stake-scripts/${address}`, { + const response = await axios.get(getApiUrl(`/api/v1/query/stake-scripts/${address}`), { headers: { 'Content-Type': 'application/json;charset=utf-8', }, @@ -237,7 +238,7 @@ export async function getUserTotalProgrammableValue(address: string): Promise( - `/api/v1/query/user-total-programmable-value/${address}`, + getApiUrl(`/api/v1/query/user-total-programmable-value/${address}`), { headers: { 'Content-Type': 'application/json;charset=utf-8', @@ -331,7 +332,7 @@ export async function fetchPolicyHolders( export async function submitTx(tx: string): Promise> { return axios.post( - '/api/v1/tx/submit', + getApiUrl('/api/v1/tx/submit'), { description: "", type: "Tx ConwayEra", @@ -339,7 +340,7 @@ export async function submitTx(tx: string): Promise> { }, { headers: { - 'Content-Type': 'application/json;charset=utf-8', + 'Content-Type': 'application/json;charset=utf-8', }, } );