Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,18 +1,48 @@
# ──────────────────────────────────────────────────────────────────────────────
# Community Support System β€” Environment Variables
# Copy to .env and fill in real values. Never commit .env to git.
# ──────────────────────────────────────────────────────────────────────────────

# Spring Profile
SPRING_PROFILES_ACTIVE=dev # dev | docker | fly | prod

# Database Configuration
DATABASE_URL=jdbc:postgresql://localhost:5432/community_support_system_db
PROD_DB_URL=jdbc:postgresql://localhost:5432/community_support_system_db
PROD_DB_USERNAME=postgres
PROD_DB_PASSWORD=yourpassword

# JWT Configuration - REQUIRED for production
# JWT Configuration
# REQUIRED: must be at least 32 chars. App refuses to start with a weak secret.
# Generate: openssl rand -hex 32
JWT_SECRET=your-secure-256-bit-secret-key-here-minimum-32-characters
JWT_EXPIRATION=86400000

# SendGrid Email Configuration (Optional)
# SendGrid Email Configuration
SENDGRID_API_KEY=your_sendgrid_api_key_here
SENDGRID_FROM_EMAIL=your_email@domain.com
SENDGRID_FROM_EMAIL=darkosee23@gmail.com
SENDGRID_FROM_NAME=Community Support System
SENDGRID_ENABLED=false
SENDGRID_ENABLED=false # Set true in production

# CORS Configuration
CORS_ALLOWED_ORIGINS=http://localhost:3000,https://your-frontend.vercel.app
CORS_ALLOWED_ORIGINS=http://localhost:5173,https://community-support-system.vercel.app

# Frontend URL (used in password-reset & verification emails)
FRONTEND_URL=https://community-support-system.vercel.app

# CI/CD β€” Fly.io (only needed in GitHub Actions)
FLY_API_TOKEN=

# CI/CD β€” Vercel (only needed in GitHub Actions)
VERCEL_TOKEN=
VERCEL_ORG_ID=
VERCEL_PROJECT_ID=

# Swagger (disabled by default; set true only for dev/docker)
SWAGGER_ENABLED=false

# Docker compose frontend build arg
VITE_API_BASE_URL=/api

# Frontend URL
FRONTEND_URL=https://your-frontend.vercel.app
# Frontend local dev (create frontend/.env.local)
# VITE_API_BASE_URL=http://localhost:8080/api
33 changes: 32 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,42 @@ jobs:
- name: Type-check
run: npx tsc --noEmit

- name: Test
run: npm test

- name: Build
run: npm run build

- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: frontend/dist/
path: frontend/dist/

# ─── Deploy (main branch only) ─────────────────────────────────────────────
deploy-backend:
name: Deploy Backend to Fly.io
needs: backend
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- name: Deploy to Fly.io
run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

