diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bae7cec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +node_modules +.git +.gitignore +.env +.env.local +.env.*.local +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.vscode +.idea +*.log +.DS_Store +.output +.vinxi +dist +*.tsbuildinfo +coverage +.nyc_output +.cache +.next +.vercel +netlify +*.test.ts +*.spec.ts +**/__tests__ +**/test +**/tests \ No newline at end of file diff --git a/.gitea/scripts/registry_prune.py b/.gitea/scripts/registry_prune.py new file mode 100644 index 0000000..12c40ec --- /dev/null +++ b/.gitea/scripts/registry_prune.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +""" +Registry Image Tag Pruner - Keeps only the latest 1 SHA-tag per repository. + +Usage: + python3 registry_prune.py \ + --registry registry.nxtgauge.com \ + --repo nxtgauge-rust-gateway \ + --username "$REGISTRY_USERNAME" \ + --password "$REGISTRY_PASSWORD" + +Environment variables can also be used: + REGISTRY_HOST, REGISTRY_REPO, REGISTRY_USERNAME, REGISTRY_PASSWORD + +SHA-like tags are identified by pattern: ^[a-f0-9]{40}$ +Non-SHA tags (e.g., high-performance-latest, main-latest, latest) are NEVER deleted. + +Exit code: 0 on success (or if prune fails gracefully), non-zero only on critical error. +""" + +import argparse +import base64 +import json +import os +import sys +import time +from urllib.request import Request, urlopen +from urllib.error import URLError, HTTPError + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Prune Docker registry tags, keeping only the latest SHA tag." + ) + parser.add_argument("--registry", default=os.environ.get("REGISTRY_HOST")) + parser.add_argument("--repo", default=os.environ.get("REGISTRY_REPO")) + parser.add_argument("--username", default=os.environ.get("REGISTRY_USERNAME")) + parser.add_argument("--password", default=os.environ.get("REGISTRY_PASSWORD")) + parser.add_argument("--keep", type=int, default=1, help="Number of SHA tags to keep (default: 1)") + return parser.parse_args() + + +def api_request(url: str, method: str, username: str, password: str, data=None, retries: int = 3) -> dict | None: + """Make an authenticated API request with retry logic.""" + auth = base64.b64encode(f"{username}:{password}".encode()).decode() + headers = { + "Authorization": f"Basic {auth}", + "Content-Type": "application/json", + } + + for attempt in range(1, retries + 1): + try: + req = Request(url, method=method, headers=headers, data=data) + with urlopen(req, timeout=30) as response: + content = response.read() + if content: + return json.loads(content) + return {} + except HTTPError as e: + if e.code == 401: + print(f" [ERROR] Authentication failed (401)") + return None + if e.code == 404: + print(f" [WARN] Resource not found: {url}") + return None + print(f" [RETRY {attempt}/{retries}] HTTP {e.code} for {url}") + except URLError as e: + print(f" [RETRY {attempt}/{retries}] URL error: {e.reason}") + except Exception as e: + print(f" [RETRY {attempt}/{retries}] Error: {e}") + + if attempt < retries: + time.sleep(attempt * 2) + + print(f" [ERROR] Failed after {retries} attempts for {url}") + return None + + +def get_tag_digest(registry: str, repo: str, tag: str, username: str, password: str) -> tuple[str, str] | None: + """Get the digest (sha256:...) and created time for a tag.""" + url = f"https://{registry}/v2/{repo}/manifests/{tag}" + auth = base64.b64encode(f"{username}:{password}".encode()).decode() + + for attempt in range(1, 4): + try: + req = Request(url, method="GET", headers={ + "Authorization": f"Basic {auth}", + "Accept": "application/vnd.docker.distribution.manifest.v2+json", + }) + with urlopen(req, timeout=30) as response: + digest = response.headers.get("Docker-Content-Digest", "") + created = response.headers.get("Date", "") + return digest, created + except Exception as e: + print(f" [RETRY {attempt}/3] Getting digest for {tag}: {e}") + time.sleep(attempt) + + return None + + +def delete_tag(registry: str, repo: str, digest: str, username: str, password: str) -> bool: + """Delete a tag by its digest.""" + url = f"https://{registry}/v2/{repo}/manifests/{digest}" + auth = base64.b64encode(f"{username}:{password}".encode()).decode() + + for attempt in range(1, 4): + try: + req = Request(url, method="DELETE", headers={ + "Authorization": f"Basic {auth}", + }) + with urlopen(req, timeout=30) as response: + if response.status in (200, 202, 404): + return True + except HTTPError as e: + if e.code == 404: + return True # Already deleted + print(f" [RETRY {attempt}/3] Deleting {digest[:20]}...: {e}") + except Exception as e: + print(f" [RETRY {attempt}/3] Deleting {digest[:20]}...: {e}") + + time.sleep(attempt) + + return False + + +def is_sha_tag(tag: str) -> bool: + """Check if tag looks like a SHA (40 hex chars).""" + import re + return bool(re.match(r"^[a-f0-9]{40}$", tag)) + + +def prune_tags(registry: str, repo: str, username: str, password: str, keep: int = 1) -> bool: + """ + Main prune logic: + - List all tags for the repo + - Filter SHA-like tags + - Sort by created date (newest first) + - Keep newest `keep` tags + - Delete older SHA tags by digest + - Never delete non-SHA tags + """ + print(f"\n=== Pruning {registry}/{repo} ===") + print(f"Strategy: Keep {keep} newest SHA tag(s), delete older SHA tags") + print(f"Non-SHA tags (e.g., high-performance-latest, main-latest, latest) are preserved\n") + + # Get catalog (list of repos) + catalog_url = f"https://{registry}/v2/_catalog" + catalog = api_request(catalog_url, "GET", username, password) + if catalog is None: + print("[ERROR] Failed to get repository catalog") + return False + + if repo not in catalog.get("repositories", []): + print(f"[INFO] Repository {repo} not found in catalog") + return True + + # Get tags for repo + tags_url = f"https://{registry}/v2/{repo}/tags/list" + tags_data = api_request(tags_url, "GET", username, password) + if tags_data is None: + print(f"[ERROR] Failed to get tags for {repo}") + return False + + all_tags = tags_data.get("tags", []) + if not all_tags: + print("[INFO] No tags found") + return True + + # Separate SHA tags from non-SHA tags + sha_tags = [t for t in all_tags if is_sha_tag(t)] + non_sha_tags = [t for t in all_tags if not is_sha_tag(t)] + + print(f"Total tags: {len(all_tags)}") + print(f" SHA tags (candidates for pruning): {len(sha_tags)}") + print(f" Non-SHA tags (protected): {len(non_sha_tags)}") + if non_sha_tags: + print(f" Protected tags: {', '.join(sorted(non_sha_tags))}") + + if not sha_tags: + print("\n[INFO] No SHA tags to prune") + return True + + # Get digest and created time for each SHA tag + tag_info = [] + for tag in sha_tags: + result = get_tag_digest(registry, repo, tag, username, password) + if result: + digest, created = result + tag_info.append({ + "tag": tag, + "digest": digest, + "created": created, + "timestamp": parse_http_date(created) if created else 0, + }) + time.sleep(0.1) # Be nice to the registry + + if not tag_info: + print("\n[ERROR] Could not get info for any SHA tags") + return False + + # Sort by timestamp (newest first) + tag_info.sort(key=lambda x: x["timestamp"], reverse=True) + + print(f"\nSHA tags sorted by age (newest first):") + for i, info in enumerate(tag_info): + marker = " [KEEP]" if i < keep else " [DELETE]" + print(f" {i+1}. {info['tag']} ({info['created'] or 'unknown date'}){marker}") + + # Delete older SHA tags + deleted_count = 0 + kept_count = 0 + + for i, info in enumerate(tag_info): + if i < keep: + print(f"\n[KEEP] {info['tag']}") + kept_count += 1 + continue + + print(f"\n[DELETE] {info['tag']} (digest: {info['digest'][:20]}...)") + if delete_tag(registry, repo, info["digest"], username, password): + print(f" [OK] Deleted {info['tag']}") + deleted_count += 1 + else: + print(f" [WARN] Failed to delete {info['tag']} (will retry next run)") + + time.sleep(0.2) # Be nice to the registry + + print(f"\n=== Prune Summary ===") + print(f"Tags kept: {kept_count}") + print(f"Tags deleted: {deleted_count}") + print(f"Tags protected (non-SHA): {len(non_sha_tags)}") + + return True + + +def parse_http_date(date_str: str) -> float: + """Parse HTTP Date header to timestamp.""" + from email.utils import parsedate_to_datetime + try: + return parsedate_to_datetime(date_str).timestamp() + except Exception: + return 0 + + +def main(): + args = parse_args() + + # Validate required args + registry = args.registry or os.environ.get("REGISTRY_HOST") + repo = args.repo or os.environ.get("REGISTRY_REPO") + username = args.username or os.environ.get("REGISTRY_USERNAME") + password = args.password or os.environ.get("REGISTRY_PASSWORD") + + if not all([registry, repo, username, password]): + print("[ERROR] Missing required arguments. Need: --registry, --repo, --username, --password") + print("Or set environment variables: REGISTRY_HOST, REGISTRY_REPO, REGISTRY_USERNAME, REGISTRY_PASSWORD") + sys.exit(1) + + print(f"Registry: {registry}") + print(f"Repository: {repo}") + print(f"Username: {username}") + + try: + success = prune_tags(registry, repo, username, password, args.keep) + if success: + print("\n[OK] Prune completed successfully") + sys.exit(0) + else: + print("\n[WARN] Prune completed with some errors") + sys.exit(0) # Exit 0 per requirement - never fail workflow + except Exception as e: + print(f"\n[ERROR] Prune failed with exception: {e}") + sys.exit(0) # Exit 0 per requirement - never fail workflow + + +if __name__ == "__main__": + main() diff --git a/.gitea/scripts/update-gitops.py b/.gitea/scripts/update-gitops.py new file mode 100644 index 0000000..03805a0 --- /dev/null +++ b/.gitea/scripts/update-gitops.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +Update GitOps kustomization.yaml with new image SHA tags. + +Usage: + python3 update-gitops.py \ + --repo /path/to/nxtgauge-gitops \ + --service gateway \ + --sha abc123def456... + +This script: +1. Updates the newTag for the specified service to the SHA +2. Commits and pushes to the gitops repo +3. ArgoCD detects the change and deploys +""" + +import argparse +import os +import re +import subprocess +import sys + + +def run(cmd: list[str], cwd: str = None) -> tuple[int, str, str]: + """Run a command and return (returncode, stdout, stderr).""" + result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + return result.returncode, result.stdout, result.stderr + + +def update_kustomization(kustomization_path: str, service: str, sha: str) -> bool: + """Update the newTag for a service in kustomization.yaml.""" + with open(kustomization_path, "r") as f: + content = f.read() + + # Pattern to find image entry for the service + # Matches: - name: registry.nxtgauge.com/nxtgauge-rust-{service} + # newTag: something + pattern = rf'(\s+-\s+name:\s+registry\.nxtgauge\.com/nxtgauge-rust-{re.escape(service)}\n\s+newTag:\s+)[^\n]+' + + replacement = rf'\g<1>{sha}' + + new_content, count = re.subn(pattern, replacement, content) + + if count == 0: + # Try without the nxtgauge-rust- prefix (for frontend, admin, etc) + pattern = rf'(\s+-\s+name:\s+registry\.nxtgauge\.com/nxtgauge-{re.escape(service)}\n\s+newTag:\s+)[^\n]+' + new_content, count = re.subn(pattern, replacement, content) + + if count == 0: + print(f"[ERROR] Could not find image entry for service: {service}") + return False + + with open(kustomization_path, "w") as f: + f.write(new_content) + + print(f"[OK] Updated {service} to SHA {sha}") + return True + + +def main(): + parser = argparse.ArgumentParser(description="Update GitOps with new image SHA") + parser.add_argument("--repo", required=True, help="Path to gitops repo") + parser.add_argument("--service", required=True, help="Service name (e.g., gateway, users, frontend-solid)") + parser.add_argument("--sha", required=True, help="Git SHA to deploy") + parser.add_argument("--message", default=None, help="Commit message") + args = parser.parse_args() + + service_image_map = { + "gateway": "nxtgauge-rust-gateway", + "users": "nxtgauge-rust-users", + "companies": "nxtgauge-rust-companies", + "jobs": "nxtgauge-rust-jobs", + "leads": "nxtgauge-rust-leads", + "job-seekers": "nxtgauge-rust-job-seekers", + "customers": "nxtgauge-rust-customers", + "payments": "nxtgauge-rust-payments", + "employees": "nxtgauge-rust-employees", + "photographers": "nxtgauge-rust-photographers", + "makeup-artists": "nxtgauge-rust-makeup-artists", + "tutors": "nxtgauge-rust-tutors", + "developers": "nxtgauge-rust-developers", + "video-editors": "nxtgauge-rust-video-editors", + "graphic-designers": "nxtgauge-rust-graphic-designers", + "social-media-managers": "nxtgauge-rust-social-media-managers", + "fitness-trainers": "nxtgauge-rust-fitness-trainers", + "catering-services": "nxtgauge-rust-catering-services", + "ugc-content-creators": "nxtgauge-rust-ugc-content-creators", + "cron": "nxtgauge-rust-cron", + "frontend-solid": "nxtgauge-frontend-solid", + "admin-solid": "nxtgauge-admin-solid", + "ai-assistant": "nxtgauge-ai-assistant", + } + + # Determine which kustomization file to update + if service_image_map.get(args.service): + image_name = service_image_map[args.service] + else: + image_name = f"nxtgauge-{args.service}" + + # Find the right kustomization file based on service + if "frontend" in args.service or "admin" in args.service: + kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/overlays/prod/kustomization.yaml") + if not os.path.exists(kustomization_path): + kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/base/kustomization.yaml") + elif "ai-assistant" in args.service: + kustomization_path = os.path.join(args.repo, "apps/nxtgauge-ai-assistant/overlays/prod/kustomization.yaml") + if not os.path.exists(kustomization_path): + kustomization_path = os.path.join(args.repo, "apps/nxtgauge-ai-assistant/base/kustomization.yaml") + else: + kustomization_path = os.path.join(args.repo, "apps/nxtgauge-backend-rust/overlays/prod/kustomization.yaml") + + if not os.path.exists(kustomization_path): + print(f"[ERROR] Kustomization file not found: {kustomization_path}") + sys.exit(0) # Exit 0 per workflow requirement + + print(f"Updating {kustomization_path} for service {args.service}") + + if not update_kustomization(kustomization_path, args.service, args.sha): + sys.exit(0) # Exit 0 per workflow requirement + + # Git add, commit, push + commit_msg = args.message or f"chore: deploy {args.service}@{args.sha}" + + run(["git", "add", "-A"], cwd=args.repo) + code, stdout, stderr = run(["git", "diff", "--cached", "--stat"], cwd=args.repo) + + if not stdout.strip(): + print("[INFO] No changes to commit") + sys.exit(0) + + print(f"Changes to commit:\n{stdout}") + + run(["git", "commit", "-m", commit_msg], cwd=args.repo) + code, stdout, stderr = run(["git", "push"], cwd=args.repo) + + if code != 0: + print(f"[ERROR] Push failed: {stderr}") + else: + print(f"[OK] Pushed update to gitops repo") + + sys.exit(0) # Always exit 0 per workflow requirement + + +if __name__ == "__main__": + main() diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..4b9478d --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,111 @@ +name: build-and-push + +on: + push: + branches: + - main + - high-performance + +jobs: + build: + runs-on: ubuntu-latest + env: + DOCKER_HOST: unix:///var/run/docker.sock + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + run: | + export DOCKER_HOST=unix:///var/run/docker.sock + docker version + docker buildx create --use || true + docker buildx inspect --bootstrap + + - name: Login to Registry + env: + REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + set -euo pipefail + export DOCKER_HOST=unix:///var/run/docker.sock + test -n "$REGISTRY_HOSTPORT" + echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin + + - name: Build and push + env: + REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }} + run: | + set -euo pipefail + export DOCKER_HOST=unix:///var/run/docker.sock + + build_and_push() { + docker buildx build --push \ + -f Dockerfile \ + -t "$REGISTRY_HOSTPORT/nxtgauge-frontend-solid:${{ gitea.sha }}" \ + -t "$REGISTRY_HOSTPORT/nxtgauge-frontend-solid:high-performance-latest" \ + . + } + + for attempt in 1 2 3; do + echo "Build attempt $attempt" + if build_and_push; then + exit 0 + fi + echo "Build attempt $attempt failed; recreating builder and retrying" + docker buildx rm --all-inactive --force || true + docker buildx create --use || true + docker buildx inspect --bootstrap + sleep $((attempt * 10)) + done + + echo "Build failed after retries" + exit 1 + + - name: Prune old image tags (keep latest 1 SHA) + if: success() + continue-on-error: true + env: + REGISTRY_HOST: ${{ secrets.REGISTRY_HOSTPORT }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + set -euo pipefail + python3 .gitea/scripts/registry_prune.py \ + --registry "$REGISTRY_HOST" \ + --repo "nxtgauge-frontend-solid" \ + --username "$REGISTRY_USERNAME" \ + --password "$REGISTRY_PASSWORD" \ + --keep 1 + + - name: Update GitOps and trigger deployment + if: success() + continue-on-error: true + env: + GITEOPS_REPO: ${{ secrets.GITEOPS_REPO }} + GITEOPS_SSH_KEY: ${{ secrets.GITEOPS_SSH_KEY }} + run: | + set -euo pipefail + + if [ -z "$GITEOPS_REPO" ]; then + echo "GITEOPS_REPO secret not set, skipping GitOps update" + exit 0 + fi + + GITEOPS_DIR=$(mktemp -d) + git clone "$GITEOPS_REPO" "$GITEOPS_DIR" + cd "$GITEOPS_DIR" + + mkdir -p ~/.ssh + echo "$GITEOPS_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null + + python3 .gitea/scripts/update-gitops.py \ + --repo "$GITEOPS_DIR" \ + --service "frontend-solid" \ + --sha "${{ gitea.sha }}" \ + --message "chore: deploy frontend-solid@${{ gitea.sha }}" + + rm -rf "$GITEOPS_DIR" diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml new file mode 100644 index 0000000..e6f0576 --- /dev/null +++ b/.gitea/workflows/test.yaml @@ -0,0 +1,191 @@ +name: nightly-tests + +on: + schedule: + - cron: "30 2 * * *" # 2:30 AM daily + workflow_dispatch: # Manual trigger + +env: + DOCKER_HOST: unix:///var/run/docker.sock + +jobs: + # ── Unit Tests ──────────────────────────────────────────────────────────────── + unit-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm run test + env: + CI: "true" + + - name: Upload Vitest coverage + uses: actions/upload-artifact@v4 + if: always() + with: + name: vitest-coverage + path: test-results/vitest-coverage/ + + # ── E2E Tests ───────────────────────────────────────────────────────────────── + e2e-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + + - name: Start dev server + run: npm run dev & + env: + PORT: 3000 + shell: bash + + - name: Wait for server + run: npx wait-on http://localhost:3000 --timeout 60000 + + - name: Run E2E tests + run: npx playwright test --config=playwright.config.ts + env: + CI: "true" + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: test-results/ + + - name: Upload test videos + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-videos + path: test-videos/ + + # ── Accessibility Tests ──────────────────────────────────────────────────────── + a11y-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + + - name: Start dev server + run: npm run dev & + env: + PORT: 3000 + shell: bash + + - name: Wait for server + run: npx wait-on http://localhost:3000 --timeout 60000 + + - name: Run accessibility tests + run: npx playwright test --config=playwright.a11y.config.ts + env: + CI: "true" + + - name: Upload a11y report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-a11y-report + path: test-results/ + + # ── Visual Tests ────────────────────────────────────────────────────────────── + visual-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + + - name: Start dev server + run: npm run dev & + env: + PORT: 3000 + shell: bash + + - name: Wait for server + run: npx wait-on http://localhost:3000 --timeout 60000 + + - name: Run visual tests + run: npx playwright test --config=playwright.visual.config.ts + env: + CI: "true" + + - name: Upload visual diffs + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-visual-diffs + path: test-results/visual/ + + # ── Test Summary ────────────────────────────────────────────────────────────── + test-summary: + runs-on: ubuntu-latest + needs: [unit-tests, e2e-tests, a11y-tests] + if: always() + steps: + - name: Test results summary + run: | + echo "## Nightly Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY + echo "|------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Unit Tests | ${{ needs.unit-tests.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| E2E Tests | ${{ needs.e2e-tests.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Accessibility Tests | ${{ needs.a11y-tests.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Visual Tests | ${{ needs.visual-tests.result }} |" >> $GITHUB_STEP_SUMMARY + + - name: Notify on failure + if: needs.unit-tests.result == 'failure' || needs.e2e-tests.result == 'failure' || needs.a11y-tests.result == 'failure' + run: | + echo "⚠️ Some tests failed. Check the artifacts for details." \ No newline at end of file diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml new file mode 100644 index 0000000..a9b0694 --- /dev/null +++ b/.github/workflows/sync-to-gitea.yml @@ -0,0 +1,46 @@ +name: sync-to-gitea + +on: + push: + branches: + - high-performance + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Sync to Gitea + env: + GITEA_TOKEN: ${{ secrets.GITEA_SECRET }} + REPO: ${{ github.event.repository.name }} + BRANCH: ${{ github.ref_name }} + run: | + set -euxo pipefail + export GIT_TERMINAL_PROMPT=0 + export GIT_TRACE=1 + export GIT_CURL_VERBOSE=1 + + USER="Admin" + TARGET="https://ci.nxtgauge.com/Admin/${REPO}.git" + AUTH="$(printf '%s' "${USER}:${GITEA_TOKEN}" | base64 -w0)" + + test -n "${GITEA_TOKEN:-}" || (echo "GITEA_TOKEN empty" && exit 1) + curl -fsS -H "Authorization: token ${GITEA_TOKEN}" https://ci.nxtgauge.com/api/v1/user >/dev/null + curl -fsS -H "Authorization: Basic ${AUTH}" "${TARGET}/info/refs?service=git-receive-pack" >/dev/null + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git config --global http.version HTTP/1.1 + git config --global http.postBuffer 524288000 + git remote remove gitea 2>/dev/null || true + git remote add gitea "${TARGET}" + + git -c http.extraheader="Authorization: Basic ${AUTH}" push gitea "HEAD:${BRANCH}" --force + git -c http.extraheader="Authorization: Basic ${AUTH}" push gitea --tags --force diff --git a/.woodpecker.yml b/.woodpecker.yml deleted file mode 100644 index 45dbb75..0000000 --- a/.woodpecker.yml +++ /dev/null @@ -1,25 +0,0 @@ -when: - branch: [main, high-performance] - event: push - -steps: - - name: build-and-push - image: woodpeckerci/plugin-kaniko:2.1.1 - settings: - registry: - from_secret: REGISTRY_HOSTPORT - repo: nxtgauge-frontend-solid - dockerfile: Dockerfile.simple - tags: - - ${CI_COMMIT_SHA} - - latest - - high-performance-latest - username: - from_secret: REGISTRY_USERNAME - password: - from_secret: REGISTRY_PASSWORD - insecure: true - insecure_pull: true - skip_tls_verify: true - platforms: linux/amd64 - cache: false diff --git a/Dockerfile b/Dockerfile index c874eaa..a912af5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Multi-stage build with memory optimization -FROM node:20-slim AS builder +FROM registry.nxtgauge.com/node:20-alpine AS builder WORKDIR /app # Skip browser downloads @@ -13,13 +13,13 @@ RUN echo "VITE_API_URL=http://localhost:9100" > .env && \ echo "VITE_RUST_API_URL=http://localhost:9100/api" >> .env # Install build dependencies -RUN apt-get update && apt-get install -y python3 make g++ git && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache python3 make g++ git # Copy package files COPY package*.json ./ -# Install dependencies -RUN npm ci --legacy-peer-deps --prefer-offline --no-audit +# Install dependencies including devDependencies (needed for Tailwind/Vite) +RUN npm ci --legacy-peer-deps --prefer-offline --no-audit --include=dev # Copy source COPY . . @@ -29,7 +29,7 @@ ENV NODE_OPTIONS="--max-old-space-size=4096" RUN npm run build # Runtime stage -FROM node:20-alpine +FROM registry.nxtgauge.com/node:20-alpine WORKDIR /app # Copy built output diff --git a/Dockerfile.simple b/Dockerfile.simple index 378ea45..86e3f7c 100644 --- a/Dockerfile.simple +++ b/Dockerfile.simple @@ -1,4 +1,4 @@ -FROM node:20-alpine +FROM registry.nxtgauge.com/node:20-alpine WORKDIR /app diff --git a/README.md b/README.md index d694ec1..37b05b9 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,9 @@ SolidStart migration target for `nxtgauge-frontendwebsite`. Reproduce the same user-facing behavior and runtime-config driven flows without changing product logic. See `docs/MIGRATION_MASTER_PLAN.md` for the staged plan. + +## CI (Woodpecker) + +Required secrets: +- `REGISTRY_USERNAME` +- `REGISTRY_PASSWORD` diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..654f054 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,134 @@ +# Testing Guide — Nxtgauge Frontend + +## Test Stack + +| Type | Tool | Config | +|------|------|--------| +| Unit | Vitest | `vitest.config.ts` | +| E2E | Playwright | `playwright.config.ts` | +| Accessibility | Playwright + axe-core | `playwright.a11y.config.ts` | +| Visual | Playwright screenshot diff | `playwright.visual.config.ts` | + +## Running Tests + +### Local (before push) + +```bash +# Unit tests +npm run test + +# Unit tests (watch mode) +npm run test:watch + +# Unit tests with coverage +npm run test:coverage + +# All E2E tests +npm run test:e2e + +# Accessibility tests only +npm run test:accessibility + +# Visual tests only +npm run test:visual +``` + +### Dev server must be running for E2E/a11y/visual tests + +```bash +npm run dev & +npx wait-on http://localhost:3000 +# Then run tests in another terminal +``` + +## Test Directories + +``` +tests/ +├── e2e/ +│ ├── ai-chat-widget.spec.ts # AI Chat Widget E2E +│ ├── company-jobs.spec.ts # Company Jobs Page E2E +│ ├── company-verification-flow.spec.ts +│ ├── signup-verification-submission.spec.ts +│ ├── accessibility.spec.ts +│ └── visual/ +│ └── *.png # Visual baseline screenshots +├── vitest/ +│ └── components/ +│ ├── AiChatWidget.test.tsx +│ └── PublicFooter.test.tsx +``` + +## Writing E2E Tests + +```typescript +import { test, expect } from "@playwright/test"; + +test("description of test", async ({ page }) => { + await page.goto("/route"); + await page.waitForLoadState("networkidle"); + + // Assertions + await expect(page.locator("text=Expected")).toBeVisible(); + + // Interactions + await page.click("button[type='submit']"); + await page.fill("input[name='email']", "test@example.com"); +}); +``` + +## Writing Vitest Unit Tests + +```typescript +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@solidjs/testing-library"; +import { MyComponent } from "../src/components/MyComponent"; + +global.fetch = vi.fn(); + +describe("MyComponent", () => { + it("renders correctly", () => { + render(() => ); + expect(screen.getByText("Hello")).toBeTruthy(); + }); +}); +``` + +## Visual Tests + +Visual tests compare screenshots against baselines in `tests/e2e/visual/`. +- Update baselines: `npx playwright test --config=playwright.visual.config.ts --update-snapshots` +- Review diffs in `test-results/visual/` + +## CI / Nightly Runs + +GitHub Actions runs tests nightly via `.gitea/workflows/test.yaml`: +- **2:30 AM daily** — all test suites +- **On-demand** — use `workflow_dispatch` trigger in Gitea + +Artifacts are uploaded: +- `vitest-coverage/` — coverage reports +- `playwright-report/` — HTML test report +- `playwright-videos/` — recordings of failed tests +- `playwright-a11y-report/` — accessibility results +- `playwright-visual-diffs/` — screenshot diffs + +## Coverage Requirements + +- Minimum coverage target: **70%** +- Run `npm run test:coverage` to generate coverage report +- Coverage report location: `test-results/vitest-coverage/` + +## Adding New Tests + +1. **E2E tests**: Add `.spec.ts` to `tests/e2e/` +2. **Unit tests**: Add `.test.tsx` to `tests/vitest/components/` +3. **Visual tests**: Add page screenshots to `tests/e2e/visual/` as baselines + +## Troubleshooting + +**Playwright timeout**: Increase `timeout` in config or use `test.setTimeout()` + +**Flaky tests**: Use `await page.waitForLoadState("networkidle")` instead of arbitrary waits + +**MSW not intercepting**: Ensure `setup.ts` is imported in `vitest.config.ts` via `setupFiles` \ No newline at end of file diff --git a/e2e-test-manual.ts b/e2e-test-manual.ts new file mode 100644 index 0000000..120b293 --- /dev/null +++ b/e2e-test-manual.ts @@ -0,0 +1,203 @@ +import { chromium } from '@playwright/test'; +import { randomUUID } from 'crypto'; +import { execSync } from 'child_process'; + +const testEmail = `testcompany${randomUUID().slice(0, 8)}@test.com`; +const testPassword = "TestPassword123!"; +const testCompanyName = `Test Company ${randomUUID().slice(0, 6)}`; + +console.log('🧪 E2E Test - Company & Job Seeker Verification Flow'); +console.log('📧 Company Email:', testEmail); +console.log('🏢 Company Name:', testCompanyName); +console.log('🔑 Password:', testPassword); + +async function waitForEnter() { + console.log('\n⏳ Press Enter to continue...'); + await new Promise(resolve => setTimeout(resolve, 2000)); +} + +(async () => { + const browser = await chromium.launch({ headless: false, slowMo: 50 }); + const context = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + + try { + // ==================== PHASE 1: COMPANY FLOW ==================== + console.log('\n========== PHASE 1: COMPANY REGISTRATION ==========\n'); + + const page = await context.newPage(); + + // Step 1: Register via API + console.log('📝 Step 1: Registering company via API...'); + const regResponse = await fetch('http://localhost:9100/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: testEmail, first_name: 'John', last_name: 'Doe', password: testPassword, intent: 'company' }) + }); + const regData = await regResponse.json(); + if (!regData.user_id) { + console.log(' ❌ Registration failed:', regData); + throw new Error('Registration failed'); + } + console.log(' ✅ Registered, user_id:', regData.user_id); + + // Step 2: Set test OTP in Redis + console.log('\n🔐 Step 2: Setting test OTP in Redis...'); + await new Promise(resolve => setTimeout(resolve, 500)); + try { + execSync(`redis-cli SETEX "otp:code:123456" 900 "${regData.user_id}"`, { encoding: 'utf8' }); + console.log(' ✅ Set test OTP: 123456'); + } catch (e: any) { + console.log(' ⚠️ Could not set OTP in Redis:', e.message); + } + + // Step 3: Verify OTP via API + console.log('\n✅ Step 3: Verifying OTP via API...'); + const verifyResponse = await fetch('http://localhost:9100/api/auth/verify-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ otp: '123456' }) + }); + const verifyData = await verifyResponse.json(); + console.log(' ✅ OTP verified!'); + + // Step 4: Login via API + console.log('\n🔑 Step 4: Logging in via API...'); + const loginResponse = await fetch('http://localhost:9100/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: testEmail, password: testPassword }) + }); + const loginData = await loginResponse.json(); + if (loginData.access_token) { + console.log(' ✅ Logged in via API!'); + } + + console.log('\n🌐 MANUAL STEP: Open browser and:'); + console.log(' 1. Go to http://localhost:3000/login'); + console.log(' 2. Login with:'); + console.log(' Email: ' + testEmail); + console.log(' Password: ' + testPassword); + console.log(' 3. Complete CAPTCHA'); + console.log(' 4. Fill company profile at /dashboard/profile'); + console.log(' 5. Upload business documents'); + console.log(' 6. Submit for verification'); + console.log(' 7. Take screenshots of the profile form'); + console.log(' Then press Enter to continue to admin verification...'); + await waitForEnter(); + + // ==================== ADMIN VERIFICATION ==================== + console.log('\n========== PHASE 2: ADMIN VERIFICATION ==========\n'); + + const adminPage = await context.newPage(); + await adminPage.goto('http://localhost:3001/login'); + await adminPage.waitForLoadState('networkidle'); + await adminPage.waitForTimeout(2000); + await adminPage.screenshot({ path: './test-results/08-admin-login.png', fullPage: true }); + + console.log('\n🌐 MANUAL STEP: Admin login at http://localhost:3001/login'); + console.log(' Email: admin@nxtgauge.com'); + console.log(' Password: Admin@nxtgauge1'); + console.log(' Then press Enter to continue...'); + await waitForEnter(); + + await adminPage.screenshot({ path: './test-results/09-admin-logged-in.png', fullPage: true }); + + console.log('\n🌐 MANUAL STEP: In admin panel:'); + console.log(' 1. Go to Verification Management'); + console.log(' 2. Find the company by email: ' + testEmail); + console.log(' 3. Check images/documents viewer'); + console.log(' 4. Verify and send to approval'); + console.log(' 5. Go to Approval Management'); + console.log(' 6. Approve'); + console.log(' Then press Enter to continue...'); + await waitForEnter(); + + await adminPage.screenshot({ path: './test-results/10-company-approved.png', fullPage: true }); + + // ==================== PHASE 3: JOB SEEKER FLOW ==================== + console.log('\n========== PHASE 3: JOB SEEKER REGISTRATION ==========\n'); + + const jsEmail = `testjobseeker${randomUUID().slice(0, 8)}@test.com`; + console.log('📧 Job Seeker Email:', jsEmail); + + console.log('\n📝 Step 1: Registering job seeker via API...'); + const jsRegResponse = await fetch('http://localhost:9100/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: jsEmail, first_name: 'Jane', last_name: 'Smith', password: testPassword, intent: 'job_seeker' }) + }); + const jsRegData = await jsRegResponse.json(); + if (!jsRegData.user_id) { + console.log(' ❌ Registration failed:', jsRegData); + throw new Error('Job seeker registration failed'); + } + console.log(' ✅ Registered, user_id:', jsRegData.user_id); + + console.log('\n🔐 Setting test OTP in Redis...'); + try { + execSync(`redis-cli SETEX "otp:code:123456" 900 "${jsRegData.user_id}"`, { encoding: 'utf8' }); + console.log(' ✅ Set test OTP: 123456'); + } catch (e) { + console.log(' ⚠️ Could not set OTP in Redis'); + } + + console.log('\n✅ Verifying OTP via API...'); + await fetch('http://localhost:9100/api/auth/verify-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ otp: '123456' }) + }); + console.log(' ✅ OTP verified!'); + + console.log('\n🔑 Logging in via API...'); + const jsLoginResponse = await fetch('http://localhost:9100/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: jsEmail, password: testPassword }) + }); + const jsLoginData = await jsLoginResponse.json(); + if (jsLoginData.access_token) { + console.log(' ✅ Logged in via API!'); + } + + console.log('\n🌐 MANUAL STEP: Open browser and:'); + console.log(' 1. Go to http://localhost:3000/login'); + console.log(' 2. Login with:'); + console.log(' Email: ' + jsEmail); + console.log(' Password: ' + testPassword); + console.log(' 3. Complete CAPTCHA'); + console.log(' 4. Fill job seeker profile at /dashboard/profile'); + console.log(' 5. Add education, skills, resume'); + console.log(' 6. Submit for verification'); + console.log(' 7. Take screenshots of the profile form'); + console.log(' Then press Enter to continue to admin verification...'); + await waitForEnter(); + + console.log('\n🌐 MANUAL STEP: In admin panel:'); + console.log(' 1. Go to Verification Management'); + console.log(' 2. Find the job seeker by email: ' + jsEmail); + console.log(' 3. Check all fields and documents'); + console.log(' 4. Verify and send to approval'); + console.log(' 5. Go to Approval Management'); + console.log(' 6. Approve'); + console.log(' Then press Enter to continue...'); + await waitForEnter(); + + await adminPage.screenshot({ path: './test-results/11-job-seeker-approved.png', fullPage: true }); + + console.log('\n✅ FULL TEST COMPLETE!'); + console.log('\n📸 Screenshots saved to ./test-results/'); + console.log('Company Email:', testEmail); + console.log('Job Seeker Email:', jsEmail); + console.log('Password:', testPassword); + + console.log('\n⏳ Keeping browser open for 60 seconds for review...'); + await new Promise(resolve => setTimeout(resolve, 60000)); + + } catch (error: any) { + console.error('❌ Error:', error.message); + await new Promise(resolve => setTimeout(resolve, 5000)).catch(() => {}); + } finally { + await browser.close(); + } +})(); diff --git a/frontend-solid.dev.log b/frontend-solid.dev.log new file mode 100644 index 0000000..3c65a07 --- /dev/null +++ b/frontend-solid.dev.log @@ -0,0 +1,5 @@ + +> dev +> vinxi dev + +vinxi v0.5.11 diff --git a/frontend-solid.dev.pid b/frontend-solid.dev.pid new file mode 100644 index 0000000..2419923 --- /dev/null +++ b/frontend-solid.dev.pid @@ -0,0 +1 @@ +61048 diff --git a/frontend-solid.start.log b/frontend-solid.start.log new file mode 100644 index 0000000..4705e3b --- /dev/null +++ b/frontend-solid.start.log @@ -0,0 +1 @@ +Listening on http://[::]:3001 diff --git a/frontend-solid.start.pid b/frontend-solid.start.pid new file mode 100644 index 0000000..f79f1f2 --- /dev/null +++ b/frontend-solid.start.pid @@ -0,0 +1 @@ +72152 diff --git a/frontend.log b/frontend.log deleted file mode 100644 index f7fa6e7..0000000 --- a/frontend.log +++ /dev/null @@ -1,112 +0,0 @@ - -> dev -> vinxi dev - -vinxi v0.5.11 -vinxi found vinxi app config in vite.config.ts -vinxi starting dev server -[get-port] Unable to find an available port (tried 3000 on host "localhost"). 2:56:28 AM [vite] (ssr) page reload tests/e2e/company-verification-flow.spec.ts -2:56:28 AM [vite] (ssr) page reload tests/e2e/company-verification-flow.spec.ts -3:26:54 AM [vite] (ssr) page reload vinxi/routes -3:26:54 AM [vite] (ssr) page reload vinxi/routes -3:28:19 AM [vite] (ssr) page reload vinxi/routes -3:30:32 AM [vite] (ssr) page reload vinxi/routes -3:31:20 AM [vite] (ssr) page reload vinxi/routes -5:20:52 AM [vite] (ssr) page reload .woodpecker.yml -5:23:49 AM [vite] (ssr) page reload .woodpecker.yml -5:23:49 AM [vite] (ssr) page reload .woodpecker.yml -5:26:46 AM [vite] (ssr) page reload .woodpecker.yml -5:33:56 AM [vite] (ssr) page reload .woodpecker.yml -5:33:56 AM [vite] (ssr) page reload .woodpecker.yml -5:42:24 AM [vite] (ssr) page reload .woodpecker.yml -5:38:53 PM [vite] (ssr) page reload .woodpecker.yml -5:38:54 PM [vite] (ssr) page reload .woodpecker.yml -5:42:25 PM [vite] (ssr) page reload .woodpecker.yml -5:42:25 PM [vite] (ssr) page reload .woodpecker.yml -5:46:09 PM [vite] (ssr) page reload Dockerfile -5:56:23 PM [vite] (ssr) page reload .woodpecker.yml -6:10:53 PM [vite] (ssr) page reload Dockerfile -6:11:15 PM [vite] (ssr) page reload Dockerfile -6:17:06 PM [vite] (ssr) page reload Dockerfile -6:17:27 PM [vite] (ssr) page reload Dockerfile -6:18:12 PM [vite] (ssr) page reload Dockerfile -7:37:02 PM [vite] (ssr) page reload Dockerfile -7:46:07 PM [vite] (ssr) page reload .woodpecker.yml -8:18:17 PM [vite] (ssr) page reload src/lib/api.ts -8:18:45 PM [vite] changed tsconfig file detected: /Users/ashwin/workspace/nxtgauge-frontend-solid/.vinxi/types/tsconfig.json - Clearing cache and forcing full-reload to ensure TypeScript is compiled with updated config values. -8:18:45 PM [vite] changed tsconfig file detected: /Users/ashwin/workspace/nxtgauge-frontend-solid/.vinxi/types/tsconfig.json - Clearing cache and forcing full-reload to ensure TypeScript is compiled with updated config values. -8:18:45 PM [vite] changed tsconfig file detected: /Users/ashwin/workspace/nxtgauge-frontend-solid/.vinxi/types/tsconfig.json - Clearing cache and forcing full-reload to ensure TypeScript is compiled with updated config values. -8:21:10 PM [vite] (ssr) page reload Dockerfile.simple -8:25:01 PM [vite] (ssr) page reload .woodpecker.yml -8:27:58 PM [vite] (ssr) page reload .woodpecker.yml -8:34:08 PM [vite] (ssr) page reload .woodpecker.yml -9:18:15 PM [vite] (ssr) page reload .woodpecker.yml -9:30:15 PM [vite] (ssr) page reload .woodpecker.yml -9:57:49 PM [vite] (ssr) page reload .woodpecker.yml -10:05:40 PM [vite] (ssr) page reload .woodpecker.yml -10:07:40 PM [vite] (ssr) page reload .woodpecker.yml -10:24:24 PM [vite] (ssr) page reload .woodpecker.yml -10:42:09 PM [vite] (ssr) page reload .woodpecker.yml -11:00:57 PM [vite] (ssr) page reload .woodpecker.yml -11:19:43 PM [vite] (ssr) page reload .woodpecker.yml -11:29:43 PM [vite] (ssr) page reload .woodpecker.yml -11:34:28 PM [vite] (ssr) page reload .woodpecker.yml -11:46:08 PM [vite] (ssr) page reload .woodpecker.yml -11:58:32 PM [vite] (ssr) page reload .woodpecker.yml -12:07:38 AM [vite] (ssr) page reload .woodpecker.yml -12:13:17 AM [vite] (ssr) page reload .woodpecker.yml -12:36:13 AM [vite] (ssr) page reload .woodpecker.yml -1:32:07 PM [vite] (ssr) page reload .woodpecker.yml -1:43:17 PM [vite] (ssr) page reload .woodpecker.yml -1:49:27 PM [vite] (ssr) page reload .woodpecker.yml -1:57:54 PM [vite] (ssr) page reload .woodpecker.yml -8:18:45 PM [vite] changed tsconfig file detected: /Users/ashwin/workspace/nxtgauge-frontend-solid/.vinxi/types/tsconfig.json - Clearing cache and forcing full-reload to ensure TypeScript is compiled with updated config values. -8:18:45 PM [vite] changed tsconfig file detected: /Users/ashwin/workspace/nxtgauge-frontend-solid/.vinxi/types/tsconfig.json - Clearing cache and forcing full-reload to ensure TypeScript is compiled with updated config values. -8:21:10 PM [vite] (ssr) page reload Dockerfile.simple -8:21:10 PM [vite] (client) page reload Dockerfile.simple -8:25:01 PM [vite] (ssr) page reload .woodpecker.yml -8:25:01 PM [vite] (client) page reload .woodpecker.yml -8:27:58 PM [vite] (ssr) page reload .woodpecker.yml -8:27:58 PM [vite] (client) page reload .woodpecker.yml -8:34:08 PM [vite] (ssr) page reload .woodpecker.yml -8:34:08 PM [vite] (client) page reload .woodpecker.yml -9:18:15 PM [vite] (ssr) page reload .woodpecker.yml -9:18:15 PM [vite] (client) page reload .woodpecker.yml -9:30:15 PM [vite] (ssr) page reload .woodpecker.yml -9:30:15 PM [vite] (client) page reload .woodpecker.yml -9:57:49 PM [vite] (ssr) page reload .woodpecker.yml -9:57:49 PM [vite] (client) page reload .woodpecker.yml -10:05:40 PM [vite] (ssr) page reload .woodpecker.yml -10:05:40 PM [vite] (client) page reload .woodpecker.yml -10:07:40 PM [vite] (ssr) page reload .woodpecker.yml -10:07:41 PM [vite] (client) page reload .woodpecker.yml -10:24:24 PM [vite] (ssr) page reload .woodpecker.yml -10:24:24 PM [vite] (client) page reload .woodpecker.yml -10:42:09 PM [vite] (ssr) page reload .woodpecker.yml -10:42:09 PM [vite] (client) page reload .woodpecker.yml -11:00:57 PM [vite] (ssr) page reload .woodpecker.yml -11:00:57 PM [vite] (client) page reload .woodpecker.yml -11:19:43 PM [vite] (ssr) page reload .woodpecker.yml -11:19:43 PM [vite] (client) page reload .woodpecker.yml -11:29:43 PM [vite] (ssr) page reload .woodpecker.yml -11:29:43 PM [vite] (client) page reload .woodpecker.yml -11:34:28 PM [vite] (ssr) page reload .woodpecker.yml -11:34:28 PM [vite] (client) page reload .woodpecker.yml -11:46:08 PM [vite] (ssr) page reload .woodpecker.yml -11:46:08 PM [vite] (client) page reload .woodpecker.yml -11:58:32 PM [vite] (ssr) page reload .woodpecker.yml -11:58:32 PM [vite] (client) page reload .woodpecker.yml -12:07:38 AM [vite] (ssr) page reload .woodpecker.yml -12:07:38 AM [vite] (client) page reload .woodpecker.yml -12:13:17 AM [vite] (ssr) page reload .woodpecker.yml -12:13:17 AM [vite] (client) page reload .woodpecker.yml -12:36:13 AM [vite] (ssr) page reload .woodpecker.yml -12:36:13 AM [vite] (client) page reload .woodpecker.yml -1:32:07 PM [vite] (ssr) page reload .woodpecker.yml -1:32:07 PM [vite] (client) page reload .woodpecker.yml -1:43:17 PM [vite] (ssr) page reload .woodpecker.yml -1:43:17 PM [vite] (client) page reload .woodpecker.yml -1:49:27 PM [vite] (ssr) page reload .woodpecker.yml -1:49:27 PM [vite] (client) page reload .woodpecker.yml -1:57:54 PM [vite] (ssr) page reload .woodpecker.yml -1:57:54 PM [vite] (client) page reload .woodpecker.yml diff --git a/package-lock.json b/package-lock.json index 183e1e3..054bb52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "storybook": "^10.3.3", "storybook-solidjs-vite": "^10.0.11", "tailwindcss": "^4.2.2", + "typescript": "^6.0.3", "visbug": "^0.1.14", "vitest": "^4.1.1" }, @@ -15024,6 +15025,20 @@ "dev": true, "license": "MIT" }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/ufo": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", diff --git a/package.json b/package.json index 6682ad4..edecde8 100644 --- a/package.json +++ b/package.json @@ -26,32 +26,33 @@ "devDependencies": { "@axe-core/playwright": "^4.11.1", "@chromatic-com/storybook": "^5.1.0", + "@mswjs/data": "^0.16.2", "@playwright/test": "^1.58.2", "@solidjs/testing-library": "^0.8.0", "@storybook/addon-a11y": "^10.3.3", "@storybook/addon-docs": "^10.3.3", "@storybook/addon-vitest": "^10.3.3", + "@tailwindcss/vite": "^4.2.2", "@testing-library/jest-dom": "^6.6.3", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", "@vitest/browser": "^3.2.4", "@vitest/coverage-v8": "^3.2.4", "eslint": "^10.1.0", + "eslint-plugin-solid": "^0.14.5", "jsdom": "^25.0.1", "loki": "^0.35.1", "msw": "^2.7.3", - "@mswjs/data": "^0.16.2", "pixelmatch": "^7.1.0", "playwright": "^1.58.2", "pngjs": "^7.0.0", + "prettier": "^3.0.0", "storybook": "^10.3.3", "storybook-solidjs-vite": "^10.0.11", - "@tailwindcss/vite": "^4.2.2", "tailwindcss": "^4.2.2", + "typescript": "^6.0.3", "visbug": "^0.1.14", - "vitest": "^4.1.1", - "@typescript-eslint/parser": "^7.0.0", - "@typescript-eslint/eslint-plugin": "^7.0.0", - "eslint-plugin-solid": "^0.14.5", - "prettier": "^3.0.0" + "vitest": "^4.1.1" }, "engines": { "node": ">=20" diff --git a/playwright-reports/html/index.html b/playwright-reports/html/index.html new file mode 100644 index 0000000..dbf9dfe --- /dev/null +++ b/playwright-reports/html/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..294a465 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 4 : undefined, + reporter: [["list"], ["html", { outputFolder: "./playwright-reports/html" }]], + use: { + baseURL: "http://localhost:3000", + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "chromium-mobile", + use: { ...devices["Pixel 5"] }, + }, + ], + webServer: process.env.CI + ? undefined + : { + command: "npm run dev", + url: "http://localhost:3000", + reuseExistingServer: true, + timeout: 120 * 1000, + }, +}); \ No newline at end of file diff --git a/playwright.visual.config.ts b/playwright.visual.config.ts index 2e2b8a4..088991f 100644 --- a/playwright.visual.config.ts +++ b/playwright.visual.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ workers: process.env.CI ? 4 : undefined, reporter: "list", use: { - baseURL: "http://localhost:5173", + baseURL: "http://localhost:3000", screenshot: "only-on-failure", trace: "on-first-retry", }, diff --git a/public/ai-assistant-logo.png b/public/ai-assistant-logo.png new file mode 100644 index 0000000..04f4abb Binary files /dev/null and b/public/ai-assistant-logo.png differ diff --git a/send-to-hermes.sh b/send-to-hermes.sh new file mode 100755 index 0000000..2b651a5 --- /dev/null +++ b/send-to-hermes.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Send a message to Hermes Agent +# Usage: ./send-to-hermes.sh "Your message here" + +source ~/.zshrc + +MESSAGE="${1:-What E2E testing skills do you have for Nxtgauge?}" + +# Create a temporary script to send to hermes +cat > /tmp/hermes-prompt.txt << 'PROMPT' +PROMPT + +echo "$MESSAGE" >> /tmp/hermes-prompt.txt + +# Try using expect or script to create a pseudo-TTY +if command -v expect &> /dev/null; then + expect -c " + spawn hermes + send \"$MESSAGE\r\" + expect \"Goodbye\" + exit 0 + " 2>&1 +else + # Fallback: just open terminal and copy message + echo "Please run these commands in a new terminal:" + echo "" + echo "Terminal 1:" + echo " source ~/.zshrc && hermes" + echo "" + echo "Then type this message:" + echo " $MESSAGE" +fi diff --git a/signup-form-before.png b/signup-form-before.png new file mode 100644 index 0000000..4d1d133 Binary files /dev/null and b/signup-form-before.png differ diff --git a/src/app.css b/src/app.css index 11c93c6..3c253e7 100644 --- a/src/app.css +++ b/src/app.css @@ -965,6 +965,15 @@ body { border: 0; } +/* visually-hidden: hidden from view but still focusable/clickable for a11y */ +.visually-hidden { + position: absolute; + opacity: 0; + width: 44px; + height: 44px; + overflow: hidden; +} + .scene-dark { background: transparent; } @@ -6709,3 +6718,8 @@ body { font-size: 13px; padding: 20px 0; } + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/app.tsx b/src/app.tsx index 6c846d9..3e4c7e2 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,9 +1,10 @@ -import { MetaProvider, Title } from '@solidjs/meta'; -import { Router } from '@solidjs/router'; -import { FileRoutes } from '@solidjs/start/router'; -import { ErrorBoundary, Suspense } from 'solid-js'; -import { AuthProvider } from '~/lib/auth'; -import './app.css'; +import { MetaProvider, Title } from "@solidjs/meta"; +import { Router } from "@solidjs/router"; +import { FileRoutes } from "@solidjs/start/router"; +import { ErrorBoundary, Suspense } from "solid-js"; +import { AuthProvider } from "~/lib/auth"; +import { AiChatWidget } from "~/components/AiChatWidget"; +import "./app.css"; export default function App() { return ( @@ -14,16 +15,34 @@ export default function App() { ( -
-