deploy-frontend:
name: Deploy Frontend to Vercel
needs: frontend
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel
run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }} --yes
working-directory: frontend
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ COPY --from=build /app/target/*.jar app.jar
COPY start.sh start.sh
RUN chmod +x start.sh

ENV SPRING_PROFILES_ACTIVE=fly
# Profile β€” defaults to fly; overridden at runtime by docker-compose or Fly.io env
ARG SPRING_PROFILES_ACTIVE=fly
ENV SPRING_PROFILES_ACTIVE=$SPRING_PROFILES_ACTIVE
ENV JAVA_OPTS="-Xmx400m -Xms200m -XX:+UseG1GC"

EXPOSE 8080
Expand Down
8 changes: 4 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ services:
timeout: 5s
retries: 5

# Spring Boot Backend - Fly.io Profile
# Spring Boot Backend - Docker Profile
backend:
build:
context: .
dockerfile: Dockerfile
container_name: community-backend
environment:
SPRING_PROFILES_ACTIVE: fly
SPRING_PROFILES_ACTIVE: docker
PORT: 8080
DATABASE_URL: jdbc:postgresql://database:5432/community_support_system_db?user=postgres&password=postgres
JWT_SECRET: ${JWT_SECRET:-5f566298b0ba4f8e38c1ff7c6e0844a1}
Expand Down Expand Up @@ -58,6 +58,8 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
container_name: community-frontend
ports:
- "3000:80"
Expand All @@ -66,8 +68,6 @@ services:
condition: service_healthy
networks:
- community-network
env_file:
- ./frontend/.env.docker

volumes:
postgres_data:
Expand Down
16 changes: 5 additions & 11 deletions frontend/.env.docker
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
# Docker Environment Configuration
VITE_API_BASE_URL=http://localhost:8080/api

# Environment
# Docker Environment β€” runtime values (non-VITE vars only)
# VITE_* vars are baked at build time via Dockerfile ARG / docker-compose build.args
NODE_ENV=production

# Security
VITE_ENABLE_MOCK_AUTH=false
VITE_JWT_SECRET=your-jwt-secret-here

# Feature Flags
VITE_ENABLE_2FA=true
VITE_ENABLE_EMAIL_VERIFICATION=true
# Feature Flags (baked at build time β€” set via docker-compose build args if needed)
# VITE_ENABLE_2FA=true
# VITE_ENABLE_EMAIL_VERIFICATION=true
4 changes: 4 additions & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ WORKDIR /app
COPY package*.json ./
RUN npm ci

# Build-time env vars (baked into the Vite bundle)
ARG VITE_API_BASE_URL=/api
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL

COPY . .
RUN npm run build

Expand Down
8 changes: 6 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"build-with-types": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"vercel-build": "npm run build"
"vercel-build": "npm run build",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
Expand Down Expand Up @@ -45,6 +47,8 @@
"terser": "^5.44.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^2.0.0",
"@vitest/coverage-v8": "^2.0.0"
}
}
64 changes: 64 additions & 0 deletions frontend/src/__tests__/dateUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { timeAgo, formatDate, getGreeting } from '../utils/dateUtils';

describe('timeAgo', () => {
it('returns "just now" for recent timestamps', () => {
const now = new Date().toISOString();
expect(timeAgo(now)).toBe('just now');
});

it('returns minutes ago for timestamps < 1 hour', () => {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
expect(timeAgo(fiveMinutesAgo)).toBe('5 minutes ago');
});

it('returns singular minute for 1 minute ago', () => {
const oneMinuteAgo = new Date(Date.now() - 61 * 1000).toISOString();
expect(timeAgo(oneMinuteAgo)).toBe('1 minute ago');
});

it('returns hours ago for timestamps < 1 day', () => {
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
expect(timeAgo(threeHoursAgo)).toBe('3 hours ago');
});

it('returns days ago for timestamps < 1 week', () => {
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
expect(timeAgo(twoDaysAgo)).toBe('2 days ago');
});

it('returns a formatted date string for old timestamps', () => {
const old = '2020-01-15T00:00:00.000Z';
const result = timeAgo(old);
expect(result).toMatch(/Jan/);
expect(result).toMatch(/2020/);
});
});

describe('formatDate', () => {
it('formats a date string as "Mon D, YYYY"', () => {
const result = formatDate('2026-03-10T12:00:00.000Z');
expect(result).toMatch(/Mar/);
expect(result).toMatch(/2026/);
});
});

describe('getGreeting', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });

it('returns Good morning before noon', () => {
vi.setSystemTime(new Date('2026-03-10T08:00:00'));
expect(getGreeting()).toBe('Good morning');
});

it('returns Good afternoon from 12:00 to 16:59', () => {
vi.setSystemTime(new Date('2026-03-10T14:00:00'));
expect(getGreeting()).toBe('Good afternoon');
});

it('returns Good evening from 17:00', () => {
vi.setSystemTime(new Date('2026-03-10T19:00:00'));
expect(getGreeting()).toBe('Good evening');
});
});
34 changes: 15 additions & 19 deletions frontend/src/pages/gamification/LeaderboardPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import React, { useState } from 'react';
import { Trophy, Medal, Star, TrendingUp, Award, Crown } from 'lucide-react';
import { useGamification, LEVEL_THRESHOLDS } from '../../contexts/GamificationContext';
import { Star, TrendingUp, Crown } from 'lucide-react';
import { useGamification } from '../../contexts/GamificationContext';
import { LevelBadge, BadgeRow } from '../../components/common/BadgeDisplay';
import { RatingDisplay } from '../../components/common/StarRating';
import { LeaderboardSkeleton } from '../../components/common/SkeletonLoader';
import Breadcrumb from '../../components/common/Breadcrumb';
import type { LeaderboardEntry } from '../../types';
Expand All @@ -17,14 +16,13 @@ const PERIOD_LABELS: Record<Period, string> = {
};

function getPodiumColor(rank: number) {
if (rank === 1) return { bg: 'bg-yellow-100 dark:bg-yellow-900/30', border: 'border-yellow-300 dark:border-yellow-600/40', icon: 'πŸ₯‡', text: 'text-yellow-700 dark:text-yellow-400', ring: 'ring-yellow-300 dark:ring-yellow-600' };
if (rank === 2) return { bg: 'bg-slate-100 dark:bg-slate-700', border: 'border-slate-300 dark:border-slate-500', icon: 'πŸ₯ˆ', text: 'text-slate-600 dark:text-slate-300', ring: 'ring-slate-300 dark:ring-slate-500' };
return { bg: 'bg-orange-100 dark:bg-orange-900/30', border: 'border-orange-300 dark:border-orange-600/40', icon: 'πŸ₯‰', text: 'text-orange-700 dark:text-orange-400', ring: 'ring-orange-300 dark:ring-orange-600' };
if (rank === 1) return { bg: 'bg-black dark:bg-gray-900', border: 'border-gray-800 dark:border-gray-700', icon: '1st', text: 'text-white dark:text-gray-100', ring: 'ring-black dark:ring-gray-700' };
if (rank === 2) return { bg: 'bg-gray-700 dark:bg-gray-800', border: 'border-gray-600 dark:border-gray-600', icon: '2nd', text: 'text-white dark:text-gray-200', ring: 'ring-gray-700 dark:ring-gray-600' };
return { bg: 'bg-gray-400 dark:bg-gray-600', border: 'border-gray-300 dark:border-gray-500', icon: '3rd', text: 'text-white dark:text-gray-100', ring: 'ring-gray-400 dark:ring-gray-600' };
}

const PodiumCard: React.FC<{ entry: LeaderboardEntry }> = ({ entry }) => {
const colors = getPodiumColor(entry.rank);
const levelInfo = LEVEL_THRESHOLDS[entry.level];
const height = entry.rank === 1 ? 'h-28' : entry.rank === 2 ? 'h-20' : 'h-14';

return (
Expand Down Expand Up @@ -59,20 +57,19 @@ const PodiumCard: React.FC<{ entry: LeaderboardEntry }> = ({ entry }) => {
};

const LeaderboardPage: React.FC = () => {
const { t } = useTranslation();
useTranslation();
const { leaderboard, isLoading } = useGamification();
const [period, setPeriod] = useState<Period>('allTime');

const top3 = leaderboard.slice(0, 3);
const rest = leaderboard.slice(3);
const top3 = leaderboard.slice(0, 3);
const podiumOrder = [top3[1], top3[0], top3[2]].filter(Boolean) as LeaderboardEntry[];

return (
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
<Breadcrumb />

{/* Header */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-yellow-500 via-orange-500 to-primary-600 text-white shadow-soft-lg p-6 lg:p-8">
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-gray-900 via-gray-800 to-black text-white shadow-soft-lg p-6 lg:p-8">
<div className="dot-grid absolute inset-0 opacity-[0.07]" />
<div className="absolute -top-8 -right-8 w-40 h-40 bg-white/10 rounded-full blur-3xl" />
<div className="relative">
Expand All @@ -82,7 +79,7 @@ const LeaderboardPage: React.FC = () => {
</div>
<div>
<h1 className="font-display text-2xl font-extrabold">Community Leaderboard</h1>
<p className="text-white/70 text-sm">Celebrating our top volunteers πŸ†</p>
<p className="text-white/70 text-sm">Celebrating our top volunteers</p>
</div>
</div>
<div className="flex flex-wrap gap-2 mt-4">
Expand All @@ -92,7 +89,7 @@ const LeaderboardPage: React.FC = () => {
onClick={() => setPeriod(p)}
className={`px-4 py-1.5 rounded-full text-sm font-semibold transition-all ${
period === p
? 'bg-white text-orange-700 shadow-sm'
? 'bg-white text-black shadow-sm'
: 'bg-white/20 text-white hover:bg-white/30'
}`}
>
Expand Down Expand Up @@ -128,26 +125,25 @@ const LeaderboardPage: React.FC = () => {
</div>

<div className="divide-y divide-neutral-50 dark:divide-slate-800">
{leaderboard.map((entry, idx) => {
{leaderboard.map((entry) => {
const isTop3 = entry.rank <= 3;
const rankIcons: Record<number, string> = { 1: 'πŸ₯‡', 2: 'πŸ₯ˆ', 3: 'πŸ₯‰' };

return (
<div
key={entry.user.userId}
className={`flex items-center gap-4 p-4 transition-colors hover:bg-neutral-50 dark:hover:bg-slate-700/50 ${isTop3 ? 'bg-gradient-to-r from-yellow-50/60 to-transparent dark:from-yellow-900/5' : ''}`}
className={`flex items-center gap-4 p-4 transition-colors hover:bg-neutral-50 dark:hover:bg-slate-700/50 ${isTop3 ? 'bg-gray-50 dark:bg-slate-700/30' : ''}`}
>
{/* Rank */}
<div className="w-8 text-center flex-shrink-0">
{isTop3 ? (
<span className="text-xl">{rankIcons[entry.rank]}</span>
<span className="text-sm font-black text-black dark:text-white">#{entry.rank}</span>
) : (
<span className="text-sm font-bold text-neutral-400 dark:text-slate-500">#{entry.rank}</span>
)}
</div>

{/* Avatar */}
<div className={`w-10 h-10 rounded-full flex-shrink-0 flex items-center justify-center bg-gradient-to-br from-primary-500 to-secondary-600 shadow-sm ${isTop3 ? 'ring-2 ring-yellow-300 dark:ring-yellow-600/40' : ''}`}>
<div className={`w-10 h-10 rounded-full flex-shrink-0 flex items-center justify-center bg-black dark:bg-gray-700 shadow-sm ${isTop3 ? 'ring-2 ring-gray-400 dark:ring-gray-500' : ''}`}>
<span className="text-white font-bold">{entry.user.name.charAt(0).toUpperCase()}</span>
</div>

Expand All @@ -171,7 +167,7 @@ const LeaderboardPage: React.FC = () => {
</div>
{entry.averageRating && (
<div className="hidden md:flex items-center gap-1">
<Star className="w-3.5 h-3.5 fill-yellow-400 text-yellow-400" />
<Star className="w-3.5 h-3.5 fill-gray-500 text-gray-500 dark:fill-gray-400 dark:text-gray-400" />
<span className="text-xs font-bold text-gray-900 dark:text-slate-100">{entry.averageRating.toFixed(1)}</span>
</div>
)}
Expand Down
8 changes: 8 additions & 0 deletions frontend/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableCaching
@EnableScheduling
public class CommunitySupportSystemApplication {

public static void main(String[] args) {
Expand Down
Loading
Loading