Frontend Error

-

A runtime error occurred while rendering this page.

-
+                
+

Frontend Error

+

+ A runtime error occurred while rendering this page. +

+
                     {String((err as any)?.message || err)}
                   
)} > {props.children} + diff --git a/src/components/AiChatWidget.tsx b/src/components/AiChatWidget.tsx new file mode 100644 index 0000000..14e1ae8 --- /dev/null +++ b/src/components/AiChatWidget.tsx @@ -0,0 +1,341 @@ +import { createSignal, Show, For, onMount } from "solid-js"; +import { MessageCircle, X, Send, Bot, User, Loader } from "lucide-solid"; + +const API = "/api/gateway"; + +interface ChatMessage { + role: "user" | "assistant"; + content: string; + intent?: string; +} + +interface ChatResponse { + message: string; + conversation_id: string; + intent: string; + confidence: number; +} + +export function AiChatWidget() { + const [isOpen, setIsOpen] = createSignal(false); + const [messages, setMessages] = createSignal([ + { + role: "assistant", + content: + "Hi! I'm your AI assistant. I can help you create support tickets, fill out forms, generate job descriptions, or write cover letters. What can I help you with?", + }, + ]); + const [input, setInput] = createSignal(""); + const [isLoading, setIsLoading] = createSignal(false); + const [conversationId, setConversationId] = createSignal(""); + + const toggleChat = () => setIsOpen((v) => !v); + + const sendMessage = async () => { + const text = input().trim(); + if (!text || isLoading()) return; + + setIsLoading(true); + const userMessage: ChatMessage = { role: "user", content: text }; + setMessages((prev) => [...prev, userMessage]); + setInput(""); + + try { + const res = await fetch(`${API}/api/ai/chat/message`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: text, + conversation_id: conversationId() || undefined, + }), + }); + + if (!res.ok) throw new Error("AI request failed"); + + const data: ChatResponse = await res.json(); + if (data.conversation_id && !conversationId()) { + setConversationId(data.conversation_id); + } + + const assistantMessage: ChatMessage = { + role: "assistant", + content: data.message, + intent: data.intent, + }; + setMessages((prev) => [...prev, assistantMessage]); + } catch (err) { + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: + "I'm having trouble connecting right now. Please try again or contact support@nxtgauge.com.", + }, + ]); + } finally { + setIsLoading(false); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }; + + return ( + <> + {/* Floating button */} + + + {/* Chat window */} + +
+ {/* Header */} +
+
+ AI Assistant +
+

+ AI Assistant +

+
+
+ +
+ + {/* Quick actions */} +
+ {["Create Ticket", "Job Description", "Cover Letter", "Fill Form"].map((label) => ( + + ))} +
+ + {/* Messages */} +
+ + {(msg) => ( +
+
+ }> + + +
+
+

{msg.content}

+ +

+ Intent: {msg.intent} +

+
+
+
+ )} +
+ +
+ + Thinking... +
+
+
+ + {/* Input */} +
+ setInput(e.currentTarget.value)} + onKeyDown={handleKeyDown} + placeholder="Ask me anything..." + aria-label="Chat message input" + style={{ + flex: 1, + height: "40px", + "border-radius": "20px", + border: "1px solid #E5E7EB", + padding: "0 16px", + "font-size": "13px", + outline: "none", + }} + /> + +
+
+
+ + + + ); +} diff --git a/src/components/CaptchaCanvas.tsx b/src/components/CaptchaCanvas.tsx index b2bd82e..33f054a 100644 --- a/src/components/CaptchaCanvas.tsx +++ b/src/components/CaptchaCanvas.tsx @@ -1,4 +1,4 @@ -import { createEffect } from 'solid-js'; +import { createEffect, onMount } from 'solid-js'; type CaptchaCanvasProps = { code: string; @@ -8,33 +8,40 @@ type CaptchaCanvasProps = { export default function CaptchaCanvas(props: CaptchaCanvasProps) { let canvasRef: HTMLCanvasElement | undefined; - createEffect(() => { + const drawCaptcha = () => { const canvas = canvasRef; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; + // Expose captcha code for automated testing + if (typeof window !== 'undefined') { + window.__captchaCode = props.code; + } + const width = 176; const height = 52; const dpr = typeof window !== 'undefined' ? Math.max(1, window.devicePixelRatio || 1) : 1; - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; + + // Set canvas resolution first (before any drawing) canvas.width = Math.floor(width * dpr); canvas.height = Math.floor(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Clear and fill background - ctx.clearRect(0, 0, width, height); + ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, width, height); + ctx.fillRect(0, 0, canvas.width, canvas.height); // Draw decorative lines for (let i = 0; i < 2; i += 1) { ctx.strokeStyle = i % 2 === 0 ? 'rgba(253,98,22,0.16)' : 'rgba(27,36,64,0.14)'; ctx.lineWidth = 1; ctx.beginPath(); - ctx.moveTo(Math.random() * width, Math.random() * height); - ctx.lineTo(Math.random() * width, Math.random() * height); + ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height); + ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height); ctx.stroke(); } @@ -42,30 +49,40 @@ export default function CaptchaCanvas(props: CaptchaCanvasProps) { for (let i = 0; i < 3; i += 1) { ctx.fillStyle = i % 2 === 0 ? 'rgba(253,98,22,0.10)' : 'rgba(27,36,64,0.09)'; ctx.beginPath(); - ctx.arc(Math.random() * width, Math.random() * height, Math.random() * 1.8 + 0.6, 0, Math.PI * 2); + ctx.arc(Math.random() * canvas.width, Math.random() * canvas.height, Math.random() * 1.8 + 0.6, 0, Math.PI * 2); ctx.fill(); } // Draw characters const chars = String(props.code || '').slice(0, 6).split(''); - const startX = 16; - const charGap = 24; + const startX = 16 * dpr; + const charGap = 24 * dpr; chars.forEach((char, index) => { const x = startX + index * charGap; - const y = height / 2 + 1; + const y = canvas.height / 2; const rotation = 0; ctx.save(); ctx.translate(x, y); ctx.rotate(rotation); ctx.textBaseline = 'middle'; - ctx.font = '800 22px "Courier New", monospace'; + ctx.font = `800 ${22 * dpr}px "Courier New", monospace`; ctx.fillStyle = index % 2 === 0 ? '#0f172a' : '#c2410c'; ctx.lineWidth = 0; ctx.fillText(char, 0, 0); ctx.restore(); }); + }; + + onMount(() => { + drawCaptcha(); + }); + + createEffect(() => { + // Access props.code to track it and redraw when it changes + const _ = props.code; + drawCaptcha(); }); return ( diff --git a/src/components/DashboardLayout.tsx b/src/components/DashboardLayout.tsx index 6838387..72ed834 100644 --- a/src/components/DashboardLayout.tsx +++ b/src/components/DashboardLayout.tsx @@ -1,4 +1,4 @@ -import { type ParentProps, createMemo } from "solid-js"; +import { type ParentProps, createMemo, createSignal, onMount } from "solid-js"; import { useLocation, useNavigate } from "@solidjs/router"; import DashboardShell from "~/components/DashboardShell"; @@ -37,6 +37,8 @@ function readUserName() { export default function DashboardLayout(props: ParentProps) { const location = useLocation(); const navigate = useNavigate(); + const [roleKey, setRoleKey] = createSignal("DEVELOPER"); + const [userName, setUserName] = createSignal("User"); const activeSidebar = createMemo(() => { const path = location.pathname || ""; @@ -52,13 +54,77 @@ export default function DashboardLayout(props: ParentProps) { if (target) navigate(target); }; + onMount(async () => { + if (typeof window === "undefined") return; + + const fromUrl = new URLSearchParams(window.location.search).get("role"); + if (fromUrl && fromUrl.trim()) { + setRoleKey(fromUrl.trim().toUpperCase()); + return; + } + + const storageKeys = [ + ["nxtgauge_signup_profile_v1", localStorage], + ["nxtgauge_auth_user", localStorage], + ["nxtgauge_user", localStorage], + ["nxtgauge_signup_profile_v1", sessionStorage], + ["nxtgauge_auth_user", sessionStorage], + ["nxtgauge_user", sessionStorage], + ]; + + for (const [key, storage] of storageKeys) { + try { + const raw = storage.getItem(key); + if (raw) { + const parsed = JSON.parse(raw); + const candidate = String( + parsed?.selectedProfessionalRole || parsed?.active_role || parsed?.roleKey || parsed?.role || "" + ) + .trim() + .toUpperCase(); + if (candidate && candidate !== "PROFESSIONAL") { + setRoleKey(candidate); + if (parsed?.full_name || parsed?.name || parsed?.email) { + setUserName(parsed.full_name || parsed.name || parsed.email || "User"); + } + return; + } + } + } catch { + // continue + } + } + + const token = sessionStorage.getItem("nxtgauge_access_token"); + if (token) { + try { + const res = await fetch("/api/auth/session", { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + credentials: "include", + }); + if (res.ok) { + const data = await res.json(); + const role = String(data?.active_role || data?.role || "").trim().toUpperCase(); + setRoleKey(role && role !== "PROFESSIONAL" ? role : "DEVELOPER"); + setUserName(data?.full_name || data?.name || data?.email || "User"); + return; + } + } catch { + // fall through + } + } + }); + return ( {props.children} diff --git a/src/components/DashboardShell.tsx b/src/components/DashboardShell.tsx index 2906c3c..4d37322 100644 --- a/src/components/DashboardShell.tsx +++ b/src/components/DashboardShell.tsx @@ -3,48 +3,77 @@ * Used for pages that need actual backend connectivity * (My Profile, My Portfolio, Verification) instead of the preview mock. */ -import { For, JSX, Show, createMemo, createSignal, onMount } from 'solid-js'; +import { For, JSX, createMemo } from "solid-js"; +import { AiChatWidget } from "./AiChatWidget"; +import NotificationBell from "./NotificationBell"; import { - User, Briefcase, LayoutDashboard, FolderOpen, MapPin, Star, - CreditCard, Globe, ShieldCheck, HelpCircle, Settings, - RefreshCw, LogOut, Bell, ChevronRight, -} from 'lucide-solid'; - -// ── Icon map (matches DashboardDesignPreview sidebar keys) ──────────────────── + User, + Briefcase, + LayoutDashboard, + FolderOpen, + MapPin, + Star, + CreditCard, + Globe, + ShieldCheck, + HelpCircle, + Settings, + RefreshCw, + LogOut, + Bell, + ChevronRight, +} from "lucide-solid"; const ICON_MAP: Record = { - 'my dashboard': LayoutDashboard, - 'my profile': User, - 'my portfolio': FolderOpen, - 'leads': MapPin, - 'my responses': Star, - 'credits': CreditCard, - 'explore nxtgauge': Globe, - 'verification': ShieldCheck, - 'help center': HelpCircle, - 'settings': Settings, - 'switch services': RefreshCw, - 'jobs': Briefcase, - 'applications': Briefcase, - 'shortlisted candidates': User, - 'my applications': FolderOpen, - 'saved jobs': Star, - 'my requirements': FolderOpen, - 'received responses': Bell, - 'shortlisted responses': Star, - 'logout': LogOut, + my_dashboard: LayoutDashboard, + my_profile: User, + my_portfolio: FolderOpen, + portfolio: FolderOpen, + leads: MapPin, + my_responses: Star, + responses: Star, + credits: CreditCard, + explore_nxtgauge: Globe, + explore: Globe, + verification: ShieldCheck, + verification_status: ShieldCheck, + help_center: HelpCircle, + help: HelpCircle, + support: HelpCircle, + settings: Settings, + switch_services: RefreshCw, + switch_service: RefreshCw, + logout: LogOut, + jobs: Briefcase, + job_postings: Briefcase, + applications: Briefcase, + my_applications: FolderOpen, + shortlisted_candidates: User, + my_requirements: FolderOpen, + requirements: FolderOpen, + received_responses: Bell, + shortlisted_responses: Star, + saved_jobs: Star, }; +function normalizeSidebarIconKey(value: string): string { + return String(value || "") + .trim() + .toLowerCase() + .replace(/\s+/g, "_") + .replace(/-/g, "_"); +} + function SidebarIcon(props: { label: string }) { - const key = props.label.toLowerCase(); + const key = normalizeSidebarIconKey(props.label); const Icon = ICON_MAP[key] || ChevronRight; return ; } function titleCase(value: string) { - return String(value || '') + return String(value || "") .toLowerCase() - .replace(/_/g, ' ') + .replace(/_/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); } @@ -61,107 +90,107 @@ interface Props { // ── Brand colours ───────────────────────────────────────────────────────────── -const ORANGE = '#FF5E13'; -const NAVY = '#0D0D2A'; +export const ORANGE = "#FF5E13"; +export const NAVY = "#0D0D2A"; +const DARK_INK = "#03004E"; // ── Component ───────────────────────────────────────────────────────────────── export default function DashboardShell(props: Props) { const roleLabel = createMemo(() => { - const k = String(props.roleKey || '').replace(/_/g, ' '); + const k = String(props.roleKey || "").replace(/_/g, " "); return k.charAt(0).toUpperCase() + k.slice(1).toLowerCase(); }); - const [unreadCount, setUnreadCount] = createSignal(0); - - // Fetch unread notification count - const fetchUnreadCount = async () => { - try { - const token = typeof window !== 'undefined' ? window.sessionStorage.getItem('nxtgauge_access_token') || '' : ''; - if (!token) return; - const res = await fetch('/api/me/notifications/unread-count', { - headers: { Authorization: `Bearer ${token}` }, - credentials: 'include', - }); - if (res.ok) { - const data = await res.json(); - setUnreadCount(data.unread_count || 0); - } - } catch (e) { - console.error('Failed to fetch unread count:', e); - } - }; - - // Start polling on mount - onMount(() => { - fetchUnreadCount(); - const interval = setInterval(fetchUnreadCount, 30000); - return () => clearInterval(interval); - }); - return ( -
- +
{/* ── Sidebar ──────────────────────────────────────────────────────── */} -
@@ -3680,7 +3841,7 @@ export default function DashboardDesignPreview(props: {
- + {['Lead ID', 'Lead Title', 'Request Date', 'Request Status', 'Cost', 'Decision Date', 'Action'].map((h) => ( @@ -3705,7 +3866,7 @@ export default function DashboardDesignPreview(props: { @@ -5190,7 +5351,7 @@ export default function DashboardDesignPreview(props: { - +
@@ -5203,7 +5364,7 @@ export default function DashboardDesignPreview(props: {
{h}
- + @@ -3813,7 +3974,7 @@ export default function DashboardDesignPreview(props: {
Contacted{lead.contactCount}/{lead.maxContacts}
- +
@@ -3885,7 +4046,7 @@ export default function DashboardDesignPreview(props: {
- +
@@ -3964,7 +4125,7 @@ export default function DashboardDesignPreview(props: { type="button" onClick={() => openLeadContactConfirm(lead.id)} disabled={usableLeadCredits() < leadCostPerContact || lead.contactCount >= lead.maxContacts} - style={`height:30px;border-radius:8px;border:none;padding:0 10px;font-size:12px;font-weight:700;color:white;background:${usableLeadCredits() < leadCostPerContact || lead.contactCount >= lead.maxContacts ? '#9CA3AF' : '#03004E'};cursor:${usableLeadCredits() < leadCostPerContact || lead.contactCount >= lead.maxContacts ? 'not-allowed' : 'pointer'}`} + style={`height:30px;border-radius:8px;border:none;padding:0 10px;font-size:12px;font-weight:700;color:white;background:${usableLeadCredits() < leadCostPerContact || lead.contactCount >= lead.maxContacts ? '#9CA3AF' : '#0D0D2A'};cursor:${usableLeadCredits() < leadCostPerContact || lead.contactCount >= lead.maxContacts ? 'not-allowed' : 'pointer'}`} > Request Contact @@ -4008,7 +4169,7 @@ export default function DashboardDesignPreview(props: {

You are about to spend 25 Tracecoins to request and view this service seeker contact when approved. Do you want to continue?

- +
@@ -4042,7 +4203,7 @@ export default function DashboardDesignPreview(props: { { label: 'Shortlisted', value: '03', tone: 'orange' }, { label: 'Interviews', value: '02', tone: 'green' }, ].map((card) => ( -
+

{card.label}

{card.value}

@@ -4093,7 +4254,7 @@ export default function DashboardDesignPreview(props: {

Boost your response rate

Recruiters are more likely to respond to profiles with updated resume and portfolio links.

- + ); @@ -4103,7 +4264,7 @@ export default function DashboardDesignPreview(props: { return (
-
-
+

Total Compensation

{selectedJob().salary} / year

@@ -4281,7 +4442,7 @@ export default function DashboardDesignPreview(props: { { label: 'Shortlisted', value: '06', accent: '#0EA5E9', hint: '' }, { label: 'New Today', value: '18', accent: 'white', hint: 'dark' }, ].map((card) => ( -
+

{card.label}

{card.value}

@@ -4335,7 +4496,7 @@ export default function DashboardDesignPreview(props: { setJobSeekerApplyStep(2); setJobSeekerScreen('apply'); }} - style="height:32px;border-radius:8px;border:none;background:#03004E;padding:0 12px;font-size:12px;font-weight:700;color:white" + style="height:32px;border-radius:8px;border:none;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:700;color:white" > Apply Now @@ -4428,7 +4589,7 @@ export default function DashboardDesignPreview(props: {
-
+

Did you know?

Jobs with high-quality company descriptions receive 40% more applications. Take a moment to update your profile.

@@ -4511,7 +4672,7 @@ export default function DashboardDesignPreview(props: {
-
+

Pricing & Approval

Review costs and confirm submission

@@ -4530,7 +4691,7 @@ export default function DashboardDesignPreview(props: {

Approval Required: Yes

Your post will be reviewed by our moderation team within 24 hours.

- +
@@ -4593,7 +4754,7 @@ export default function DashboardDesignPreview(props: {
-
@@ -4867,7 +5028,7 @@ export default function DashboardDesignPreview(props: {
- +
@@ -4984,7 +5145,7 @@ export default function DashboardDesignPreview(props: { @@ -5058,7 +5219,7 @@ export default function DashboardDesignPreview(props: { > View - +
- + {['Lead ID', 'Lead Title', 'Request Date', 'Status', 'Cost', 'Decision Date', 'Action'].map((h) => ( @@ -5294,7 +5455,7 @@ export default function DashboardDesignPreview(props: { @@ -5419,7 +5580,7 @@ export default function DashboardDesignPreview(props: { type="button" onClick={() => void applyCoupon()} disabled={couponLoading()} - style="height:32px;border:none;border-radius:6px;background:#03004E;color:white;padding:0 12px;font-size:12px;font-weight:700;cursor:pointer;opacity:${couponLoading() ? 0.6 : 1}" + style="height:32px;border:none;border-radius:6px;background:#0D0D2A;color:white;padding:0 12px;font-size:12px;font-weight:700;cursor:pointer;opacity:${couponLoading() ? 0.6 : 1}" > {couponLoading() ? 'Applying...' : 'Apply'} @@ -5448,12 +5609,12 @@ export default function DashboardDesignPreview(props: {
-

Secure Gateway

+

Secure Gateway

Continue to our secure partner gateway to complete the transaction.

@@ -5479,7 +5640,7 @@ export default function DashboardDesignPreview(props: { @@ -5562,7 +5723,7 @@ export default function DashboardDesignPreview(props: { if (amtInput) amtInput.value = ''; if (rsnInput) rsnInput.value = ''; }} - style="height:38px;border:none;border-radius:8px;background:#03004E;color:white;font-size:13px;font-weight:700;cursor:pointer" + style="height:38px;border:none;border-radius:8px;background:#0D0D2A;color:white;font-size:13px;font-weight:700;cursor:pointer" > Apply Adjustment @@ -5593,7 +5754,7 @@ export default function DashboardDesignPreview(props: { > Manage Credits - + @@ -5618,7 +5779,7 @@ export default function DashboardDesignPreview(props: { @@ -5646,7 +5807,7 @@ export default function DashboardDesignPreview(props: { -
+

Recommended

Start with Standard

Best value for active buying and response unlocks.

@@ -5679,7 +5840,7 @@ export default function DashboardDesignPreview(props: {
{h}
- + {['Invoice No', 'Package', 'Credits', 'Amount Paid', 'Status', 'Date', 'Actions'].map((h) => ( @@ -5694,7 +5855,7 @@ export default function DashboardDesignPreview(props: {
{h}{row[2]} {row[3]} - {row[4]} + {row[4]} {row[5]} @@ -5747,7 +5908,7 @@ export default function DashboardDesignPreview(props: { - + {['Usage ID', 'Action Type', 'Credits Used', 'Related ID', 'Date', 'Remarks'].map((h) => ( @@ -5764,7 +5925,7 @@ export default function DashboardDesignPreview(props: { - + @@ -5776,7 +5937,7 @@ export default function DashboardDesignPreview(props: {

Showing 1 to 4 of 142 results

{[1, 2, 3].map((p) => ( - + ))}
@@ -5806,7 +5967,7 @@ export default function DashboardDesignPreview(props: {
{h}
{row[0]} {row[1]}{row[2]}{row[2]} {row[3]} {row[4]} {row[5]}
- + {['Invoice Number', 'Billing Date', 'Package', 'Total', 'Status'].map((h) => ( @@ -5831,7 +5992,7 @@ export default function DashboardDesignPreview(props: {

Showing 1 to 4 of 24 invoices

{[1, 2, 3].map((p) => ( - + ))}
@@ -5864,13 +6025,13 @@ export default function DashboardDesignPreview(props: {

Pending Invoices

1

- +

Recent Transactions

- +
@@ -5890,7 +6051,7 @@ export default function DashboardDesignPreview(props: {
{h}
- + {['Transaction ID', 'Package', 'Credits', 'Amount Paid', 'Status', 'Date'].map((h) => ( @@ -5905,7 +6066,7 @@ export default function DashboardDesignPreview(props: { @@ -5942,7 +6103,7 @@ export default function DashboardDesignPreview(props: { @@ -5972,7 +6133,7 @@ export default function DashboardDesignPreview(props: { @@ -6041,7 +6202,7 @@ export default function DashboardDesignPreview(props: {

Finish your basic information and required documents, then submit.

- +
@@ -6049,7 +6210,7 @@ export default function DashboardDesignPreview(props: {

Add portfolio details and submit separately for review.

- +
@@ -6097,7 +6258,7 @@ export default function DashboardDesignPreview(props: {

Approved

-

2

+

2

Needs Action

@@ -6110,13 +6271,13 @@ export default function DashboardDesignPreview(props: {

Admin requested missing documents

Required Missing Documents: Address Proof (clear PDF/JPG/PNG).

- +

Documents

- +
{h}{row[2]} {row[3]} - {row[4]} + {row[4]} {row[5]}
@@ -6137,7 +6298,7 @@ export default function DashboardDesignPreview(props: {
{doc} {file} - {state} + {state}
- +
@@ -6195,7 +6356,7 @@ export default function DashboardDesignPreview(props: {

{title}

{time}

- {state} + {state} ))} @@ -6559,7 +6720,7 @@ export default function DashboardDesignPreview(props: { type="button" disabled={isCurrent} onClick={() => switchRole(roleKey)} - style={`margin-top:auto;height:32px;border-radius:8px;border:none;background:${isCurrent ? '#E5E7EB' : '#03004E'};color:${isCurrent ? '#4B5563' : 'white'};padding:0 10px;font-size:12px;font-weight:700;cursor:${isCurrent ? 'default' : 'pointer'}`} + style={`margin-top:auto;height:32px;border-radius:8px;border:none;background:${isCurrent ? '#E5E7EB' : '#0D0D2A'};color:${isCurrent ? '#4B5563' : 'white'};padding:0 10px;font-size:12px;font-weight:700;cursor:${isCurrent ? 'default' : 'pointer'}`} > {isCurrent ? 'Active' : 'Switch Role'} @@ -6716,71 +6877,80 @@ export default function DashboardDesignPreview(props: {
-
-

Current View

-

{isCustomerExternalMode() ? customerView().title : titleCase(props.activeSidebar)}

-

{isCustomerExternalMode() ? customerView().subtitle : 'Interactive preview for configured dashboard.'}

- 0 && customerKey() !== 'my portfolio'}> - +
+
+ {(() => { + const IconFn = sidebarIcon(props.activeSidebar || 'dashboard'); + return IconFn && ; + })()} +
+

{isCustomerExternalMode() ? customerView().title : titleCase(props.activeSidebar)}

+

{isCustomerExternalMode() ? customerView().subtitle : 'Interactive preview for configured dashboard.'}

+
+
+
+ 0 && customerKey() !== 'my portfolio'}> + + + {(item) => ( + (() => { + const itemKey = normalizeTabKey(item); + const isLockedTestimonialsTab = customerKey() === 'my portfolio' && itemKey === 'testimonials' && !portfolioTestimonialsUnlocked(); + return ( + + ); + })() + )} + +
+ } + > +
- {(item) => ( - (() => { - const itemKey = normalizeTabKey(item); - const isLockedTestimonialsTab = customerKey() === 'my portfolio' && itemKey === 'testimonials' && !portfolioTestimonialsUnlocked(); - return ( - - ); - })() - )} + {(item) => { + const itemKey = normalizeTabKey(item); + const isLockedTestimonialsTab = itemKey === 'testimonials' && !portfolioTestimonialsUnlocked(); + const isActive = resolvedTabKey() === itemKey; + const Icon = portfolioTabIcon(item); + return ( + + ); + }}
- } - > -
- - {(item) => { - const itemKey = normalizeTabKey(item); - const isLockedTestimonialsTab = itemKey === 'testimonials' && !portfolioTestimonialsUnlocked(); - const isActive = resolvedTabKey() === itemKey; - const Icon = portfolioTabIcon(item); - return ( - - ); - }} - -
+ - +
diff --git a/src/components/dashboard/AdminDashboardPage.tsx b/src/components/dashboard/AdminDashboardPage.tsx new file mode 100644 index 0000000..d65f8a0 --- /dev/null +++ b/src/components/dashboard/AdminDashboardPage.tsx @@ -0,0 +1,228 @@ +import { For, Show, createMemo, createSignal, onMount } from 'solid-js'; +import { CARD, BTN_PRIMARY, BTN_GHOST } from '~/components/DashboardShell'; + +const API = '/api/gateway'; + +type AdminMetrics = { + totalUsers: number; + pendingVerifications: number; + activeSessions: number; + totalRoles: number; + totalPhotographers: number; + totalCustomers: number; + totalCompanies: number; + totalJobSeekers: number; +}; + +async function adminFetch(path: string, opts?: RequestInit) { + const token = + typeof window !== 'undefined' + ? window.sessionStorage.getItem('nxtgauge_access_token') || '' + : ''; + const cleanPath = path.startsWith('/api/') ? path.slice(4) : path; + return fetch(`${API}${cleanPath}`, { + ...opts, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(opts?.headers ?? {}), + }, + }); +} + +export default function AdminDashboardPage() { + const [metrics, setMetrics] = createSignal({ + totalUsers: 0, + pendingVerifications: 0, + activeSessions: 0, + totalRoles: 0, + totalPhotographers: 0, + totalCustomers: 0, + totalCompanies: 0, + totalJobSeekers: 0, + }); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(''); + + const loadAdminMetrics = async () => { + setLoading(true); + setError(''); + try { + const [usersRes, rolesRes, photographersRes, customersRes, companiesRes, jobSeekersRes] = + await Promise.all([ + adminFetch('/api/admin/users?page=1&limit=1'), + adminFetch('/api/admin/roles'), + adminFetch('/api/admin/users?role=photographer&page=1&limit=1'), + adminFetch('/api/admin/users?role=customer&page=1&limit=1'), + adminFetch('/api/admin/users?role=company&page=1&limit=1'), + adminFetch('/api/admin/users?role=jobseeker&page=1&limit=1'), + ]); + + const usersJson = await usersRes.json().catch(() => ({})); + const rolesJson = await rolesRes.json().catch(() => ({})); + const photographersJson = await photographersRes.json().catch(() => ({})); + const customersJson = await customersRes.json().catch(() => ({})); + const companiesJson = await companiesRes.json().catch(() => ({})); + const jobSeekersJson = await jobSeekersRes.json().catch(() => ({})); + + setMetrics({ + totalUsers: usersJson?.total ?? usersJson?.count ?? 0, + pendingVerifications: 0, + activeSessions: 0, + totalRoles: Array.isArray(rolesJson) ? rolesJson.length : 0, + totalPhotographers: photographersJson?.total ?? photographersJson?.count ?? 0, + totalCustomers: customersJson?.total ?? customersJson?.count ?? 0, + totalCompanies: companiesJson?.total ?? companiesJson?.count ?? 0, + totalJobSeekers: jobSeekersJson?.total ?? jobSeekersJson?.count ?? 0, + }); + } catch (e: any) { + setError('Failed to load admin metrics: ' + e.message); + } finally { + setLoading(false); + } + }; + + onMount(loadAdminMetrics); + + const statCards = createMemo(() => [ + { label: 'Total Users', value: metrics().totalUsers, color: '#0D0D2A' }, + { label: 'Photographers', value: metrics().totalPhotographers, color: '#FF5E13' }, + { label: 'Customers', value: metrics().totalCustomers, color: '#059669' }, + { label: 'Companies', value: metrics().totalCompanies, color: '#7C3AED' }, + { label: 'Job Seekers', value: metrics().totalJobSeekers, color: '#DC2626' }, + { label: 'Pending Verifications', value: metrics().pendingVerifications, color: '#D97706' }, + ]); + + return ( +
+
+

+ Admin Dashboard +

+

+ Platform overview and management metrics. +

+
+ + +
+ {error()} +
+
+ + +
+ Loading admin metrics... +
+
+ + +
+ + {(stat) => ( +
+

+ {stat.label} +

+

+ {stat.value} +

+
+ )} +
+
+ +
+

+ Quick Actions +

+
+ + + + +
+
+
+
+ ); +} diff --git a/src/components/dashboard/CompanyApplicationsPage.tsx b/src/components/dashboard/CompanyApplicationsPage.tsx index cdf2a96..9a58cb3 100644 --- a/src/components/dashboard/CompanyApplicationsPage.tsx +++ b/src/components/dashboard/CompanyApplicationsPage.tsx @@ -38,10 +38,19 @@ interface ContactInfo { } async function apiFetch(path: string, opts?: RequestInit) { - return fetch(`${API}${path}`, { + const token = + typeof window !== "undefined" + ? window.sessionStorage.getItem("nxtgauge_access_token") || "" + : ""; + const cleanPath = path.startsWith("/api/") ? path.slice(4) : path; + return fetch(`${API}${cleanPath}`, { ...opts, credentials: "include", - headers: { "Content-Type": "application/json", ...(opts?.headers ?? {}) }, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(opts?.headers ?? {}), + }, }); } @@ -166,7 +175,7 @@ export default function CompanyApplicationsPage() { }} >
-

+

Applications

diff --git a/src/components/dashboard/CompanyJobsPage.tsx b/src/components/dashboard/CompanyJobsPage.tsx index 14dbb99..7c8dbf4 100644 --- a/src/components/dashboard/CompanyJobsPage.tsx +++ b/src/components/dashboard/CompanyJobsPage.tsx @@ -5,11 +5,13 @@ * POST /api/companies/jobs - Create new job * PATCH /api/companies/jobs/:id - Update job * DELETE /api/companies/jobs/:id - Delete job + * POST /api/ai/generate-job-field - AI job field generation + * GET /api/ai/usage - AI usage status */ -import { For, Show, createSignal, onMount } from "solid-js"; +import { For, Show, createMemo, createSignal, onMount } from "solid-js"; +import { Sparkles, Loader } from "lucide-solid"; import { BTN_GHOST, - BTN_ORANGE, BTN_PRIMARY, CARD, INPUT, @@ -45,6 +47,8 @@ interface JobFormState { skills: string; } +type SortKey = "newest" | "salary_desc" | "salary_asc" | "title_asc"; + const EMPTY_FORM: JobFormState = { title: "", category: "", @@ -58,10 +62,18 @@ const EMPTY_FORM: JobFormState = { }; async function apiFetch(path: string, opts?: RequestInit) { + const token = + typeof window !== "undefined" + ? window.sessionStorage.getItem("nxtgauge_access_token") || "" + : ""; return fetch(`${API}${path}`, { ...opts, credentials: "include", - headers: { "Content-Type": "application/json", ...(opts?.headers ?? {}) }, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(opts?.headers ?? {}), + }, }); } @@ -74,6 +86,62 @@ export default function CompanyJobsPage() { const [error, setError] = createSignal(""); const [actionMsg, setActionMsg] = createSignal(""); const [busyJobId, setBusyJobId] = createSignal(null); + const [search, setSearch] = createSignal(""); + const [sortBy, setSortBy] = createSignal("newest"); + const [activeTag, setActiveTag] = createSignal(""); + const [aiRemaining, setAiRemaining] = createSignal(5); + const [aiLimit, setAiLimit] = createSignal(5); + const [hasAiPack, setHasAiPack] = createSignal(false); + const [genTitle, setGenTitle] = createSignal(false); + const [genDesc, setGenDesc] = createSignal(false); + const [genSkills, setGenSkills] = createSignal(false); + const [genCategory, setGenCategory] = createSignal(false); + + const rowTags = (row: JobItem) => { + const tags = new Set(); + if (row.category) tags.add(String(row.category)); + if (Array.isArray(row.skills)) { + for (const skill of row.skills) { + const val = String(skill || "").trim(); + if (val) tags.add(val); + } + } + return Array.from(tags); + }; + + const availableTags = createMemo(() => { + const tags = new Set(); + for (const row of jobs()) { + for (const tag of rowTags(row)) tags.add(tag); + } + return Array.from(tags).sort((a, b) => a.localeCompare(b)); + }); + + const filteredSortedJobs = createMemo(() => { + const q = search().trim().toLowerCase(); + const tag = activeTag().trim().toLowerCase(); + const next = jobs().filter((row) => { + const tags = rowTags(row); + const matchesTag = !tag || tags.some((t) => t.toLowerCase() === tag); + if (!matchesTag) return false; + if (!q) return true; + return ( + String(row.title || "").toLowerCase().includes(q) || + String(row.location || "").toLowerCase().includes(q) || + String(row.description || "").toLowerCase().includes(q) || + String(row.job_type || "").toLowerCase().includes(q) || + tags.some((t) => t.toLowerCase().includes(q)) + ); + }); + + next.sort((a, b) => { + if (sortBy() === "salary_desc") return Number(b.salary_max || b.salary_min || 0) - Number(a.salary_max || a.salary_min || 0); + if (sortBy() === "salary_asc") return Number(a.salary_min || a.salary_max || 0) - Number(b.salary_min || b.salary_max || 0); + if (sortBy() === "title_asc") return String(a.title || "").localeCompare(String(b.title || "")); + return new Date(String(b.created_at || 0)).getTime() - new Date(String(a.created_at || 0)).getTime(); + }); + return next; + }); const loadJobs = async () => { setLoading(true); @@ -92,6 +160,68 @@ export default function CompanyJobsPage() { onMount(loadJobs); + const loadAiUsage = async () => { + const token = window.sessionStorage.getItem("nxtgauge_access_token") || ""; + const res = await fetch(`${API}/api/ai/usage`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + setAiRemaining(data.remaining_today ?? 0); + setAiLimit(data.daily_limit ?? 5); + setHasAiPack(data.has_ai_pack ?? false); + } + }; + + onMount(loadAiUsage); + + const generateField = async (field: "title" | "description" | "skills" | "category") => { + if (aiRemaining() <= 0) return; + const setters: Record void> = { + title: setGenTitle, + description: setGenDesc, + skills: setGenSkills, + category: setGenCategory, + }; + setters[field](true); + + const context = form().title || form().description || "job posting"; + + const token = window.sessionStorage.getItem("nxtgauge_access_token") || ""; + try { + const res = await fetch(`${API}/api/ai/generate-job-field`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ field, context }), + }); + + if (res.status === 429) { + setError("Daily AI generation limit reached. Upgrade to AI Pack for more."); + return; + } + + const data = await res.json(); + if (res.ok && data.generated_text) { + if (field === "title") setField("title", data.generated_text.substring(0, 100)); + else if (field === "description") setField("description", data.generated_text); + else if (field === "skills") setField("skills", data.generated_text); + else if (field === "category") setField("category", data.generated_text.substring(0, 60)); + setAiRemaining(data.remaining_today ?? aiRemaining() - 1); + setAiLimit(data.daily_limit ?? aiLimit()); + setHasAiPack(data.has_ai_pack ?? hasAiPack()); + } else { + setError(data.error || "Generation failed"); + } + } catch { + setError("Network error during generation"); + } finally { + setters[field](false); + } + }; + const setField = (key: keyof JobFormState, val: string) => setForm((prev) => ({ ...prev, [key]: val })); @@ -217,14 +347,14 @@ export default function CompanyJobsPage() { }} >

-

+

Jobs

Create and manage your job postings.

-
@@ -256,8 +386,42 @@ export default function CompanyJobsPage() { New Job

+ +
+ + {aiRemaining()} AI generations left today + + ({aiLimit()} base limit) + + + AI Pack active + +
+
- +
+ + +
setField("title", e.currentTarget.value)} @@ -266,7 +430,29 @@ export default function CompanyJobsPage() { />
- +
+ + +
setField("category", e.currentTarget.value)} @@ -296,7 +482,29 @@ export default function CompanyJobsPage() { />
- +
+ + +