Compare commits
141 Commits
5fc7b33b72
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c08e90a56 | |||
| 719f4c0724 | |||
| 1a63533333 | |||
| bf579b93e6 | |||
| f1cb360e4f | |||
| 9d99e6ee0a | |||
| f36d55f814 | |||
| 93bd23c1cd | |||
| 54fcfd80cb | |||
| 7415d0b696 | |||
| 9205536bf2 | |||
| 4ec86789cf | |||
| fd2625a34d | |||
| f17206b2f4 | |||
| 85c5c6e39d | |||
| 09fe679f9b | |||
| 6a0e0c159c | |||
| faa1d61923 | |||
| d170d6f729 | |||
| 13334f6cdd | |||
| 436e3743ed | |||
| 86e0057adc | |||
| 380c0313d9 | |||
| ddb1a26d47 | |||
| 5f3856fce6 | |||
| 84964ccb84 | |||
| f9e6025dc4 | |||
| 3d25d87415 | |||
| 6dca12dc5a | |||
| 622aabf384 | |||
| bbf6afe2f6 | |||
| 3bdb005efb | |||
| 5df7d5cb96 | |||
| 4995101dd1 | |||
| c5a9bd83d5 | |||
| fca54a93e7 | |||
| 85b1be691a | |||
| 618eadb433 | |||
| 77e3e9dc1f | |||
| a7e1ceaca0 | |||
| b0b1930b7e | |||
| 9a576f5a90 | |||
| 9302c279df | |||
| 34d242c614 | |||
| b58b45f412 | |||
| 0729c2426a | |||
| c35548c9d4 | |||
| 06976674d3 | |||
| 2011aae6fa | |||
| 93a18820c2 | |||
| 8dc6f85fb8 | |||
| 108b0ae775 | |||
| 5d81f8d71e | |||
| c822517694 | |||
| aeafb7257e | |||
| 186563ffba | |||
| eb2148080a | |||
| 433b0d6ebd | |||
| 81eeceb856 | |||
| fc40a4784d | |||
| dae4da3f38 | |||
| 800379b581 | |||
| dad97f951e | |||
| 86bfcc0e36 | |||
| 42e09b46ab | |||
| 8d91caefae | |||
| 202e615e2b | |||
| c41f870040 | |||
| c85f5773fa | |||
| 9bdb673220 | |||
| 1d0bdb92ca | |||
| 5d382e88a9 | |||
| 29d0760856 | |||
| 52ee772391 | |||
| f807f2d2bc | |||
| 6001676058 | |||
| a70439c51a | |||
| 871ee3f80e | |||
| 460074d01f | |||
| 4c08c92780 | |||
| 87310f4fa2 | |||
| 2dbd03db6d | |||
| b31a7f6442 | |||
| f4c355b2cf | |||
| 7c2d7d0a0e | |||
| f711ac23b9 | |||
| f9e1da9ba0 | |||
| c140d16198 | |||
| 8c499ddccd | |||
| 5a1fcb30dd | |||
| c23d3c4b6c | |||
| 129716ad1d | |||
| 0618d8517d | |||
| e135c3258b | |||
| e75c9bc9ef | |||
| 31a8d62b38 | |||
| 442c1db580 | |||
| b35d5e0ba0 | |||
| aab2a0c3ad | |||
| fed5b15378 | |||
| 01d6d24e59 | |||
| 8f6e13ffcf | |||
| 5ffe7092ff | |||
| b8afdc812f | |||
| fcd36a0a29 | |||
| eaa8ad1511 | |||
| c832939d5b | |||
| 7822cd094f | |||
| bd3046b782 | |||
| 19cd61f3d3 | |||
| a9da2fc265 | |||
| aad1a54dfd | |||
| 815f08dac0 | |||
| dd8ce18e93 | |||
| fc708016e2 | |||
| 05e9f59ccf | |||
| 9f48c401e9 | |||
| e8d23e8a49 | |||
| 52770986cd | |||
| d7eb86b0c0 | |||
| b30b8e1ec0 | |||
| 22eeb85765 | |||
| d96668424f | |||
| 7885884e45 | |||
| 2553c7d4c4 | |||
| 90ec2648fc | |||
| 6e9c5a1394 | |||
| 7f3846934c | |||
| 4d90b75717 | |||
| 891d17362e | |||
| 1ceb6bf5a8 | |||
| ac9ad41d86 | |||
| ab27cb1295 | |||
| 7594a723c6 | |||
| 006407ca1d | |||
| 9656d8d028 | |||
| 5f5509b7f8 | |||
| 51b7727ed4 | |||
| e95fcd5bae | |||
| 2b0c3aacf8 | |||
| 74b8a12742 |
159
.gitea/workflows/deploy.yml
Normal file
159
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,159 @@
|
||||
name: Deploy Pounce (Auto)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install deploy tooling
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends openssh-client rsync ca-certificates
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Sync repository to server
|
||||
run: |
|
||||
rsync -az --delete \
|
||||
-e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes" \
|
||||
--exclude ".git" \
|
||||
--exclude ".venv" \
|
||||
--exclude "venv" \
|
||||
--exclude "backend/.venv" \
|
||||
--exclude "backend/venv" \
|
||||
--exclude "frontend/node_modules" \
|
||||
--exclude "frontend/.next" \
|
||||
--exclude "**/__pycache__" \
|
||||
--exclude "**/*.pyc" \
|
||||
./ \
|
||||
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/"
|
||||
|
||||
- name: Generate backend env file (from secrets)
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
||||
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
GH_OAUTH_SECRET: ${{ secrets.GH_OAUTH_SECRET }}
|
||||
CZDS_USERNAME: ${{ secrets.CZDS_USERNAME }}
|
||||
CZDS_PASSWORD: ${{ secrets.CZDS_PASSWORD }}
|
||||
SWITCH_TSIG_CH_SECRET: ${{ secrets.SWITCH_TSIG_CH_SECRET }}
|
||||
SWITCH_TSIG_LI_SECRET: ${{ secrets.SWITCH_TSIG_LI_SECRET }}
|
||||
LLM_GATEWAY_URL: ${{ secrets.LLM_GATEWAY_URL }}
|
||||
LLM_GATEWAY_API_KEY: ${{ secrets.LLM_GATEWAY_API_KEY }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
env = {
|
||||
# Core
|
||||
"ENVIRONMENT": "production",
|
||||
# Scheduler will run in separate container (pounce-scheduler)
|
||||
"ENABLE_SCHEDULER": "false",
|
||||
"DEBUG": "false",
|
||||
"COOKIE_SECURE": "true",
|
||||
"CORS_ORIGINS": "https://pounce.ch,https://www.pounce.ch",
|
||||
"SITE_URL": "https://pounce.ch",
|
||||
"FRONTEND_URL": "https://pounce.ch",
|
||||
|
||||
# Data dirs
|
||||
"CZDS_DATA_DIR": "/data/czds",
|
||||
"SWITCH_DATA_DIR": "/data/switch",
|
||||
"ZONE_RETENTION_DAYS": "3",
|
||||
|
||||
# DB/Redis
|
||||
"DATABASE_URL": os.environ["DATABASE_URL"],
|
||||
"REDIS_URL": "redis://pounce-redis:6379/0",
|
||||
# Rate limiting must be shared across workers in production
|
||||
"RATE_LIMIT_STORAGE_URI": "redis://pounce-redis:6379/2",
|
||||
|
||||
# Auth
|
||||
"SECRET_KEY": os.environ["SECRET_KEY"],
|
||||
"JWT_SECRET": os.environ["SECRET_KEY"],
|
||||
|
||||
# SMTP
|
||||
"SMTP_HOST": "smtp.zoho.eu",
|
||||
"SMTP_PORT": "465",
|
||||
"SMTP_USER": "hello@pounce.ch",
|
||||
"SMTP_PASSWORD": os.environ["SMTP_PASSWORD"],
|
||||
"SMTP_FROM_EMAIL": "hello@pounce.ch",
|
||||
"SMTP_FROM_NAME": "pounce",
|
||||
"SMTP_USE_TLS": "false",
|
||||
"SMTP_USE_SSL": "true",
|
||||
|
||||
# Stripe
|
||||
"STRIPE_SECRET_KEY": os.environ["STRIPE_SECRET_KEY"],
|
||||
"STRIPE_PUBLISHABLE_KEY": "pk_live_51ScLbjCtFUamNRpNeFugrlTIYhszbo8GovSGiMnPwHpZX9p3SGtgG8iRHYRIlAtg9M9sl3mvT5r8pwXP3mOsPALG00Wk3j0wH4",
|
||||
"STRIPE_PRICE_TRADER": "price_1ScRlzCtFUamNRpNQdMpMzxV",
|
||||
"STRIPE_PRICE_TYCOON": "price_1SdwhSCtFUamNRpNEXTSuGUc",
|
||||
"STRIPE_WEBHOOK_SECRET": os.environ["STRIPE_WEBHOOK_SECRET"],
|
||||
|
||||
# OAuth
|
||||
"GOOGLE_CLIENT_ID": "865146315769-vi7vcu91d3i7huv8ikjun52jo9ob7spk.apps.googleusercontent.com",
|
||||
"GOOGLE_CLIENT_SECRET": os.environ["GOOGLE_CLIENT_SECRET"],
|
||||
"GOOGLE_REDIRECT_URI": "https://pounce.ch/api/v1/oauth/google/callback",
|
||||
|
||||
"GITHUB_CLIENT_ID": "Ov23liBjROk39vYXi3G5",
|
||||
"GITHUB_CLIENT_SECRET": os.environ["GH_OAUTH_SECRET"],
|
||||
"GITHUB_REDIRECT_URI": "https://pounce.ch/api/v1/oauth/github/callback",
|
||||
|
||||
# CZDS
|
||||
"CZDS_USERNAME": os.environ["CZDS_USERNAME"],
|
||||
"CZDS_PASSWORD": os.environ["CZDS_PASSWORD"],
|
||||
|
||||
# Switch TSIG (AXFR)
|
||||
"SWITCH_TSIG_CH_SECRET": os.environ["SWITCH_TSIG_CH_SECRET"],
|
||||
"SWITCH_TSIG_LI_SECRET": os.environ["SWITCH_TSIG_LI_SECRET"],
|
||||
|
||||
# LLM Gateway (Mistral Nemo via Ollama)
|
||||
"LLM_GATEWAY_URL": os.environ.get("LLM_GATEWAY_URL", ""),
|
||||
"LLM_GATEWAY_API_KEY": os.environ.get("LLM_GATEWAY_API_KEY", ""),
|
||||
}
|
||||
|
||||
lines = []
|
||||
for k, v in env.items():
|
||||
if v is None:
|
||||
continue
|
||||
lines.append(f"{k}={v}")
|
||||
|
||||
Path("backend.env").write_text("\n".join(lines) + "\n")
|
||||
PY
|
||||
|
||||
- name: Upload backend env to server
|
||||
run: |
|
||||
rsync -az \
|
||||
-e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes" \
|
||||
./backend.env \
|
||||
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/data/pounce/env/backend.env"
|
||||
|
||||
- name: Deploy on server (pounce-deploy)
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" << 'DEPLOY_EOF'
|
||||
set -euo pipefail
|
||||
chmod 600 /data/pounce/env/backend.env
|
||||
sudo /usr/local/bin/pounce-deploy
|
||||
DEPLOY_EOF
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "=========================================="
|
||||
echo "🎉 AUTO DEPLOY COMPLETED"
|
||||
echo "=========================================="
|
||||
echo "Commit: ${{ github.sha }}"
|
||||
echo "Backend: https://api.pounce.ch"
|
||||
echo "Web: https://pounce.ch"
|
||||
echo "=========================================="
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -26,6 +26,7 @@ dist/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.deploy
|
||||
*.log
|
||||
|
||||
# Deployment env files (MUST NOT be committed)
|
||||
|
||||
@ -629,3 +629,4 @@ MIT License
|
||||
## 📧 Support
|
||||
|
||||
For issues and feature requests, please open a GitHub issue or contact support@pounce.ch
|
||||
# Pounce CI/CD
|
||||
|
||||
335
UX_TERMINAL_UX_REPORT.md
Normal file
335
UX_TERMINAL_UX_REPORT.md
Normal file
@ -0,0 +1,335 @@
|
||||
# UX-Review: Pounce Terminal (Next.js User App)
|
||||
|
||||
Stand: 2025-12-19
|
||||
Scope: **alle Seiten unter** `frontend/src/app/terminal/*` (inkl. Subroute `intel/[tld]`) + globale Terminal-UX (Navigation, Feedback, Loading/Error, Mobile, Accessibility)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary (was am meisten “zählt”)
|
||||
|
||||
Das Terminal wirkt visuell stark (klare Terminal-Ästhetik, konsistente Farbwelt, guter Einsatz von Stats/Badges). Die größten UX-Hebel liegen aktuell aber weniger im „Look“, sondern in **Konsistenz, Feedback-Loops und Bedienbarkeit**:
|
||||
|
||||
- **P0: Navigation/Chrome ist pro Seite dupliziert** (Mobile Header/Bottom Nav/Drawer) → Inkonsistenzen + Bugs (z.B. aktive Tabs/Routes falsch) + hoher Maintenance-Overhead.
|
||||
- **P0: Keine globalen `loading.tsx` / `error.tsx`** im App Router → schlechte UX bei langsamen API-Calls und Edge-Fehlern.
|
||||
- **P1: Mixed Feedback** (`alert/confirm`, Toast, inline) → mentaler Kontext-Switch + „unbranded“ Dialoge.
|
||||
- **P1: Accessibility & Keyboard UX** (Focus Trap, ESC, ARIA-Rollen, Kontrast/Typografie bei 9–10px) → wirkt “pro”, aber ist für längere Sessions anstrengend.
|
||||
|
||||
---
|
||||
|
||||
## Quick Wins (1–2 Tage, hoher Impact)
|
||||
|
||||
- **Unify Terminal Shell**: Ein einziges Layout für Terminal (Sidebar + Topbar + Mobile Drawer + Bottom Nav) statt Copy/Paste pro Seite.
|
||||
- **App Router Loading/Error**: `frontend/src/app/terminal/loading.tsx` + `frontend/src/app/terminal/error.tsx` (+ ggf. root-level) einführen.
|
||||
- **Mobile Nav Active State fixen**: Aktive Route automatisch über `usePathname()` bestimmen (nicht hardcoded `active: true/false` Arrays).
|
||||
- **Replace `alert/confirm`**: überall ein konsistentes Modal/Toast Pattern.
|
||||
- **Modals**: ESC schließen, Fokus in Modal halten, `role="dialog"`, `aria-modal="true"`, initial focus auf primäres Feld/CTA.
|
||||
|
||||
---
|
||||
|
||||
## P0 / P1 / P2 – Priorisierte UX-Baustellen
|
||||
|
||||
### P0 – Blocker / deutliche UX-Schäden
|
||||
- **Terminal Navigation/Chrome dupliziert**
|
||||
Viele Seiten implementieren eigene Mobile Header/Bottom Nav/Drawer. Das führt zu:
|
||||
- inkonsistenten Labels (Radar/Hunt), unterschiedlichen Menüs, unterschiedlichen Drawer-Sections
|
||||
- Bugs: aktive Navigation ist teils falsch oder gar nicht gesetzt (z.B. `/terminal/listing` hat mobileNavItems alle `active: false`)
|
||||
- fehlende Features in manchen Layouts (z.B. Notifications/Quick Search aus `components/TerminalLayout.tsx` werden in den meisten Terminal-Seiten nie genutzt)
|
||||
|
||||
- **Keine globalen Loading/Error States**
|
||||
Es existieren keine `loading.tsx` / `error.tsx` im `app/` Tree. Bei langsamen Calls (Market Feed, TLD-Loads, WHOIS/Health) entsteht:
|
||||
- „Blank“/Springen zwischen States
|
||||
- inkonsistente Loader-Stile pro Seite
|
||||
- keine zentrale Recovery (Retry, Logging, Support CTA)
|
||||
|
||||
### P1 – hoher Impact, aber kein Blocker
|
||||
- **Mixed Feedback & Confirmation UX**
|
||||
Unterschiedliche Patterns je Seite: Toast (`Toast`), inline error blocks, aber auch `alert()` / `confirm()` (unbranded, nicht mobil-optimiert, blockierend).
|
||||
|
||||
- **Mobile IA/Navigation ist zu “kurz”**
|
||||
Bottom Nav zeigt meist nur 4 Punkte (Hunt/Watch/Portfolio/Intel) → wichtige Module (Yield, Sniper, Inbox, For Sale, Settings) hängen hinter „Menu“, ohne klare Priorisierung oder Badges.
|
||||
|
||||
- **Intel lädt sehr viel Daten “am Client”**
|
||||
`IntelPage` lädt bis zu 500 TLDs in einem Loop (100er chunks). UX-Probleme:
|
||||
- lange initiale Ladezeit
|
||||
- kein echtes Pagination/Search UX (Suche filtert lokal)
|
||||
- mobile wird schnell unübersichtlich
|
||||
|
||||
### P2 – polish / langfristige Qualität
|
||||
- **Typografie & Kontrast**
|
||||
Viele essenzielle Infos sind `text-white/30` oder `text-[9px]` → funktioniert “stylish”, aber wird bei langen Sessions anstrengend.
|
||||
- **Keyboard-first**
|
||||
Das Terminal-Feeling schreit nach Power-User Shortcuts (Cmd+K existiert, aber Search hat noch keine Results UX).
|
||||
|
||||
---
|
||||
|
||||
## Globale Empfehlungen (Terminal-weit)
|
||||
|
||||
### 1) Ein „Terminal Shell“ Layout (Single Source of Truth)
|
||||
Ziel: **Sidebar + Topbar + Mobile Drawer + Mobile Bottom Nav** einmal bauen und Seiten nur noch Content rendern lassen.
|
||||
|
||||
Konkret:
|
||||
- `frontend/src/app/terminal/layout.tsx` (auth guard) ist ok, aber **UI-Chrome sollte zusätzlich** in einem Layout/Wrapper passieren (z.B. `TerminalShellLayout`).
|
||||
- `components/TerminalLayout.tsx` existiert bereits, wird aber primär nur auf `/terminal/welcome` genutzt.
|
||||
Empfehlung: Entweder
|
||||
- **Option A**: `TerminalLayout` so erweitern, dass alle Terminal-Seiten ihn nutzen, oder
|
||||
- **Option B**: neue `TerminalShell` Komponente, die die wiederkehrenden Teile kapselt (Desktop+Mobile).
|
||||
|
||||
**Wichtig**: Mobile Bottom Nav muss aktive Route automatisch bestimmen (z.B. über `usePathname()`), sonst bleibt es fehleranfällig.
|
||||
|
||||
### 2) Konsistentes Feedback-System
|
||||
Standardisieren:
|
||||
- **Errors**: inline „ErrorCard“ mit Retry + „Details“ (expand), und optional „Contact support“ Link.
|
||||
- **Confirms**: eigenes Confirm-Modal (nicht `confirm()`).
|
||||
- **Success**: Toast.
|
||||
- **Long-running**: progress + disable states + ggf. optimistic updates (macht ihr teils schon gut).
|
||||
|
||||
### 3) Modal Quality Bar (Accessibility + UX)
|
||||
Alle Modals (Add/Verify/Thread/Wizards) sollten:
|
||||
- ESC schließen
|
||||
- Fokus im Modal halten (Focus Trap)
|
||||
- `role="dialog"`, `aria-modal="true"`, `aria-labelledby`
|
||||
- initial focus (erstes Input-Feld oder Primary CTA)
|
||||
- „Close“ Button immer sichtbar
|
||||
- bei kritischen Aktionen: klare irreversible Warnung + „Undo“ wo möglich
|
||||
|
||||
### 4) URL-State für Filter & Deep Links
|
||||
Viele Seiten haben Filter/Sort/Search, aber Zustand ist oft nur in React State:
|
||||
- Market: source/tld/price/hideSpam/search/page sollten in Query Params spiegeln (shareable links, back button korrekt).
|
||||
- Intel: Filter und Search ebenfalls in URL.
|
||||
- Inbox: ihr habt schon Query Param `inquiry` + `tab` → gutes Pattern, ausbauen.
|
||||
|
||||
### 5) “System Status” & Datenfrische
|
||||
Für alle datengetriebenen Seiten ein einheitliches Muster:
|
||||
- „Last updated“ + „Refresh“ (nicht überall gleich)
|
||||
- bei Auto-refresh (Watchlist Tycoon): UI-Indikator „Auto-refresh active“ + letzte Hintergrund-Aktualisierung (sonst wirkt es „random“).
|
||||
|
||||
---
|
||||
|
||||
## Seitenreport (Terminal)
|
||||
|
||||
### `/terminal` (Redirect)
|
||||
Ist aktuell ein Spinner + Redirect nach `/terminal/hunt`. UX ok, aber:
|
||||
- **Verbesserung**: kurze Textzeile „Opening Terminal…“ (Barrierefreiheit, Kontext), plus “fallback link” falls Router hängt.
|
||||
|
||||
### `/terminal/welcome`
|
||||
Starkes Upgrade-Confirmation Pattern (Feature Cards, Next Steps).
|
||||
UX-Fixes:
|
||||
- Confetti nutzt `Math.random()` in render → kann bei Re-Renders „flackern“ / unruhig wirken.
|
||||
- CTA Label “Go to Radar” führt nach `/terminal/hunt` (Terminologie konsistent halten: Hunt vs Radar).
|
||||
|
||||
### `/terminal/hunt`
|
||||
Stärken:
|
||||
- Tab UX (Search/Drops/Auctions/Trends/Forge) ist klar und fühlt sich wie ein “Workbench” an.
|
||||
|
||||
Probleme:
|
||||
- Seite rendert eigene Sidebar + Mobile Drawer + Bottom Nav → **duplizierter Shell**.
|
||||
- Mobile Bottom Nav hat „active“ hardcoded; ist hier ok, aber strukturell fragil.
|
||||
|
||||
Empfehlungen:
|
||||
- Tabs als URL-State: `?tab=search|drops|...` (Deep link + Back button).
|
||||
- Top-of-page: “What’s new / last updated” pro Tab (Drops/Auctions/Trends) um Datenfrische sichtbar zu machen.
|
||||
|
||||
### `/terminal/market`
|
||||
Stärken:
|
||||
- Gute Filter, Spam-Hide, Score Badges, Track/Analyze Actions.
|
||||
|
||||
Probleme:
|
||||
- `searchQuery` wird sowohl server-seitig (API `keyword`) als auch client-seitig gefiltert → Doppel-Filter kann zu “WTF”-Momenten führen.
|
||||
- Fehlerfall: bei API error wird `items=[]` gesetzt, aber **kein** Error UI (nur „No domains found“ → falsche Diagnose).
|
||||
- Mobile Drawer/Bottom Nav duplicated.
|
||||
|
||||
Empfehlungen:
|
||||
- Error UI unterscheiden: „0 Results“ vs „Load failed“.
|
||||
- Filter in URL spiegeln.
|
||||
- “Track” UX: wenn Domain schon getrackt ist und Remove passiert, muss UI klar „Removed from Watchlist“ anzeigen (Toast habt ihr), und optional Undo.
|
||||
|
||||
### `/terminal/intel`
|
||||
Stärken:
|
||||
- Tier Gating ist klar visualisiert (Locks, Upgrade CTA).
|
||||
- Sort/Filter/Search UI ist solide.
|
||||
|
||||
Probleme:
|
||||
- Große Client-Loads (bis 500 TLDs in 100er Schritten) → mobile/slow networks leiden.
|
||||
- Duplicate nav/drawer.
|
||||
|
||||
Empfehlungen:
|
||||
- Server-driven pagination: „Top 100 by popularity“ + search server-seitig, infinite scroll optional.
|
||||
- “Renewal trap” als eigenes Filter/Badge prominent (weil es echte Entscheidung beeinflusst).
|
||||
|
||||
### `/terminal/intel/[tld]`
|
||||
Stärken:
|
||||
- Gute „Detail“-Informationsarchitektur (Chart + Domain Check + Registrar Tabelle).
|
||||
- Lock Overlay für Scout ist gut.
|
||||
|
||||
Probleme:
|
||||
- Domain Check Fehler wird nur `console.error`, kein UI-Feedback.
|
||||
- Registrar-Link Fallback `'#'` (führt zu dead click) → besser: Button disabled + Tooltip „No registrar link available“.
|
||||
- Duplicate nav/drawer.
|
||||
|
||||
Empfehlungen:
|
||||
- Domain Check: inline error state + retry.
|
||||
- Chart Tooltip: auf mobile fehlt Hover; optional Tap-to-inspect.
|
||||
|
||||
### `/terminal/watchlist`
|
||||
Stärken:
|
||||
- Sehr gutes Domain Operations UX (Alert toggle, Refresh, Health modal, Expiry tags).
|
||||
|
||||
Probleme:
|
||||
- Mixed confirmations: `confirm('Drop target…')` ist unbranded und blockierend.
|
||||
- Health-Autoload kann bei großen Listen viele Requests erzeugen; UX kann „unruhig“ werden (Loader überall).
|
||||
- Duplicate nav/drawer.
|
||||
|
||||
Empfehlungen:
|
||||
- Confirm Modal + optional Undo „Removed“.
|
||||
- Health Cache: klarer Indikator „Health: cached at …“ vs „live check“.
|
||||
- „Add domain“ Modal: Domain Normalisierung/Validation vor Submit (z.B. whitespace/protocol) + Inline errors.
|
||||
|
||||
### `/terminal/portfolio`
|
||||
Stärken:
|
||||
- Sehr umfangreich, aber erstaunlich gut strukturiert (Assets/Financials Tabs, CFO-Karten, KillList/BurnRate).
|
||||
- DNS Verify Flow ist verständlich (2 Schritte, Copy Button).
|
||||
|
||||
Probleme:
|
||||
- Auto Health checks (bis 20 Domains, delay 300ms) können initial lange dauern; ohne globalen “health loading” Kontext wirkt es wie “random spinners”.
|
||||
- Viele Modals/Overlays, aber Fokus/ESC/ARIA nicht standardisiert.
|
||||
- Duplicate nav/drawer (hier sogar unterschiedliche Drawer-Implementierungen).
|
||||
|
||||
Empfehlungen:
|
||||
- Health prefetch optional machen (Toggle „Preload health on open“).
|
||||
- “Financials” braucht klaren “data computed at” Stempel + “Refresh CFO” prominent.
|
||||
- Vereinheitlichte Drawer/Bottom nav in Shell.
|
||||
|
||||
### `/terminal/listing` (For Sale)
|
||||
Stärken:
|
||||
- Wizard (Create Listing) ist ein gutes Pattern (Step 1–3).
|
||||
- Leads Modal mit Thread UI ist sehr wertvoll (reale Workflows).
|
||||
|
||||
Probleme:
|
||||
- Mobile Bottom Nav: `active` ist überall false → **Navigation Feedback kaputt**.
|
||||
- Mixed feedback: `alert()` bei errors (publish/delete) → unbranded.
|
||||
- Wizard Step 2: “DNS can take 1–5 minutes” ist ok, aber es fehlen “Where exactly to set TXT record?” Verweise/Links pro Registrar.
|
||||
|
||||
Empfehlungen:
|
||||
- Active Nav fixen (global).
|
||||
- Alerts/confirm ersetzen (Toast + Modal).
|
||||
- Wizard: “Copy all fields” + “Open registrar docs” optional.
|
||||
- Leads/Thread: auto-scroll to latest message + “unread” marker.
|
||||
|
||||
### `/terminal/inbox`
|
||||
Stärken:
|
||||
- Buying vs Selling Tabs; query-params unterstütztes Deep-Linking (gut!).
|
||||
- Thread Pane Layout auf Desktop ist sinnvoll.
|
||||
|
||||
Probleme:
|
||||
- Polling (15s messages, 30s threads) ohne sichtbaren „sync“ Indikator; kann „messages jumpen“ oder user verunsichern.
|
||||
- Mobile Drawer ist sehr reduziert (Hunt/Watchlist/For Sale) → Missing core modules.
|
||||
|
||||
Empfehlungen:
|
||||
- „Last synced“ + kleiner Spinner beim Poll refresh.
|
||||
- Thread: “Sending…” state im Message Bubble optimistisch anzeigen.
|
||||
- Mobile: Navigation vereinheitlichen (Shell).
|
||||
|
||||
### `/terminal/sniper`
|
||||
Stärken:
|
||||
- Übersicht mit Active/Matches/Sent Stats.
|
||||
- Create/Edit Modal deckt viele Filter ab.
|
||||
|
||||
Probleme:
|
||||
- Mixed feedback: `alert()` bei toggle/delete errors.
|
||||
- Limit messaging: Scout maxAlerts=0, aber Settings-Plan Copy sagt “2 Sniper Alerts” → **Widerspruch** (führt zu Trust-Bruch).
|
||||
|
||||
Empfehlungen:
|
||||
- Limits und Copy überall konsistent (Settings, Pricing, Backend).
|
||||
- “Preview matches” wäre stark: pro Alert ein „Show matches“ (wenn Backend vorhanden).
|
||||
|
||||
### `/terminal/yield`
|
||||
Stärken:
|
||||
- Aktivierungsflow (Select Domain → DNS Setup → Verify) ist klar.
|
||||
- Dashboard Tabelle liefert „operative“ KPIs.
|
||||
|
||||
Probleme:
|
||||
- **Hardcoded IP** `46.235.147.194` im Frontend: UX/Trust & Ops-Risiko (kann sich ändern, Multi-env schwierig).
|
||||
- Sehr viel `alert()/confirm()` für Verify/Delete → unbranded, keine Recovery UI.
|
||||
- Tier-Gating Messaging: Modal Titel „Preview Yield Landing“ auch für Trader/Tycoon; das ist ok, aber sollte klarer sein.
|
||||
|
||||
Empfehlungen:
|
||||
- IP & DNS instructions server/ENV-driven (z.B. `NEXT_PUBLIC_YIELD_SERVER_IP` + Backend liefert aktuelle DNS Targets).
|
||||
- Verify/Delete: styled modal + inline results (verified/expected vs actual).
|
||||
- “Landing Ready/Missing”: wenn missing, direkt CTA „Regenerate (Tycoon)“ mit Erklärung.
|
||||
|
||||
### `/terminal/settings`
|
||||
Stärken:
|
||||
- Gute Tabs (Profile/Alerts/Plans/Security), Plan Cards ordentlich.
|
||||
- Referral/Invite ist sauber umgesetzt.
|
||||
|
||||
Probleme:
|
||||
- Notification prefs werden nur in `localStorage` gespeichert → nicht cross-device, nicht auditierbar. (UX: „Saved“ wirkt, aber auf anderem Device weg.)
|
||||
- “Email Verified” wird als immer verified angezeigt (UI Copy), unabhängig vom echten `user.is_verified` → UX/Trust Risiko.
|
||||
- “Delete Account” Button ohne Flow (gefährlich).
|
||||
|
||||
Empfehlungen:
|
||||
- Notification prefs in Backend persistieren (Endpoint z.B. `PUT /auth/notification-prefs` oder `PUT /users/me/preferences`).
|
||||
- Security Tab: echten Status aus `user.is_verified` anzeigen + „Resend verification“ CTA falls false.
|
||||
- Delete Account: confirm flow + export data + cooldown.
|
||||
|
||||
---
|
||||
|
||||
## Konsistenz-/Trust-Issues (wichtig für Conversion)
|
||||
|
||||
- **Plan Limits widersprüchlich**:
|
||||
Beispiel: Sniper Limits in `/terminal/sniper` (scout: 0) vs Settings Plan Copy (Scout: “2 Sniper Alerts”). Das ist ein direkter Trust-Bruch.
|
||||
|
||||
- **Terminologie**: Radar vs Hunt; Terminal v1.0 in Drawer; “Go to Radar” aber Route `/terminal/hunt`.
|
||||
Empfehlung: eine Terminologie festlegen (z.B. „Hunt“ überall) und konsequent.
|
||||
|
||||
---
|
||||
|
||||
## Messbare UX-KPIs (damit Improvements nachvollziehbar sind)
|
||||
|
||||
- **Time-to-first-useful-data** pro Seite (Market, Intel, Watchlist, Portfolio CFO)
|
||||
- **Error rate** (API errors) pro Seite + Retry success rate
|
||||
- **Completion rates**:
|
||||
- Listing Wizard Step 1→2→3
|
||||
- Yield Activation Step 1→2→3
|
||||
- DNS verification success time distribution
|
||||
- **Engagement**:
|
||||
- Track/untrack from Market
|
||||
- Analyze opens per session
|
||||
- Inbox reply median time
|
||||
|
||||
---
|
||||
|
||||
## Konkrete nächste Schritte (Engineering Backlog Vorschlag)
|
||||
|
||||
### Phase 1 (P0): Shell + States
|
||||
- Terminal Shell Layout bauen, alle Terminal-Seiten migrieren.
|
||||
- `terminal/loading.tsx` + `terminal/error.tsx` einführen.
|
||||
- Einheitliches Toast/Confirm/Modal Pattern.
|
||||
|
||||
### Phase 2 (P1): Data UX
|
||||
- Market: echte Error UI + URL-state für Filter + klare Trennung API vs client filtering.
|
||||
- Intel: server-driven pagination/search.
|
||||
- Settings: Preferences persistieren + Security Status korrekt.
|
||||
|
||||
### Phase 3 (P2): Polish & Power-User
|
||||
- Keyboard-first: Cmd+K Search mit echten Results + navigation.
|
||||
- Accessibility sweep: Kontrast, Focus states, ARIA, reduced motion.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ Neu: **DISCOVER → TRACK → TRADE → YIELD**
|
||||
│ "Let your domains work for you." │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🔌 Connect Point DNS to ns.pounce.io │ │
|
||||
│ │ 🔌 Connect Point DNS to ns.pounce.ch │ │
|
||||
│ │ 🧠 Analyze We detect: "kredit.ch" → Loan Intent │ │
|
||||
│ │ 💰 Earn Affiliate routing → CHF 25/lead │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
@ -161,8 +161,8 @@ SETTINGS
|
||||
│ Change your nameservers to: │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ ns1.pounce.io [📋] │ │
|
||||
│ │ ns2.pounce.io [📋] │ │
|
||||
│ │ ns1.pounce.ch [📋] │ │
|
||||
│ │ ns2.pounce.ch [📋] │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ⏳ We're checking your DNS... │
|
||||
@ -380,7 +380,7 @@ class YieldDNSService:
|
||||
"""Verwaltet DNS und Hosting für Yield-Domains."""
|
||||
|
||||
async def verify_nameservers(self, domain: str) -> bool:
|
||||
"""Prüft ob Domain auf ns1/ns2.pounce.io zeigt."""
|
||||
"""Prüft ob Domain auf ns1/ns2.pounce.ch zeigt."""
|
||||
|
||||
async def provision_landing_page(self, domain: str, intent: str) -> str:
|
||||
"""Erstellt minimale Landing Page für Routing."""
|
||||
@ -468,7 +468,7 @@ class YieldDNSService:
|
||||
|
||||
| Komponente | Benötigt | Status |
|
||||
|------------|----------|--------|
|
||||
| Eigene Nameserver (ns1/ns2.pounce.io) | ✅ | Neu |
|
||||
| Eigene Nameserver (ns1/ns2.pounce.ch) | ✅ | Neu |
|
||||
| DNS-Hosting (Cloudflare API oder ähnlich) | ✅ | Neu |
|
||||
| Landing Page CDN | ✅ | Neu |
|
||||
| Affiliate-Netzwerk Accounts | ✅ | Neu |
|
||||
|
||||
@ -64,15 +64,15 @@ For yield domains to work, you need to set up DNS infrastructure:
|
||||
|
||||
#### Option A: Dedicated Nameservers (Recommended for Scale)
|
||||
|
||||
1. Set up two nameserver instances (e.g., `ns1.pounce.io`, `ns2.pounce.io`)
|
||||
1. Set up two nameserver instances (e.g., `ns1.pounce.ch`, `ns2.pounce.ch`)
|
||||
2. Run PowerDNS or similar with a backend that queries your yield_domains table
|
||||
3. Return A records pointing to your yield routing service
|
||||
|
||||
#### Option B: CNAME Approach (Simpler)
|
||||
|
||||
1. Set up a wildcard SSL certificate for `*.yield.pounce.io`
|
||||
1. Set up a wildcard SSL certificate for `*.yield.pounce.ch`
|
||||
2. Configure Nginx/Caddy to handle all incoming hosts
|
||||
3. Users add CNAME: `@ → yield.pounce.io`
|
||||
3. Users add CNAME: `@ → yield.pounce.ch`
|
||||
|
||||
### 4. Nginx Configuration
|
||||
|
||||
@ -85,8 +85,8 @@ server {
|
||||
server_name ~^(?<domain>.+)$;
|
||||
|
||||
# Wildcard cert
|
||||
ssl_certificate /etc/ssl/yield.pounce.io.crt;
|
||||
ssl_certificate_key /etc/ssl/yield.pounce.io.key;
|
||||
ssl_certificate /etc/ssl/yield.pounce.ch.crt;
|
||||
ssl_certificate_key /etc/ssl/yield.pounce.ch.key;
|
||||
|
||||
location / {
|
||||
proxy_pass http://backend:8000/api/v1/r/$domain;
|
||||
|
||||
@ -12,8 +12,10 @@ RUN groupadd -r pounce && useradd -r -g pounce pounce
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
# dnsutils provides 'dig' for DNS zone transfers (AXFR)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
dnsutils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
"""add llm artifacts and yield landing config
|
||||
|
||||
Revision ID: 016_add_llm_artifacts_and_yield_landing_config
|
||||
Revises: 015_add_subscription_referral_bonus_domains
|
||||
Create Date: 2025-12-17
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "016_add_llm_artifacts_and_yield_landing_config"
|
||||
down_revision = "015_add_subscription_referral_bonus_domains"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"llm_artifacts",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("kind", sa.String(length=50), nullable=False),
|
||||
sa.Column("domain", sa.String(length=255), nullable=False),
|
||||
sa.Column("prompt_version", sa.String(length=50), nullable=False),
|
||||
sa.Column("model", sa.String(length=100), nullable=False),
|
||||
sa.Column("payload_json", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_llm_artifacts_id", "llm_artifacts", ["id"])
|
||||
op.create_index("ix_llm_artifacts_user_id", "llm_artifacts", ["user_id"])
|
||||
op.create_index("ix_llm_artifacts_kind", "llm_artifacts", ["kind"])
|
||||
op.create_index("ix_llm_artifacts_domain", "llm_artifacts", ["domain"])
|
||||
op.create_index("ix_llm_artifacts_prompt_version", "llm_artifacts", ["prompt_version"])
|
||||
op.create_index("ix_llm_artifacts_created_at", "llm_artifacts", ["created_at"])
|
||||
op.create_index("ix_llm_artifacts_expires_at", "llm_artifacts", ["expires_at"])
|
||||
op.create_index(
|
||||
"ix_llm_artifacts_kind_domain_prompt",
|
||||
"llm_artifacts",
|
||||
["kind", "domain", "prompt_version"],
|
||||
)
|
||||
|
||||
# Yield landing config (generated by LLM on activation)
|
||||
op.add_column("yield_domains", sa.Column("landing_config_json", sa.Text(), nullable=True))
|
||||
op.add_column("yield_domains", sa.Column("landing_template", sa.String(length=50), nullable=True))
|
||||
op.add_column("yield_domains", sa.Column("landing_headline", sa.String(length=300), nullable=True))
|
||||
op.add_column("yield_domains", sa.Column("landing_intro", sa.Text(), nullable=True))
|
||||
op.add_column("yield_domains", sa.Column("landing_cta_label", sa.String(length=120), nullable=True))
|
||||
op.add_column("yield_domains", sa.Column("landing_model", sa.String(length=100), nullable=True))
|
||||
op.add_column("yield_domains", sa.Column("landing_generated_at", sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("yield_domains", "landing_generated_at")
|
||||
op.drop_column("yield_domains", "landing_model")
|
||||
op.drop_column("yield_domains", "landing_cta_label")
|
||||
op.drop_column("yield_domains", "landing_intro")
|
||||
op.drop_column("yield_domains", "landing_headline")
|
||||
op.drop_column("yield_domains", "landing_template")
|
||||
op.drop_column("yield_domains", "landing_config_json")
|
||||
|
||||
op.drop_index("ix_llm_artifacts_kind_domain_prompt", table_name="llm_artifacts")
|
||||
op.drop_index("ix_llm_artifacts_expires_at", table_name="llm_artifacts")
|
||||
op.drop_index("ix_llm_artifacts_created_at", table_name="llm_artifacts")
|
||||
op.drop_index("ix_llm_artifacts_prompt_version", table_name="llm_artifacts")
|
||||
op.drop_index("ix_llm_artifacts_domain", table_name="llm_artifacts")
|
||||
op.drop_index("ix_llm_artifacts_kind", table_name="llm_artifacts")
|
||||
op.drop_index("ix_llm_artifacts_user_id", table_name="llm_artifacts")
|
||||
op.drop_index("ix_llm_artifacts_id", table_name="llm_artifacts")
|
||||
op.drop_table("llm_artifacts")
|
||||
|
||||
@ -27,6 +27,10 @@ from app.api.analyze import router as analyze_router
|
||||
from app.api.hunt import router as hunt_router
|
||||
from app.api.cfo import router as cfo_router
|
||||
from app.api.drops import router as drops_router
|
||||
from app.api.llm import router as llm_router
|
||||
from app.api.llm_naming import router as llm_naming_router
|
||||
from app.api.llm_vision import router as llm_vision_router
|
||||
from app.api.deploy import router as deploy_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@ -45,6 +49,9 @@ api_router.include_router(analyze_router, prefix="/analyze", tags=["Analyze"])
|
||||
api_router.include_router(hunt_router, prefix="/hunt", tags=["Hunt"])
|
||||
api_router.include_router(cfo_router, prefix="/cfo", tags=["CFO"])
|
||||
api_router.include_router(drops_router, tags=["Drops - Zone Files"])
|
||||
api_router.include_router(llm_router, tags=["LLM"])
|
||||
api_router.include_router(llm_naming_router, tags=["LLM Naming"])
|
||||
api_router.include_router(llm_vision_router, tags=["LLM Vision"])
|
||||
|
||||
# Marketplace (For Sale) - from analysis_3.md
|
||||
api_router.include_router(listings_router, prefix="/listings", tags=["Marketplace - For Sale"])
|
||||
@ -75,3 +82,6 @@ api_router.include_router(blog_router, prefix="/blog", tags=["Blog"])
|
||||
|
||||
# Admin endpoints
|
||||
api_router.include_router(admin_router, prefix="/admin", tags=["Admin"])
|
||||
|
||||
# Deploy endpoint (internal use only)
|
||||
api_router.include_router(deploy_router, tags=["Deploy"])
|
||||
|
||||
@ -662,15 +662,29 @@ async def delete_user(
|
||||
db: Database,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Delete a user and all their data."""
|
||||
"""
|
||||
Delete a user and all their data.
|
||||
|
||||
Production-hardening:
|
||||
- Prefer hard-delete (keeps DB tidy).
|
||||
- If hard-delete is blocked by FK constraints, fall back to a safe deactivation
|
||||
(soft-delete) so the admin UI never hits a 500.
|
||||
"""
|
||||
from app.models.blog import BlogPost
|
||||
from app.models.admin_log import AdminActivityLog
|
||||
from app.services.auth import AuthService
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
import secrets
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Safety rails
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete your own admin account")
|
||||
|
||||
if user.is_admin:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete admin user")
|
||||
@ -687,17 +701,47 @@ async def delete_user(
|
||||
AdminActivityLog.__table__.delete().where(AdminActivityLog.admin_id == user_id)
|
||||
)
|
||||
|
||||
# Now delete the user (cascades to domains, subscriptions, portfolio, price_alerts)
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
# Now delete the user (cascades to domains, subscriptions, portfolio, listings, alerts, etc.)
|
||||
# If FK constraints block the delete (e.g., some rows reference users.id without cascade),
|
||||
# we fall back to a safe soft-delete.
|
||||
try:
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
deleted_mode = "hard"
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
|
||||
# Soft delete: disable account + remove auth factors so the user can never log in again.
|
||||
# (We keep the row to satisfy FK constraints elsewhere.)
|
||||
user.is_active = False
|
||||
user.is_verified = False
|
||||
user.hashed_password = AuthService.hash_password(secrets.token_urlsafe(32))
|
||||
user.stripe_customer_id = None
|
||||
user.password_reset_token = None
|
||||
user.password_reset_expires = None
|
||||
user.email_verification_token = None
|
||||
user.email_verification_expires = None
|
||||
user.oauth_provider = None
|
||||
user.oauth_id = None
|
||||
user.oauth_avatar = None
|
||||
user.last_login = None
|
||||
|
||||
await db.commit()
|
||||
deleted_mode = "soft"
|
||||
|
||||
# Log this action
|
||||
await log_admin_activity(
|
||||
db, admin.id, "user_delete",
|
||||
f"Deleted user {user_email} and all their data"
|
||||
f"Deleted user {user_email} (mode={deleted_mode})"
|
||||
)
|
||||
|
||||
return {"message": f"User {user_email} and all their data have been deleted"}
|
||||
if deleted_mode == "hard":
|
||||
return {"message": f"User {user_email} and all their data have been deleted"}
|
||||
|
||||
return {
|
||||
"message": f"User {user_email} has been deactivated (soft delete) due to existing references",
|
||||
"mode": "soft",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/upgrade")
|
||||
@ -1726,3 +1770,142 @@ async def force_activate_listing(
|
||||
"slug": listing.slug,
|
||||
"public_url": listing.public_url,
|
||||
}
|
||||
|
||||
|
||||
# ============== Zone File Sync ==============
|
||||
|
||||
@router.post("/zone-sync/switch")
|
||||
async def trigger_switch_sync(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Database,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Trigger manual Switch.ch zone file sync (.ch, .li).
|
||||
Admin only.
|
||||
"""
|
||||
from app.services.zone_file import ZoneFileService
|
||||
|
||||
async def run_sync():
|
||||
from app.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as session:
|
||||
zf = ZoneFileService()
|
||||
for tld in ["ch", "li"]:
|
||||
await zf.run_daily_sync(session, tld)
|
||||
return {"status": "complete"}
|
||||
|
||||
background_tasks.add_task(run_sync)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"message": "Switch.ch zone sync started in background. Check logs for progress.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/zone-sync/czds")
|
||||
async def trigger_czds_sync(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Database,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Trigger manual ICANN CZDS zone file sync (gTLDs).
|
||||
Admin only.
|
||||
"""
|
||||
from app.services.czds_client import CZDSClient
|
||||
|
||||
async def run_sync():
|
||||
from app.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as session:
|
||||
client = CZDSClient()
|
||||
result = await client.sync_all_zones(session, parallel=True)
|
||||
return result
|
||||
|
||||
background_tasks.add_task(run_sync)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"message": "ICANN CZDS zone sync started in background (parallel mode). Check logs for progress.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/zone-sync/status")
|
||||
async def get_zone_sync_status(
|
||||
db: Database,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""
|
||||
Get zone sync status and statistics.
|
||||
Admin only.
|
||||
"""
|
||||
from app.models.zone_file import ZoneSnapshot, DroppedDomain
|
||||
from sqlalchemy import func, desc
|
||||
from datetime import timedelta
|
||||
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
yesterday = today - timedelta(days=1)
|
||||
|
||||
# Get latest snapshots per TLD
|
||||
snapshots_stmt = (
|
||||
select(
|
||||
ZoneSnapshot.tld,
|
||||
func.max(ZoneSnapshot.created_at).label("last_sync"),
|
||||
func.max(ZoneSnapshot.domain_count).label("domain_count"),
|
||||
)
|
||||
.group_by(ZoneSnapshot.tld)
|
||||
)
|
||||
result = await db.execute(snapshots_stmt)
|
||||
snapshots = {row.tld: {"last_sync": row.last_sync, "domain_count": row.domain_count} for row in result.all()}
|
||||
|
||||
# Get drops count per TLD for today
|
||||
drops_today_stmt = (
|
||||
select(
|
||||
DroppedDomain.tld,
|
||||
func.count(DroppedDomain.id).label("count"),
|
||||
)
|
||||
.where(DroppedDomain.dropped_date >= today)
|
||||
.group_by(DroppedDomain.tld)
|
||||
)
|
||||
result = await db.execute(drops_today_stmt)
|
||||
drops_today = {row.tld: row.count for row in result.all()}
|
||||
|
||||
# Total drops per TLD
|
||||
total_drops_stmt = (
|
||||
select(
|
||||
DroppedDomain.tld,
|
||||
func.count(DroppedDomain.id).label("count"),
|
||||
)
|
||||
.group_by(DroppedDomain.tld)
|
||||
)
|
||||
result = await db.execute(total_drops_stmt)
|
||||
total_drops = {row.tld: row.count for row in result.all()}
|
||||
|
||||
# Build status for each TLD
|
||||
all_tlds = set(snapshots.keys()) | set(drops_today.keys()) | set(total_drops.keys())
|
||||
|
||||
zones = []
|
||||
for tld in sorted(all_tlds):
|
||||
snapshot = snapshots.get(tld, {})
|
||||
last_sync = snapshot.get("last_sync")
|
||||
|
||||
zones.append({
|
||||
"tld": tld,
|
||||
"last_sync": last_sync.isoformat() if last_sync else None,
|
||||
"domain_count": snapshot.get("domain_count", 0),
|
||||
"drops_today": drops_today.get(tld, 0),
|
||||
"total_drops": total_drops.get(tld, 0),
|
||||
"status": "healthy" if last_sync and last_sync > yesterday else "stale" if last_sync else "never",
|
||||
})
|
||||
|
||||
return {
|
||||
"zones": zones,
|
||||
"summary": {
|
||||
"total_zones": len(zones),
|
||||
"healthy": sum(1 for z in zones if z["status"] == "healthy"),
|
||||
"stale": sum(1 for z in zones if z["status"] == "stale"),
|
||||
"never_synced": sum(1 for z in zones if z["status"] == "never"),
|
||||
"total_drops_today": sum(drops_today.values()),
|
||||
"total_drops_all": sum(total_drops.values()),
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,6 +78,10 @@ class AuctionListing(BaseModel):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
# Serialize datetimes as ISO format with UTC timezone suffix
|
||||
json_encoders = {
|
||||
datetime: lambda v: v.isoformat() + "Z" if v else None
|
||||
}
|
||||
|
||||
|
||||
class AuctionSearchResponse(BaseModel):
|
||||
@ -92,6 +96,11 @@ class AuctionSearchResponse(BaseModel):
|
||||
"$50 × Length × TLD × Keyword × Brand factors. "
|
||||
"See /portfolio/valuation/{domain} for detailed breakdown."
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
datetime: lambda v: v.isoformat() + "Z" if v else None
|
||||
}
|
||||
|
||||
|
||||
class PlatformStats(BaseModel):
|
||||
@ -108,6 +117,11 @@ class ScrapeStatus(BaseModel):
|
||||
total_auctions: int
|
||||
platforms: List[str]
|
||||
next_scrape: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
datetime: lambda v: v.isoformat() + "Z" if v else None
|
||||
}
|
||||
|
||||
|
||||
class MarketFeedItem(BaseModel):
|
||||
@ -146,6 +160,9 @@ class MarketFeedItem(BaseModel):
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
json_encoders = {
|
||||
datetime: lambda v: v.isoformat() + "Z" if v else None
|
||||
}
|
||||
|
||||
|
||||
class MarketFeedResponse(BaseModel):
|
||||
@ -157,6 +174,11 @@ class MarketFeedResponse(BaseModel):
|
||||
sources: List[str]
|
||||
last_updated: datetime
|
||||
filters_applied: dict = {}
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
datetime: lambda v: v.isoformat() + "Z" if v else None
|
||||
}
|
||||
|
||||
|
||||
# ============== Helper Functions ==============
|
||||
|
||||
@ -30,13 +30,14 @@ async def check_domain_availability(request: DomainCheckRequest):
|
||||
|
||||
return DomainCheckResponse(
|
||||
domain=result.domain,
|
||||
status=result.status.value,
|
||||
status=result.status,
|
||||
is_available=result.is_available,
|
||||
registrar=result.registrar,
|
||||
expiration_date=result.expiration_date,
|
||||
creation_date=result.creation_date,
|
||||
name_servers=result.name_servers,
|
||||
error_message=result.error_message,
|
||||
status_source=getattr(result, "check_method", None),
|
||||
checked_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
@ -61,13 +62,14 @@ async def check_domain_get(domain: str, quick: bool = False):
|
||||
|
||||
return DomainCheckResponse(
|
||||
domain=result.domain,
|
||||
status=result.status.value,
|
||||
status=result.status,
|
||||
is_available=result.is_available,
|
||||
registrar=result.registrar,
|
||||
expiration_date=result.expiration_date,
|
||||
creation_date=result.creation_date,
|
||||
name_servers=result.name_servers,
|
||||
error_message=result.error_message,
|
||||
status_source=getattr(result, "check_method", None),
|
||||
checked_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
231
backend/app/api/deploy.py
Normal file
231
backend/app/api/deploy.py
Normal file
@ -0,0 +1,231 @@
|
||||
"""
|
||||
Remote Deploy Endpoint
|
||||
|
||||
This provides a secure way to trigger deployments remotely when SSH is not available.
|
||||
Protected by an internal API key that should be kept secret.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Header, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/deploy", tags=["deploy"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class DeployStatus(BaseModel):
|
||||
"""Response model for deploy status."""
|
||||
status: str
|
||||
message: str
|
||||
timestamp: str
|
||||
details: Optional[dict] = None
|
||||
|
||||
|
||||
class DeployRequest(BaseModel):
|
||||
"""Request model for deploy trigger."""
|
||||
component: str = "all" # all, backend, frontend
|
||||
git_pull: bool = True
|
||||
|
||||
|
||||
def run_command(cmd: str, cwd: str = None, timeout: int = 300) -> tuple[int, str, str]:
|
||||
"""Run a shell command and return exit code, stdout, stderr."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except subprocess.TimeoutExpired:
|
||||
return -1, "", f"Command timed out after {timeout}s"
|
||||
except Exception as e:
|
||||
return -1, "", str(e)
|
||||
|
||||
|
||||
async def run_deploy(component: str, git_pull: bool) -> dict:
|
||||
"""
|
||||
Execute deployment steps.
|
||||
|
||||
This runs in the background to not block the HTTP response.
|
||||
"""
|
||||
results = {
|
||||
"started_at": datetime.utcnow().isoformat(),
|
||||
"steps": [],
|
||||
}
|
||||
|
||||
base_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
# Step 1: Git pull (if requested)
|
||||
if git_pull:
|
||||
logger.info("Deploy: Running git pull...")
|
||||
code, stdout, stderr = run_command("git pull origin main", cwd=base_path, timeout=60)
|
||||
results["steps"].append({
|
||||
"step": "git_pull",
|
||||
"success": code == 0,
|
||||
"output": stdout or stderr,
|
||||
})
|
||||
if code != 0:
|
||||
logger.error(f"Git pull failed: {stderr}")
|
||||
|
||||
# Step 2: Backend deployment
|
||||
if component in ("all", "backend"):
|
||||
logger.info("Deploy: Restarting backend...")
|
||||
|
||||
# Try systemctl first
|
||||
code, stdout, stderr = run_command("sudo systemctl restart pounce-backend", timeout=30)
|
||||
|
||||
if code == 0:
|
||||
results["steps"].append({
|
||||
"step": "backend_restart",
|
||||
"method": "systemctl",
|
||||
"success": True,
|
||||
})
|
||||
else:
|
||||
# Fallback: Send SIGHUP to reload
|
||||
code, stdout, stderr = run_command("pkill -HUP -f 'uvicorn app.main:app'", timeout=10)
|
||||
results["steps"].append({
|
||||
"step": "backend_restart",
|
||||
"method": "sighup",
|
||||
"success": code == 0,
|
||||
"output": stderr if code != 0 else None,
|
||||
})
|
||||
|
||||
# Step 3: Frontend deployment (more complex)
|
||||
if component in ("all", "frontend"):
|
||||
logger.info("Deploy: Rebuilding frontend...")
|
||||
|
||||
frontend_path = os.path.join(os.path.dirname(base_path), "frontend")
|
||||
|
||||
# Build frontend
|
||||
build_cmd = "npm run build"
|
||||
code, stdout, stderr = run_command(
|
||||
f"cd {frontend_path} && {build_cmd}",
|
||||
timeout=300, # 5 min for build
|
||||
)
|
||||
|
||||
results["steps"].append({
|
||||
"step": "frontend_build",
|
||||
"success": code == 0,
|
||||
"output": stderr[-500:] if code != 0 else "Build successful",
|
||||
})
|
||||
|
||||
if code == 0:
|
||||
# Copy public files for standalone
|
||||
run_command(
|
||||
f"cp -r {frontend_path}/public {frontend_path}/.next/standalone/",
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# Restart frontend
|
||||
code, stdout, stderr = run_command("sudo systemctl restart pounce-frontend", timeout=30)
|
||||
|
||||
if code != 0:
|
||||
# Fallback
|
||||
run_command("pkill -f 'node .next/standalone/server.js'", timeout=10)
|
||||
run_command(
|
||||
f"cd {frontend_path}/.next/standalone && nohup node server.js > /tmp/frontend.log 2>&1 &",
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
results["steps"].append({
|
||||
"step": "frontend_restart",
|
||||
"success": True,
|
||||
})
|
||||
|
||||
results["completed_at"] = datetime.utcnow().isoformat()
|
||||
results["success"] = all(s.get("success", False) for s in results["steps"])
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Deploy failed: {e}")
|
||||
results["error"] = str(e)
|
||||
results["success"] = False
|
||||
|
||||
logger.info(f"Deploy completed: {results}")
|
||||
return results
|
||||
|
||||
|
||||
# Store last deploy result
|
||||
_last_deploy_result: Optional[dict] = None
|
||||
|
||||
|
||||
@router.post("/trigger", response_model=DeployStatus)
|
||||
async def trigger_deploy(
|
||||
request: DeployRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
x_deploy_key: str = Header(..., alias="X-Deploy-Key"),
|
||||
):
|
||||
"""
|
||||
Trigger a deployment remotely.
|
||||
|
||||
Requires X-Deploy-Key header matching the internal_api_key setting.
|
||||
|
||||
This starts the deployment in the background and returns immediately.
|
||||
Check /deploy/status for results.
|
||||
"""
|
||||
global _last_deploy_result
|
||||
|
||||
# Verify deploy key
|
||||
expected_key = settings.internal_api_key
|
||||
if not expected_key or x_deploy_key != expected_key:
|
||||
raise HTTPException(status_code=403, detail="Invalid deploy key")
|
||||
|
||||
# Start deployment in background
|
||||
async def do_deploy():
|
||||
global _last_deploy_result
|
||||
_last_deploy_result = await run_deploy(request.component, request.git_pull)
|
||||
|
||||
background_tasks.add_task(do_deploy)
|
||||
|
||||
return DeployStatus(
|
||||
status="started",
|
||||
message=f"Deployment started for component: {request.component}",
|
||||
timestamp=datetime.utcnow().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=DeployStatus)
|
||||
async def get_deploy_status(
|
||||
x_deploy_key: str = Header(..., alias="X-Deploy-Key"),
|
||||
):
|
||||
"""
|
||||
Get the status of the last deployment.
|
||||
|
||||
Requires X-Deploy-Key header.
|
||||
"""
|
||||
expected_key = settings.internal_api_key
|
||||
if not expected_key or x_deploy_key != expected_key:
|
||||
raise HTTPException(status_code=403, detail="Invalid deploy key")
|
||||
|
||||
if _last_deploy_result is None:
|
||||
return DeployStatus(
|
||||
status="none",
|
||||
message="No deployments have been triggered",
|
||||
timestamp=datetime.utcnow().isoformat(),
|
||||
)
|
||||
|
||||
return DeployStatus(
|
||||
status="completed" if _last_deploy_result.get("success") else "failed",
|
||||
message="Last deployment result",
|
||||
timestamp=_last_deploy_result.get("completed_at", "unknown"),
|
||||
details=_last_deploy_result,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def deploy_health():
|
||||
"""Simple health check for deploy endpoint."""
|
||||
return {"status": "ok", "message": "Deploy endpoint available"}
|
||||
@ -13,9 +13,11 @@ from app.models.subscription import TIER_CONFIG, SubscriptionTier
|
||||
from app.schemas.domain import DomainCreate, DomainResponse, DomainListResponse
|
||||
from app.services.domain_checker import domain_checker
|
||||
from app.services.domain_health import get_health_checker, HealthStatus
|
||||
from app.utils.datetime import to_naive_utc
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _safe_json_loads(value: str | None, default):
|
||||
if not value:
|
||||
return default
|
||||
@ -165,6 +167,7 @@ async def add_domain(
|
||||
expiration_date=check_result.expiration_date,
|
||||
notify_on_available=domain_data.notify_on_available,
|
||||
last_checked=datetime.utcnow(),
|
||||
last_check_method=check_result.check_method,
|
||||
)
|
||||
db.add(domain)
|
||||
await db.flush()
|
||||
@ -240,7 +243,7 @@ async def refresh_domain(
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
):
|
||||
"""Manually refresh domain availability status."""
|
||||
"""Manually refresh domain availability status with a live check."""
|
||||
result = await db.execute(
|
||||
select(Domain).where(
|
||||
Domain.id == domain_id,
|
||||
@ -255,15 +258,19 @@ async def refresh_domain(
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
# Check domain
|
||||
# Track previous state for logging
|
||||
was_available = domain.is_available
|
||||
|
||||
# Check domain - always uses live data, no cache
|
||||
check_result = await domain_checker.check_domain(domain.name)
|
||||
|
||||
# Update domain
|
||||
domain.status = check_result.status
|
||||
domain.is_available = check_result.is_available
|
||||
domain.registrar = check_result.registrar
|
||||
domain.expiration_date = check_result.expiration_date
|
||||
domain.expiration_date = to_naive_utc(check_result.expiration_date)
|
||||
domain.last_checked = datetime.utcnow()
|
||||
domain.last_check_method = check_result.check_method
|
||||
|
||||
# Create check record
|
||||
check = DomainCheck(
|
||||
@ -278,9 +285,98 @@ async def refresh_domain(
|
||||
await db.commit()
|
||||
await db.refresh(domain)
|
||||
|
||||
# Log status changes
|
||||
if was_available != domain.is_available:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
if was_available and not domain.is_available:
|
||||
logger.info(f"Manual refresh: {domain.name} changed from AVAILABLE to TAKEN (registrar: {domain.registrar})")
|
||||
else:
|
||||
logger.info(f"Manual refresh: {domain.name} changed from TAKEN to AVAILABLE")
|
||||
|
||||
return domain
|
||||
|
||||
|
||||
@router.post("/refresh-all")
|
||||
async def refresh_all_domains(
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
):
|
||||
"""
|
||||
Refresh all domains in user's watchlist with live checks.
|
||||
|
||||
This is useful for bulk updates and to ensure all data is current.
|
||||
Returns summary of changes detected.
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
result = await db.execute(
|
||||
select(Domain).where(Domain.user_id == current_user.id)
|
||||
)
|
||||
domains = result.scalars().all()
|
||||
|
||||
if not domains:
|
||||
return {"message": "No domains to refresh", "checked": 0, "changes": []}
|
||||
|
||||
checked = 0
|
||||
errors = 0
|
||||
changes = []
|
||||
|
||||
for domain in domains:
|
||||
try:
|
||||
was_available = domain.is_available
|
||||
was_registrar = domain.registrar
|
||||
|
||||
# Live check
|
||||
check_result = await domain_checker.check_domain(domain.name)
|
||||
|
||||
# Track changes
|
||||
if was_available != check_result.is_available:
|
||||
change_type = "became_available" if check_result.is_available else "became_taken"
|
||||
changes.append({
|
||||
"domain": domain.name,
|
||||
"change": change_type,
|
||||
"old_registrar": was_registrar,
|
||||
"new_registrar": check_result.registrar,
|
||||
})
|
||||
logger.info(f"Bulk refresh: {domain.name} {change_type}")
|
||||
|
||||
# Update domain
|
||||
domain.status = check_result.status
|
||||
domain.is_available = check_result.is_available
|
||||
domain.registrar = check_result.registrar
|
||||
domain.expiration_date = to_naive_utc(check_result.expiration_date)
|
||||
domain.last_checked = datetime.utcnow()
|
||||
domain.last_check_method = check_result.check_method
|
||||
|
||||
# Create check record
|
||||
check = DomainCheck(
|
||||
domain_id=domain.id,
|
||||
status=check_result.status,
|
||||
is_available=check_result.is_available,
|
||||
response_data=str(check_result.to_dict()),
|
||||
checked_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(check)
|
||||
|
||||
checked += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing {domain.name}: {e}")
|
||||
errors += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Refreshed {checked} domains",
|
||||
"checked": checked,
|
||||
"errors": errors,
|
||||
"changes": changes,
|
||||
"total_domains": len(domains),
|
||||
}
|
||||
|
||||
|
||||
class NotifyUpdate(BaseModel):
|
||||
"""Schema for updating notification settings."""
|
||||
notify: bool
|
||||
|
||||
@ -8,18 +8,24 @@ API endpoints for accessing freshly dropped domains from:
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.database import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.zone_file import DroppedDomain
|
||||
from app.utils.datetime import to_iso_utc, to_naive_utc
|
||||
from app.services.zone_file import (
|
||||
ZoneFileService,
|
||||
get_dropped_domains,
|
||||
get_zone_stats,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/drops", tags=["drops"])
|
||||
|
||||
# All supported TLDs
|
||||
@ -175,3 +181,161 @@ async def api_get_supported_tlds():
|
||||
{"tld": "biz", "name": "Business", "flag": "💼", "registry": "GoDaddy", "source": "czds"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/check-status/{drop_id}")
|
||||
async def api_check_drop_status(
|
||||
drop_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Check the real-time availability status of a dropped domain.
|
||||
|
||||
Returns:
|
||||
- available: Domain can be registered NOW
|
||||
- dropping_soon: Domain is in deletion phase (track it!)
|
||||
- taken: Domain was re-registered
|
||||
- unknown: Could not determine status
|
||||
"""
|
||||
from app.services.drop_status_checker import check_drop_status
|
||||
|
||||
# Get the drop from DB
|
||||
result = await db.execute(
|
||||
select(DroppedDomain).where(DroppedDomain.id == drop_id)
|
||||
)
|
||||
drop = result.scalar_one_or_none()
|
||||
|
||||
if not drop:
|
||||
raise HTTPException(status_code=404, detail="Drop not found")
|
||||
|
||||
full_domain = f"{drop.domain}.{drop.tld}"
|
||||
|
||||
try:
|
||||
# Check with dedicated drop status checker
|
||||
status_result = await check_drop_status(full_domain)
|
||||
|
||||
persisted_deletion_date = to_naive_utc(status_result.deletion_date)
|
||||
|
||||
# Update the drop in DB
|
||||
await db.execute(
|
||||
update(DroppedDomain)
|
||||
.where(DroppedDomain.id == drop_id)
|
||||
.values(
|
||||
availability_status=status_result.status,
|
||||
rdap_status=str(status_result.rdap_status) if status_result.rdap_status else None,
|
||||
last_status_check=datetime.utcnow(),
|
||||
deletion_date=persisted_deletion_date,
|
||||
last_check_method=status_result.check_method,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"id": drop_id,
|
||||
"domain": full_domain,
|
||||
"status": status_result.status,
|
||||
"rdap_status": status_result.rdap_status,
|
||||
"can_register_now": status_result.can_register_now,
|
||||
"should_track": status_result.should_monitor,
|
||||
"message": status_result.message,
|
||||
"deletion_date": to_iso_utc(persisted_deletion_date),
|
||||
"status_checked_at": to_iso_utc(datetime.utcnow()),
|
||||
"status_source": status_result.check_method,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Status check failed for {full_domain}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/track/{drop_id}")
|
||||
async def api_track_drop(
|
||||
drop_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Add a dropped domain to the user's Watchlist.
|
||||
Will send notification when domain becomes available.
|
||||
|
||||
This is the same as adding to watchlist, but optimized for drops.
|
||||
"""
|
||||
from app.models.domain import Domain, DomainStatus
|
||||
|
||||
# Get the drop
|
||||
result = await db.execute(
|
||||
select(DroppedDomain).where(DroppedDomain.id == drop_id)
|
||||
)
|
||||
drop = result.scalar_one_or_none()
|
||||
|
||||
if not drop:
|
||||
raise HTTPException(status_code=404, detail="Drop not found")
|
||||
|
||||
full_domain = f"{drop.domain}.{drop.tld}"
|
||||
|
||||
# Check if already in watchlist
|
||||
existing = await db.execute(
|
||||
select(Domain).where(
|
||||
Domain.user_id == current_user.id,
|
||||
Domain.name == full_domain
|
||||
)
|
||||
)
|
||||
existing_domain = existing.scalar_one_or_none()
|
||||
if existing_domain:
|
||||
return {
|
||||
"status": "already_tracking",
|
||||
"domain": full_domain,
|
||||
"message": f"{full_domain} is already in your Watchlist",
|
||||
"domain_id": existing_domain.id
|
||||
}
|
||||
|
||||
try:
|
||||
# Map drop status to Domain status
|
||||
status_map = {
|
||||
'available': DomainStatus.AVAILABLE,
|
||||
'dropping_soon': DomainStatus.DROPPING_SOON,
|
||||
'taken': DomainStatus.TAKEN,
|
||||
'unknown': DomainStatus.UNKNOWN,
|
||||
}
|
||||
domain_status = status_map.get(drop.availability_status, DomainStatus.UNKNOWN)
|
||||
|
||||
# Add to watchlist with notification enabled
|
||||
domain = Domain(
|
||||
user_id=current_user.id,
|
||||
name=full_domain,
|
||||
status=domain_status,
|
||||
is_available=drop.availability_status == 'available',
|
||||
deletion_date=to_naive_utc(drop.deletion_date), # Copy deletion date for countdown
|
||||
notify_on_available=True, # Enable notification!
|
||||
last_checked=datetime.utcnow(),
|
||||
last_check_method="zone_drop",
|
||||
)
|
||||
db.add(domain)
|
||||
await db.commit()
|
||||
await db.refresh(domain)
|
||||
|
||||
return {
|
||||
"status": "tracking",
|
||||
"domain": full_domain,
|
||||
"message": f"Added {full_domain} to your Watchlist. You'll be notified when available!",
|
||||
"domain_id": domain.id
|
||||
}
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
# If duplicate key error, try to find existing
|
||||
existing = await db.execute(
|
||||
select(Domain).where(
|
||||
Domain.user_id == current_user.id,
|
||||
Domain.name == full_domain
|
||||
)
|
||||
)
|
||||
existing_domain = existing.scalar_one_or_none()
|
||||
if existing_domain:
|
||||
return {
|
||||
"status": "already_tracking",
|
||||
"domain": full_domain,
|
||||
"message": f"{full_domain} is already in your Watchlist",
|
||||
"domain_id": existing_domain.id
|
||||
}
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@ -675,16 +675,18 @@ async def create_listing(
|
||||
)
|
||||
listing_count = user_listings.scalar() or 0
|
||||
|
||||
# Listing limits by tier (from pounce_pricing.md)
|
||||
# Load subscription separately to avoid async lazy loading issues
|
||||
from app.models.subscription import Subscription
|
||||
# Listing limits by tier - using TIER_CONFIG
|
||||
from app.models.subscription import Subscription, TIER_CONFIG, SubscriptionTier
|
||||
sub_result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == current_user.id)
|
||||
)
|
||||
subscription = sub_result.scalar_one_or_none()
|
||||
tier = subscription.tier if subscription else "scout"
|
||||
limits = {"scout": 0, "trader": 5, "tycoon": 50}
|
||||
max_listings = limits.get(tier, 0)
|
||||
tier = subscription.tier if subscription else SubscriptionTier.SCOUT
|
||||
tier_config = TIER_CONFIG.get(tier, TIER_CONFIG[SubscriptionTier.SCOUT])
|
||||
max_listings = tier_config.get("listing_limit", 0)
|
||||
# -1 means unlimited
|
||||
if max_listings == -1:
|
||||
max_listings = 999999
|
||||
|
||||
if listing_count >= max_listings:
|
||||
raise HTTPException(
|
||||
@ -1474,3 +1476,168 @@ async def check_dns_verification(
|
||||
"message": "DNS check failed. Please try again in a few minutes.",
|
||||
}
|
||||
|
||||
|
||||
# ============== Inbox API (Unified Buyer + Seller) ==============
|
||||
|
||||
@router.get("/inbox/counts")
|
||||
async def get_inbox_counts(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get unread message/inquiry counts for both Buyer and Seller roles.
|
||||
Used for badge display in navigation.
|
||||
"""
|
||||
# BUYER: Count inquiries where there are unread seller messages
|
||||
# A message is "unread" for buyer if sender_user_id != buyer_user_id and no newer message from buyer
|
||||
buyer_inquiries = await db.execute(
|
||||
select(ListingInquiry).where(ListingInquiry.buyer_user_id == current_user.id)
|
||||
)
|
||||
buyer_inqs = list(buyer_inquiries.scalars().all())
|
||||
|
||||
buyer_unread = 0
|
||||
for inq in buyer_inqs:
|
||||
# Get the latest message
|
||||
latest_msg = await db.execute(
|
||||
select(ListingInquiryMessage)
|
||||
.where(ListingInquiryMessage.inquiry_id == inq.id)
|
||||
.order_by(ListingInquiryMessage.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
msg = latest_msg.scalar_one_or_none()
|
||||
# If latest message is from seller (not buyer), it's unread
|
||||
if msg and msg.sender_user_id != current_user.id:
|
||||
buyer_unread += 1
|
||||
|
||||
# SELLER: Count new/unread inquiries across all listings
|
||||
seller_listings = await db.execute(
|
||||
select(DomainListing.id).where(DomainListing.user_id == current_user.id)
|
||||
)
|
||||
listing_ids = [lid for (lid,) in seller_listings.fetchall()]
|
||||
|
||||
seller_unread = 0
|
||||
if listing_ids:
|
||||
# Count inquiries that are 'new' (never read)
|
||||
new_count = await db.execute(
|
||||
select(func.count(ListingInquiry.id)).where(
|
||||
and_(
|
||||
ListingInquiry.listing_id.in_(listing_ids),
|
||||
ListingInquiry.status == "new",
|
||||
)
|
||||
)
|
||||
)
|
||||
seller_unread = new_count.scalar() or 0
|
||||
|
||||
# Also count inquiries where latest message is from buyer (unread reply)
|
||||
for lid in listing_ids:
|
||||
inqs_result = await db.execute(
|
||||
select(ListingInquiry).where(
|
||||
and_(
|
||||
ListingInquiry.listing_id == lid,
|
||||
ListingInquiry.status.notin_(["closed", "spam"]),
|
||||
)
|
||||
)
|
||||
)
|
||||
for inq in inqs_result.scalars().all():
|
||||
if inq.status == "new":
|
||||
continue # Already counted
|
||||
latest_msg = await db.execute(
|
||||
select(ListingInquiryMessage)
|
||||
.where(ListingInquiryMessage.inquiry_id == inq.id)
|
||||
.order_by(ListingInquiryMessage.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
msg = latest_msg.scalar_one_or_none()
|
||||
# If latest message is from buyer (not seller), it's unread for seller
|
||||
if msg and msg.sender_user_id != current_user.id:
|
||||
seller_unread += 1
|
||||
|
||||
return {
|
||||
"buyer_unread": buyer_unread,
|
||||
"seller_unread": seller_unread,
|
||||
"total_unread": buyer_unread + seller_unread,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/inbox/seller")
|
||||
async def get_seller_inbox(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
status_filter: Optional[str] = Query(None, enum=["all", "new", "read", "replied", "closed", "spam"]),
|
||||
):
|
||||
"""
|
||||
Seller Inbox: Get all inquiries across all listings owned by the current user.
|
||||
This provides a unified inbox view for sellers.
|
||||
"""
|
||||
# Get all listings owned by user
|
||||
listings_result = await db.execute(
|
||||
select(DomainListing).where(DomainListing.user_id == current_user.id)
|
||||
)
|
||||
listings = {l.id: l for l in listings_result.scalars().all()}
|
||||
|
||||
if not listings:
|
||||
return {"inquiries": [], "total": 0, "unread": 0}
|
||||
|
||||
# Build query for inquiries
|
||||
query = (
|
||||
select(ListingInquiry)
|
||||
.where(ListingInquiry.listing_id.in_(listings.keys()))
|
||||
.order_by(ListingInquiry.created_at.desc())
|
||||
)
|
||||
|
||||
if status_filter and status_filter != "all":
|
||||
query = query.where(ListingInquiry.status == status_filter)
|
||||
|
||||
result = await db.execute(query)
|
||||
inquiries = list(result.scalars().all())
|
||||
|
||||
# Count unread
|
||||
unread_count = sum(1 for inq in inquiries if inq.status == "new" or not inq.read_at)
|
||||
|
||||
# Build response with listing info
|
||||
response_items = []
|
||||
for inq in inquiries:
|
||||
listing = listings.get(inq.listing_id)
|
||||
if not listing:
|
||||
continue
|
||||
|
||||
# Get latest message for preview
|
||||
latest_msg_result = await db.execute(
|
||||
select(ListingInquiryMessage)
|
||||
.where(ListingInquiryMessage.inquiry_id == inq.id)
|
||||
.order_by(ListingInquiryMessage.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
latest_msg = latest_msg_result.scalar_one_or_none()
|
||||
|
||||
# Check if has unread reply from buyer
|
||||
has_unread_reply = False
|
||||
if latest_msg and latest_msg.sender_user_id != current_user.id and inq.status not in ["closed", "spam"]:
|
||||
has_unread_reply = True
|
||||
|
||||
response_items.append({
|
||||
"id": inq.id,
|
||||
"listing_id": listing.id,
|
||||
"domain": listing.domain,
|
||||
"slug": listing.slug,
|
||||
"buyer_name": inq.name,
|
||||
"buyer_email": inq.email,
|
||||
"offer_amount": inq.offer_amount,
|
||||
"status": inq.status,
|
||||
"created_at": inq.created_at.isoformat(),
|
||||
"read_at": inq.read_at.isoformat() if inq.read_at else None,
|
||||
"replied_at": getattr(inq, "replied_at", None),
|
||||
"closed_at": inq.closed_at.isoformat() if getattr(inq, "closed_at", None) else None,
|
||||
"closed_reason": getattr(inq, "closed_reason", None),
|
||||
"has_unread_reply": has_unread_reply,
|
||||
"last_message_preview": (latest_msg.body[:100] + "..." if len(latest_msg.body) > 100 else latest_msg.body) if latest_msg else inq.message[:100],
|
||||
"last_message_at": latest_msg.created_at.isoformat() if latest_msg else inq.created_at.isoformat(),
|
||||
"last_message_is_buyer": latest_msg.sender_user_id != current_user.id if latest_msg else True,
|
||||
})
|
||||
|
||||
return {
|
||||
"inquiries": response_items,
|
||||
"total": len(response_items),
|
||||
"unread": unread_count,
|
||||
}
|
||||
|
||||
|
||||
93
backend/app/api/llm.py
Normal file
93
backend/app/api/llm.py
Normal file
@ -0,0 +1,93 @@
|
||||
"""
|
||||
LLM API endpoints (Pounce -> Ollama Gateway).
|
||||
|
||||
This is intentionally a thin proxy:
|
||||
- Enforces Pounce authentication (HttpOnly cookie)
|
||||
- Enforces tier gating (Trader/Tycoon)
|
||||
- Proxies to the internal LLM gateway (which talks to Ollama)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import CurrentUser, Database
|
||||
from app.config import get_settings
|
||||
from app.models.subscription import Subscription, SubscriptionTier
|
||||
from app.services.llm_gateway import LLMGatewayError, chat_completions, chat_completions_stream
|
||||
|
||||
|
||||
router = APIRouter(prefix="/llm", tags=["LLM"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: Literal["system", "user", "assistant"]
|
||||
content: str
|
||||
|
||||
|
||||
class ChatCompletionsRequest(BaseModel):
|
||||
model: Optional[str] = None
|
||||
messages: list[ChatMessage] = Field(default_factory=list, min_length=1)
|
||||
temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
||||
stream: bool = False
|
||||
|
||||
|
||||
async def _get_or_create_subscription(db: Database, user_id: int) -> Subscription:
|
||||
res = await db.execute(select(Subscription).where(Subscription.user_id == user_id))
|
||||
sub = res.scalar_one_or_none()
|
||||
if sub:
|
||||
return sub
|
||||
sub = Subscription(user_id=user_id, tier=SubscriptionTier.SCOUT, max_domains=5, check_frequency="daily")
|
||||
db.add(sub)
|
||||
await db.commit()
|
||||
await db.refresh(sub)
|
||||
return sub
|
||||
|
||||
|
||||
def _require_trader_or_higher(sub: Subscription) -> None:
|
||||
if sub.tier not in (SubscriptionTier.TRADER, SubscriptionTier.TYCOON):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Chat is available on Trader and Tycoon plans. Upgrade to unlock.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/chat/completions")
|
||||
async def llm_chat_completions(
|
||||
req: ChatCompletionsRequest,
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
):
|
||||
"""
|
||||
Proxy Chat Completions to internal Ollama gateway.
|
||||
Returns OpenAI-ish JSON or SSE when stream=true.
|
||||
"""
|
||||
sub = await _get_or_create_subscription(db, current_user.id)
|
||||
_require_trader_or_higher(sub)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": (req.model or settings.llm_default_model),
|
||||
"messages": [m.model_dump() for m in req.messages],
|
||||
"temperature": req.temperature,
|
||||
"stream": bool(req.stream),
|
||||
}
|
||||
|
||||
try:
|
||||
if req.stream:
|
||||
return StreamingResponse(
|
||||
chat_completions_stream(payload),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||
)
|
||||
data = await chat_completions(payload)
|
||||
return JSONResponse(data)
|
||||
except LLMGatewayError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
|
||||
171
backend/app/api/llm_naming.py
Normal file
171
backend/app/api/llm_naming.py
Normal file
@ -0,0 +1,171 @@
|
||||
"""
|
||||
API endpoints for LLM-powered naming features.
|
||||
Used by Trends and Forge tabs in the Hunt page.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.subscription import Subscription, SubscriptionTier
|
||||
from app.models.user import User
|
||||
from app.services.llm_naming import (
|
||||
expand_trend_keywords,
|
||||
analyze_trend,
|
||||
generate_brandable_names,
|
||||
generate_similar_names,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/naming", tags=["LLM Naming"])
|
||||
|
||||
|
||||
def _tier_level(tier: str) -> int:
|
||||
t = (tier or "").lower()
|
||||
if t == "tycoon":
|
||||
return 3
|
||||
if t == "trader":
|
||||
return 2
|
||||
return 1
|
||||
|
||||
|
||||
async def _get_user_tier(db: AsyncSession, user: User) -> str:
|
||||
res = await db.execute(select(Subscription).where(Subscription.user_id == user.id))
|
||||
sub = res.scalar_one_or_none()
|
||||
if not sub:
|
||||
return "scout"
|
||||
return sub.tier.value
|
||||
|
||||
|
||||
async def _require_trader_or_above(db: AsyncSession, user: User):
|
||||
"""Check that user has at least Trader tier."""
|
||||
tier = await _get_user_tier(db, user)
|
||||
if _tier_level(tier) < 2:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="AI naming features require Trader or Tycoon plan."
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TRENDS TAB ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
class TrendExpandRequest(BaseModel):
|
||||
trend: str = Field(..., min_length=1, max_length=100)
|
||||
geo: str = Field(default="US", max_length=5)
|
||||
|
||||
|
||||
class TrendExpandResponse(BaseModel):
|
||||
keywords: list[str]
|
||||
trend: str
|
||||
|
||||
|
||||
@router.post("/trends/expand", response_model=TrendExpandResponse)
|
||||
async def expand_trend(
|
||||
request: TrendExpandRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Expand a trending topic into related domain-friendly keywords.
|
||||
Requires Trader or Tycoon subscription.
|
||||
"""
|
||||
await _require_trader_or_above(db, current_user)
|
||||
|
||||
keywords = await expand_trend_keywords(request.trend, request.geo)
|
||||
return TrendExpandResponse(keywords=keywords, trend=request.trend)
|
||||
|
||||
|
||||
class TrendAnalyzeRequest(BaseModel):
|
||||
trend: str = Field(..., min_length=1, max_length=100)
|
||||
geo: str = Field(default="US", max_length=5)
|
||||
|
||||
|
||||
class TrendAnalyzeResponse(BaseModel):
|
||||
analysis: str
|
||||
trend: str
|
||||
|
||||
|
||||
@router.post("/trends/analyze", response_model=TrendAnalyzeResponse)
|
||||
async def analyze_trend_endpoint(
|
||||
request: TrendAnalyzeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get AI analysis of a trending topic for domain investors.
|
||||
Requires Trader or Tycoon subscription.
|
||||
"""
|
||||
await _require_trader_or_above(db, current_user)
|
||||
|
||||
analysis = await analyze_trend(request.trend, request.geo)
|
||||
return TrendAnalyzeResponse(analysis=analysis, trend=request.trend)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FORGE TAB ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
class BrandableGenerateRequest(BaseModel):
|
||||
concept: str = Field(..., min_length=3, max_length=200)
|
||||
style: Optional[str] = Field(default=None, max_length=50)
|
||||
count: int = Field(default=15, ge=5, le=30)
|
||||
|
||||
|
||||
class BrandableGenerateResponse(BaseModel):
|
||||
names: list[str]
|
||||
concept: str
|
||||
|
||||
|
||||
@router.post("/forge/generate", response_model=BrandableGenerateResponse)
|
||||
async def generate_brandables(
|
||||
request: BrandableGenerateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Generate brandable domain names based on a concept description.
|
||||
Requires Trader or Tycoon subscription.
|
||||
"""
|
||||
await _require_trader_or_above(db, current_user)
|
||||
|
||||
names = await generate_brandable_names(
|
||||
request.concept,
|
||||
style=request.style,
|
||||
count=request.count
|
||||
)
|
||||
return BrandableGenerateResponse(names=names, concept=request.concept)
|
||||
|
||||
|
||||
class SimilarNamesRequest(BaseModel):
|
||||
brand: str = Field(..., min_length=2, max_length=50)
|
||||
count: int = Field(default=12, ge=5, le=20)
|
||||
|
||||
|
||||
class SimilarNamesResponse(BaseModel):
|
||||
names: list[str]
|
||||
brand: str
|
||||
|
||||
|
||||
@router.post("/forge/similar", response_model=SimilarNamesResponse)
|
||||
async def generate_similar(
|
||||
request: SimilarNamesRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Generate names similar to an existing brand.
|
||||
Requires Trader or Tycoon subscription.
|
||||
"""
|
||||
await _require_trader_or_above(db, current_user)
|
||||
|
||||
names = await generate_similar_names(request.brand, count=request.count)
|
||||
return SimilarNamesResponse(names=names, brand=request.brand)
|
||||
|
||||
232
backend/app/api/llm_vision.py
Normal file
232
backend/app/api/llm_vision.py
Normal file
@ -0,0 +1,232 @@
|
||||
"""
|
||||
Vision API (Terminal-only).
|
||||
|
||||
- Trader + Tycoon: can generate Vision JSON (cached in DB)
|
||||
- Scout: receives a 403 with an upgrade teaser message
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, select
|
||||
|
||||
from app.api.deps import CurrentUser, Database
|
||||
from app.models.llm_artifact import LLMArtifact
|
||||
from app.models.subscription import Subscription, SubscriptionTier
|
||||
from app.services.llm_gateway import LLMGatewayError
|
||||
from app.services.llm_vision import (
|
||||
VISION_PROMPT_VERSION,
|
||||
YIELD_LANDING_PROMPT_VERSION,
|
||||
VisionResult,
|
||||
YieldLandingConfig,
|
||||
generate_vision,
|
||||
generate_yield_landing,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/llm", tags=["LLM Vision"])
|
||||
|
||||
|
||||
class VisionResponse(BaseModel):
|
||||
domain: str
|
||||
cached: bool
|
||||
model: str
|
||||
prompt_version: str
|
||||
generated_at: str
|
||||
result: VisionResult
|
||||
|
||||
|
||||
class YieldLandingPreviewResponse(BaseModel):
|
||||
domain: str
|
||||
cached: bool
|
||||
model: str
|
||||
prompt_version: str
|
||||
generated_at: str
|
||||
result: YieldLandingConfig
|
||||
|
||||
|
||||
async def _get_or_create_subscription(db: Database, user_id: int) -> Subscription:
|
||||
res = await db.execute(select(Subscription).where(Subscription.user_id == user_id))
|
||||
sub = res.scalar_one_or_none()
|
||||
if sub:
|
||||
return sub
|
||||
sub = Subscription(user_id=user_id, tier=SubscriptionTier.SCOUT, max_domains=5, check_frequency="daily")
|
||||
db.add(sub)
|
||||
await db.commit()
|
||||
await db.refresh(sub)
|
||||
return sub
|
||||
|
||||
|
||||
def _require_trader_or_higher(sub: Subscription) -> None:
|
||||
if sub.tier not in (SubscriptionTier.TRADER, SubscriptionTier.TYCOON):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Vision is available on Trader and Tycoon plans. Upgrade to unlock.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/vision", response_model=VisionResponse)
|
||||
async def get_vision(
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
domain: str = Query(..., min_length=3, max_length=255),
|
||||
refresh: bool = Query(False, description="Bypass cache and regenerate"),
|
||||
):
|
||||
sub = await _get_or_create_subscription(db, current_user.id)
|
||||
_require_trader_or_higher(sub)
|
||||
|
||||
normalized = domain.strip().lower()
|
||||
now = datetime.utcnow()
|
||||
ttl_days = 30
|
||||
|
||||
if not refresh:
|
||||
cached = (
|
||||
await db.execute(
|
||||
select(LLMArtifact)
|
||||
.where(
|
||||
and_(
|
||||
LLMArtifact.kind == "vision_v1",
|
||||
LLMArtifact.domain == normalized,
|
||||
LLMArtifact.prompt_version == VISION_PROMPT_VERSION,
|
||||
(LLMArtifact.expires_at.is_(None) | (LLMArtifact.expires_at > now)),
|
||||
)
|
||||
)
|
||||
.order_by(LLMArtifact.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if cached:
|
||||
try:
|
||||
payload = json.loads(cached.payload_json)
|
||||
result = VisionResult.model_validate(payload)
|
||||
except Exception:
|
||||
# Corrupt cache: regenerate.
|
||||
cached = None
|
||||
else:
|
||||
return VisionResponse(
|
||||
domain=normalized,
|
||||
cached=True,
|
||||
model=cached.model,
|
||||
prompt_version=cached.prompt_version,
|
||||
generated_at=cached.created_at.isoformat(),
|
||||
result=result,
|
||||
)
|
||||
|
||||
try:
|
||||
result, model_used = await generate_vision(normalized)
|
||||
except LLMGatewayError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Vision generation failed: {e}")
|
||||
|
||||
artifact = LLMArtifact(
|
||||
user_id=current_user.id,
|
||||
kind="vision_v1",
|
||||
domain=normalized,
|
||||
prompt_version=VISION_PROMPT_VERSION,
|
||||
model=model_used,
|
||||
payload_json=result.model_dump_json(),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
expires_at=now + timedelta(days=ttl_days),
|
||||
)
|
||||
db.add(artifact)
|
||||
await db.commit()
|
||||
|
||||
return VisionResponse(
|
||||
domain=normalized,
|
||||
cached=False,
|
||||
model=model_used,
|
||||
prompt_version=VISION_PROMPT_VERSION,
|
||||
generated_at=now.isoformat(),
|
||||
result=result,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/yield/landing-preview", response_model=YieldLandingPreviewResponse)
|
||||
async def get_yield_landing_preview(
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
domain: str = Query(..., min_length=3, max_length=255),
|
||||
refresh: bool = Query(False, description="Bypass cache and regenerate"),
|
||||
):
|
||||
"""
|
||||
Generate a Yield landing page configuration preview for Terminal UX.
|
||||
|
||||
Trader + Tycoon: allowed.
|
||||
Scout: blocked (upgrade teaser).
|
||||
"""
|
||||
sub = await _get_or_create_subscription(db, current_user.id)
|
||||
_require_trader_or_higher(sub)
|
||||
|
||||
normalized = domain.strip().lower()
|
||||
now = datetime.utcnow()
|
||||
ttl_days = 30
|
||||
|
||||
if not refresh:
|
||||
cached = (
|
||||
await db.execute(
|
||||
select(LLMArtifact)
|
||||
.where(
|
||||
and_(
|
||||
LLMArtifact.kind == "yield_landing_preview_v1",
|
||||
LLMArtifact.domain == normalized,
|
||||
LLMArtifact.prompt_version == YIELD_LANDING_PROMPT_VERSION,
|
||||
(LLMArtifact.expires_at.is_(None) | (LLMArtifact.expires_at > now)),
|
||||
)
|
||||
)
|
||||
.order_by(LLMArtifact.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if cached:
|
||||
try:
|
||||
payload = json.loads(cached.payload_json)
|
||||
result = YieldLandingConfig.model_validate(payload)
|
||||
except Exception:
|
||||
cached = None
|
||||
else:
|
||||
return YieldLandingPreviewResponse(
|
||||
domain=normalized,
|
||||
cached=True,
|
||||
model=cached.model,
|
||||
prompt_version=cached.prompt_version,
|
||||
generated_at=cached.created_at.isoformat(),
|
||||
result=result,
|
||||
)
|
||||
|
||||
try:
|
||||
result, model_used = await generate_yield_landing(normalized)
|
||||
except LLMGatewayError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Landing preview generation failed: {e}")
|
||||
|
||||
artifact = LLMArtifact(
|
||||
user_id=current_user.id,
|
||||
kind="yield_landing_preview_v1",
|
||||
domain=normalized,
|
||||
prompt_version=YIELD_LANDING_PROMPT_VERSION,
|
||||
model=model_used,
|
||||
payload_json=result.model_dump_json(),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
expires_at=now + timedelta(days=ttl_days),
|
||||
)
|
||||
db.add(artifact)
|
||||
await db.commit()
|
||||
|
||||
return YieldLandingPreviewResponse(
|
||||
domain=normalized,
|
||||
cached=False,
|
||||
model=model_used,
|
||||
prompt_version=YIELD_LANDING_PROMPT_VERSION,
|
||||
generated_at=now.isoformat(),
|
||||
result=result,
|
||||
)
|
||||
|
||||
@ -780,9 +780,9 @@ async def start_dns_verification(
|
||||
domain=domain.domain,
|
||||
verification_code=domain.verification_code,
|
||||
dns_record_type="TXT",
|
||||
dns_record_name=f"_pounce.{domain.domain}",
|
||||
dns_record_name="@",
|
||||
dns_record_value=domain.verification_code,
|
||||
instructions=f"Add a TXT record to your DNS settings:\n\nHost/Name: _pounce\nType: TXT\nValue: {domain.verification_code}\n\nDNS changes can take up to 48 hours to propagate, but usually complete within minutes.",
|
||||
instructions=f"Add a TXT record to your DNS settings:\n\nHost/Name: @ (or leave empty)\nType: TXT\nValue: {domain.verification_code}\n\nDNS changes usually propagate within 5 minutes.",
|
||||
status=domain.verification_status,
|
||||
)
|
||||
|
||||
@ -796,7 +796,7 @@ async def check_dns_verification(
|
||||
"""
|
||||
Check if DNS verification is complete.
|
||||
|
||||
Looks for the TXT record and verifies it matches the expected code.
|
||||
Looks for the TXT record at root domain and verifies it contains the expected code.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PortfolioDomain).where(
|
||||
@ -827,8 +827,7 @@ async def check_dns_verification(
|
||||
detail="Verification not started. Call POST /verify-dns first.",
|
||||
)
|
||||
|
||||
# Check DNS TXT record
|
||||
txt_record_name = f"_pounce.{domain.domain}"
|
||||
# Check DNS TXT record at ROOT domain (simpler for users)
|
||||
verified = False
|
||||
|
||||
try:
|
||||
@ -836,24 +835,26 @@ async def check_dns_verification(
|
||||
resolver.timeout = 5
|
||||
resolver.lifetime = 10
|
||||
|
||||
answers = resolver.resolve(txt_record_name, 'TXT')
|
||||
# Check ROOT domain TXT records
|
||||
answers = resolver.resolve(domain.domain, 'TXT')
|
||||
|
||||
for rdata in answers:
|
||||
txt_value = rdata.to_text().strip('"')
|
||||
if txt_value == domain.verification_code:
|
||||
# Check if verification code is present anywhere in TXT records
|
||||
if domain.verification_code in txt_value:
|
||||
verified = True
|
||||
break
|
||||
except dns.resolver.NXDOMAIN:
|
||||
return DNSVerificationCheckResponse(
|
||||
verified=False,
|
||||
status="pending",
|
||||
message=f"TXT record not found. Please add a TXT record at _pounce.{domain.domain}",
|
||||
message=f"Domain {domain.domain} not found in DNS. Check your domain configuration.",
|
||||
)
|
||||
except dns.resolver.NoAnswer:
|
||||
return DNSVerificationCheckResponse(
|
||||
verified=False,
|
||||
status="pending",
|
||||
message="TXT record exists but has no value. Check your DNS configuration.",
|
||||
message=f"No TXT records found for {domain.domain}. Please add the TXT record.",
|
||||
)
|
||||
except dns.resolver.Timeout:
|
||||
return DNSVerificationCheckResponse(
|
||||
@ -883,6 +884,6 @@ async def check_dns_verification(
|
||||
return DNSVerificationCheckResponse(
|
||||
verified=False,
|
||||
status="pending",
|
||||
message=f"TXT record found but value doesn't match. Expected: {domain.verification_code}",
|
||||
message=f"TXT record found but verification code not detected. Make sure your TXT record contains: {domain.verification_code}",
|
||||
)
|
||||
|
||||
|
||||
@ -187,9 +187,10 @@ async def create_sniper_alert(
|
||||
)
|
||||
alert_count = user_alerts.scalar() or 0
|
||||
|
||||
tier = current_user.subscription.tier if current_user.subscription else "scout"
|
||||
limits = {"scout": 2, "trader": 10, "tycoon": 50}
|
||||
max_alerts = limits.get(tier, 2)
|
||||
from app.models.subscription import TIER_CONFIG, SubscriptionTier
|
||||
tier = current_user.subscription.tier if current_user.subscription else SubscriptionTier.SCOUT
|
||||
tier_config = TIER_CONFIG.get(tier, TIER_CONFIG[SubscriptionTier.SCOUT])
|
||||
max_alerts = tier_config.get("sniper_limit", 2)
|
||||
|
||||
if alert_count >= max_alerts:
|
||||
raise HTTPException(
|
||||
|
||||
@ -310,9 +310,13 @@ async def cancel_subscription(
|
||||
"""
|
||||
Cancel subscription and downgrade to Scout.
|
||||
|
||||
Note: For Stripe-managed subscriptions, use the Customer Portal instead.
|
||||
This endpoint is for manual cancellation.
|
||||
This will:
|
||||
1. Cancel the subscription in Stripe (if exists)
|
||||
2. Downgrade the user to Scout tier locally
|
||||
"""
|
||||
from app.services.stripe_service import StripeService
|
||||
from datetime import datetime
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == current_user.id)
|
||||
)
|
||||
@ -330,12 +334,24 @@ async def cancel_subscription(
|
||||
detail="Already on free plan",
|
||||
)
|
||||
|
||||
# Downgrade to Scout
|
||||
old_tier = subscription.tier.value
|
||||
stripe_sub_id = subscription.stripe_subscription_id
|
||||
|
||||
# Cancel in Stripe first (if we have a Stripe subscription)
|
||||
if stripe_sub_id:
|
||||
cancelled = await StripeService.cancel_subscription(stripe_sub_id)
|
||||
if not cancelled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to cancel subscription in Stripe. Please try again or contact support.",
|
||||
)
|
||||
|
||||
# Downgrade to Scout locally
|
||||
subscription.tier = SubscriptionTier.SCOUT
|
||||
subscription.max_domains = TIER_CONFIG[SubscriptionTier.SCOUT]["domain_limit"]
|
||||
subscription.check_frequency = TIER_CONFIG[SubscriptionTier.SCOUT]["check_frequency"]
|
||||
subscription.stripe_subscription_id = None
|
||||
subscription.cancelled_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
@ -343,4 +359,5 @@ async def cancel_subscription(
|
||||
"status": "cancelled",
|
||||
"message": f"Subscription cancelled. Downgraded from {old_tier} to Scout.",
|
||||
"new_tier": "scout",
|
||||
"stripe_cancelled": bool(stripe_sub_id),
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy import func, and_, or_, Integer, case, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
@ -37,6 +38,7 @@ from app.schemas.yield_domain import (
|
||||
DNSSetupInstructions,
|
||||
ActivateYieldRequest,
|
||||
ActivateYieldResponse,
|
||||
YieldLandingPreview,
|
||||
)
|
||||
from app.services.intent_detector import (
|
||||
detect_domain_intent,
|
||||
@ -101,9 +103,10 @@ async def get_yield_dashboard(
|
||||
"""
|
||||
Get yield dashboard with stats, domains, and recent transactions.
|
||||
"""
|
||||
# Get user's yield domains
|
||||
# Get user's yield domains with partner relationship eagerly loaded
|
||||
result = await db.execute(
|
||||
select(YieldDomain)
|
||||
.options(selectinload(YieldDomain.partner))
|
||||
.where(YieldDomain.user_id == current_user.id)
|
||||
.order_by(YieldDomain.total_revenue.desc())
|
||||
)
|
||||
@ -343,13 +346,19 @@ async def activate_domain_for_yield(
|
||||
tier = subscription.tier if subscription else SubscriptionTier.SCOUT
|
||||
tier_value = tier.value if hasattr(tier, "value") else str(tier)
|
||||
|
||||
if tier_value == "scout":
|
||||
# Check if tier has yield feature
|
||||
from app.models.subscription import TIER_CONFIG
|
||||
tier_config = TIER_CONFIG.get(tier, {})
|
||||
has_yield = tier_config.get("features", {}).get("yield", False)
|
||||
|
||||
if not has_yield:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Yield is not available on Scout plan. Upgrade to Trader or Tycoon.",
|
||||
detail="Yield is available on Tycoon plan only. Upgrade to unlock.",
|
||||
)
|
||||
|
||||
max_yield_domains = 5 if tier_value == "trader" else 10_000_000
|
||||
# Yield limits: Trader = 10, Tycoon = unlimited
|
||||
max_yield_domains = 10 if tier_value == "trader" else 10_000_000
|
||||
user_domain_count = (
|
||||
await db.execute(
|
||||
select(func.count(YieldDomain.id)).where(YieldDomain.user_id == current_user.id)
|
||||
@ -381,6 +390,11 @@ async def activate_domain_for_yield(
|
||||
# Analyze domain intent
|
||||
intent_result = detect_domain_intent(domain)
|
||||
value_estimate = get_intent_detector().estimate_value(domain)
|
||||
|
||||
# Generate landing page config (Tycoon-only yield requirement)
|
||||
# No fallback: if the LLM gateway is unavailable, activation must fail.
|
||||
from app.services.llm_vision import generate_yield_landing
|
||||
landing_cfg, landing_model = await generate_yield_landing(domain)
|
||||
|
||||
# Create yield domain record
|
||||
yield_domain = YieldDomain(
|
||||
@ -390,6 +404,13 @@ async def activate_domain_for_yield(
|
||||
intent_confidence=intent_result.confidence,
|
||||
intent_keywords=json.dumps(intent_result.keywords_matched),
|
||||
status="pending",
|
||||
landing_config_json=landing_cfg.model_dump_json(),
|
||||
landing_template=landing_cfg.template,
|
||||
landing_headline=landing_cfg.headline,
|
||||
landing_intro=landing_cfg.seo_intro,
|
||||
landing_cta_label=landing_cfg.cta_label,
|
||||
landing_model=landing_model,
|
||||
landing_generated_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
# Find best matching partner
|
||||
@ -442,6 +463,14 @@ async def activate_domain_for_yield(
|
||||
geo=value_estimate["geo"],
|
||||
),
|
||||
dns_instructions=dns_instructions,
|
||||
landing=YieldLandingPreview(
|
||||
template=yield_domain.landing_template or "generic",
|
||||
headline=yield_domain.landing_headline or "",
|
||||
seo_intro=yield_domain.landing_intro or "",
|
||||
cta_label=yield_domain.landing_cta_label or "View offers",
|
||||
model=getattr(yield_domain, "landing_model", None),
|
||||
generated_at=getattr(yield_domain, "landing_generated_at", None),
|
||||
),
|
||||
message="Domain registered! Point your DNS to our nameservers to complete activation.",
|
||||
)
|
||||
|
||||
@ -500,8 +529,10 @@ async def verify_domain_dns(
|
||||
return DNSVerificationResult(
|
||||
domain=domain.domain,
|
||||
verified=verified,
|
||||
method=check.method,
|
||||
expected_ns=settings.yield_nameserver_list,
|
||||
actual_ns=actual_ns,
|
||||
actual_ns=check.actual_ns,
|
||||
actual_a=check.actual_a,
|
||||
cname_ok=check.cname_ok if verified else False,
|
||||
error=error,
|
||||
checked_at=datetime.utcnow(),
|
||||
@ -745,6 +776,14 @@ async def list_partners(
|
||||
|
||||
def _domain_to_response(domain: YieldDomain) -> YieldDomainResponse:
|
||||
"""Convert YieldDomain model to response schema."""
|
||||
# Safely get partner name
|
||||
partner_name = None
|
||||
try:
|
||||
if domain.partner:
|
||||
partner_name = domain.partner.name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return YieldDomainResponse(
|
||||
id=domain.id,
|
||||
domain=domain.domain,
|
||||
@ -752,7 +791,13 @@ def _domain_to_response(domain: YieldDomain) -> YieldDomainResponse:
|
||||
detected_intent=domain.detected_intent,
|
||||
intent_confidence=domain.intent_confidence,
|
||||
active_route=domain.active_route,
|
||||
partner_name=domain.partner.name if domain.partner else None,
|
||||
partner_name=partner_name,
|
||||
landing_template=getattr(domain, "landing_template", None),
|
||||
landing_headline=getattr(domain, "landing_headline", None),
|
||||
landing_intro=getattr(domain, "landing_intro", None),
|
||||
landing_cta_label=getattr(domain, "landing_cta_label", None),
|
||||
landing_model=getattr(domain, "landing_model", None),
|
||||
landing_generated_at=getattr(domain, "landing_generated_at", None),
|
||||
dns_verified=domain.dns_verified,
|
||||
dns_verified_at=domain.dns_verified_at,
|
||||
connected_at=getattr(domain, "connected_at", None),
|
||||
|
||||
@ -7,7 +7,7 @@ This handles incoming HTTP requests to yield domains:
|
||||
3. Track the click
|
||||
4. Redirect to the appropriate affiliate landing page
|
||||
|
||||
In production, this runs on a separate subdomain or IP (yield.pounce.io)
|
||||
In production, this runs on a separate subdomain or IP (yield.pounce.ch)
|
||||
that yield domains CNAME to.
|
||||
"""
|
||||
|
||||
@ -18,7 +18,7 @@ from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@ -27,6 +27,7 @@ from app.config import get_settings
|
||||
from app.models.yield_domain import YieldDomain, YieldTransaction, AffiliatePartner
|
||||
from app.services.intent_detector import detect_domain_intent
|
||||
from app.services.telemetry import track_event
|
||||
from app.services.yield_landing_page import render_yield_landing_html
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
@ -105,7 +106,7 @@ async def route_yield_domain(
|
||||
domain: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
direct: bool = Query(True, description="Direct redirect without landing page"),
|
||||
direct: bool = Query(False, description="Direct redirect without landing page"),
|
||||
):
|
||||
"""
|
||||
Route traffic for a yield domain.
|
||||
@ -167,6 +168,29 @@ async def route_yield_domain(
|
||||
if not partner:
|
||||
raise HTTPException(status_code=503, detail="No active partner available for this domain intent.")
|
||||
|
||||
# Landing page mode: do NOT record a click yet.
|
||||
# The CTA will call this endpoint again with direct=true, which records the click + redirects.
|
||||
if not direct:
|
||||
cta_url = f"/api/v1/r/{yield_domain.domain}?direct=true"
|
||||
try:
|
||||
html = render_yield_landing_html(yield_domain=yield_domain, cta_url=cta_url)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=503, detail=f"Landing page not available: {e}")
|
||||
|
||||
await track_event(
|
||||
db,
|
||||
event_name="yield_landing_view",
|
||||
request=request,
|
||||
user_id=yield_domain.user_id,
|
||||
is_authenticated=None,
|
||||
source="routing",
|
||||
domain=yield_domain.domain,
|
||||
yield_domain_id=yield_domain.id,
|
||||
metadata={"partner": partner.slug},
|
||||
)
|
||||
await db.commit()
|
||||
return HTMLResponse(content=html, status_code=200)
|
||||
|
||||
# Rate limit: max 120 clicks/10min per IP per domain
|
||||
client_ip = _get_client_ip(request)
|
||||
ip_hash = hash_ip(client_ip) if client_ip else None
|
||||
@ -241,7 +265,6 @@ async def route_yield_domain(
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Only direct redirect for MVP
|
||||
return RedirectResponse(url=destination_url, status_code=302)
|
||||
|
||||
|
||||
@ -272,7 +295,7 @@ async def catch_all_route(
|
||||
is the yield domain itself (e.g., zahnarzt-zuerich.ch).
|
||||
|
||||
This requires:
|
||||
1. Yield domains to CNAME to yield.pounce.io
|
||||
1. Yield domains to CNAME to yield.pounce.ch
|
||||
2. Nginx/Caddy to route all hosts to this backend
|
||||
3. This endpoint to parse the Host header
|
||||
"""
|
||||
@ -283,7 +306,7 @@ async def catch_all_route(
|
||||
host = host.split(":")[0]
|
||||
|
||||
# Skip our own domains
|
||||
our_domains = ["pounce.ch", "pounce.io", "localhost", "127.0.0.1"]
|
||||
our_domains = ["pounce.ch", "localhost", "127.0.0.1"]
|
||||
if any(host.endswith(d) for d in our_domains):
|
||||
return {"status": "not a yield domain", "host": host}
|
||||
|
||||
@ -304,5 +327,5 @@ async def catch_all_route(
|
||||
if not _:
|
||||
raise HTTPException(status_code=404, detail="Host not configured for yield routing.")
|
||||
|
||||
return RedirectResponse(url=f"/api/v1/r/{host}?direct=true", status_code=302)
|
||||
return RedirectResponse(url=f"/api/v1/r/{host}?direct=false", status_code=302)
|
||||
|
||||
|
||||
@ -77,11 +77,11 @@ class Settings(BaseSettings):
|
||||
# Yield / Intent Routing
|
||||
# =================================
|
||||
# Comma-separated list of nameservers the user must delegate to for Yield.
|
||||
# Example: "ns1.pounce.io,ns2.pounce.io"
|
||||
yield_nameservers: str = "ns1.pounce.io,ns2.pounce.io"
|
||||
# Example: "ns1.pounce.ch,ns2.pounce.ch"
|
||||
yield_nameservers: str = "ns1.pounce.ch,ns2.pounce.ch"
|
||||
# CNAME/ALIAS target for simpler DNS setup (provider-dependent).
|
||||
# Example: "yield.pounce.io"
|
||||
yield_cname_target: str = "yield.pounce.io"
|
||||
# Example: "yield.pounce.ch"
|
||||
yield_cname_target: str = "yield.pounce.ch"
|
||||
|
||||
@property
|
||||
def yield_nameserver_list(self) -> list[str]:
|
||||
@ -116,13 +116,40 @@ class Settings(BaseSettings):
|
||||
# Moz API (SEO Data)
|
||||
moz_access_id: str = ""
|
||||
moz_secret_key: str = ""
|
||||
|
||||
# =================================
|
||||
# LLM Gateway (Ollama / Mistral Nemo)
|
||||
# =================================
|
||||
llm_gateway_url: str = "http://127.0.0.1:8812" # reverse-tunnel default on Pounce server
|
||||
llm_gateway_api_key: str = ""
|
||||
llm_default_model: str = "mistral-nemo:latest"
|
||||
|
||||
# ICANN CZDS (Centralized Zone Data Service)
|
||||
# For downloading gTLD zone files (.com, .net, .org, etc.)
|
||||
# Register at: https://czds.icann.org/
|
||||
czds_username: str = ""
|
||||
czds_password: str = ""
|
||||
czds_data_dir: str = "/tmp/pounce_czds"
|
||||
czds_data_dir: str = "/data/czds" # Persistent storage
|
||||
|
||||
# Switch.ch Zone Files (.ch, .li)
|
||||
switch_data_dir: str = "/data/switch" # Persistent storage
|
||||
|
||||
# Switch.ch TSIG (DNS AXFR) credentials
|
||||
# These should be provided via environment variables in production.
|
||||
switch_tsig_ch_name: str = "tsig-zonedata-ch-public-21-01"
|
||||
switch_tsig_ch_algorithm: str = "hmac-sha512"
|
||||
switch_tsig_ch_secret: str = ""
|
||||
|
||||
switch_tsig_li_name: str = "tsig-zonedata-li-public-21-01"
|
||||
switch_tsig_li_algorithm: str = "hmac-sha512"
|
||||
switch_tsig_li_secret: str = ""
|
||||
|
||||
# Zone File Retention (days to keep historical snapshots)
|
||||
zone_retention_days: int = 3
|
||||
|
||||
# Domain check scheduler tuning (external I/O heavy; keep conservative defaults)
|
||||
domain_check_max_concurrent: int = 3
|
||||
domain_check_delay_seconds: float = 0.3
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
@ -105,6 +105,75 @@ async def apply_migrations(conn: AsyncConnection) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 2b) domains indexes (watchlist list/sort/filter)
|
||||
# ---------------------------------------------------------
|
||||
if await _table_exists(conn, "domains"):
|
||||
dt_type = "DATETIME" if dialect == "sqlite" else "TIMESTAMP"
|
||||
|
||||
# Canonical status metadata (optional)
|
||||
if not await _has_column(conn, "domains", "last_check_method"):
|
||||
logger.info("DB migrations: adding column domains.last_check_method")
|
||||
await conn.execute(text("ALTER TABLE domains ADD COLUMN last_check_method VARCHAR(30)"))
|
||||
if not await _has_column(conn, "domains", "deletion_date"):
|
||||
logger.info("DB migrations: adding column domains.deletion_date")
|
||||
await conn.execute(text(f"ALTER TABLE domains ADD COLUMN deletion_date {dt_type}"))
|
||||
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_domains_user_id ON domains(user_id)"))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_domains_status ON domains(status)"))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_domains_user_created_at ON domains(user_id, created_at)"))
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 2c) zone_snapshots indexes (admin zone status + recency)
|
||||
# ---------------------------------------------------------
|
||||
if await _table_exists(conn, "zone_snapshots"):
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_zone_snapshots_tld ON zone_snapshots(tld)"))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_zone_snapshots_snapshot_date ON zone_snapshots(snapshot_date)"))
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_zone_snapshots_tld_snapshot_date "
|
||||
"ON zone_snapshots(tld, snapshot_date)"
|
||||
)
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 2d) dropped_domains indexes + de-duplication
|
||||
# ---------------------------------------------------------
|
||||
if await _table_exists(conn, "dropped_domains"):
|
||||
dt_type = "DATETIME" if dialect == "sqlite" else "TIMESTAMP"
|
||||
|
||||
if not await _has_column(conn, "dropped_domains", "last_check_method"):
|
||||
logger.info("DB migrations: adding column dropped_domains.last_check_method")
|
||||
await conn.execute(text("ALTER TABLE dropped_domains ADD COLUMN last_check_method VARCHAR(30)"))
|
||||
if not await _has_column(conn, "dropped_domains", "deletion_date"):
|
||||
logger.info("DB migrations: adding column dropped_domains.deletion_date")
|
||||
await conn.execute(text(f"ALTER TABLE dropped_domains ADD COLUMN deletion_date {dt_type}"))
|
||||
|
||||
# Query patterns:
|
||||
# - by time window (dropped_date) + optional tld + keyword
|
||||
# - status updates (availability_status + last_status_check)
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_dropped_domains_tld ON dropped_domains(tld)"))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_dropped_domains_dropped_date ON dropped_domains(dropped_date)"))
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_dropped_domains_tld_dropped_date "
|
||||
"ON dropped_domains(tld, dropped_date)"
|
||||
)
|
||||
)
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_dropped_domains_domain ON dropped_domains(domain)"))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_dropped_domains_availability ON dropped_domains(availability_status)"))
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_dropped_domains_last_status_check ON dropped_domains(last_status_check)"))
|
||||
|
||||
# Enforce de-duplication per drop day (safe + idempotent).
|
||||
# SQLite: Unique indexes are supported.
|
||||
# Postgres: Unique indexes are supported; we avoid CONCURRENTLY here (runs in startup transaction).
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_dropped_domains_domain_tld_dropped_date "
|
||||
"ON dropped_domains(domain, tld, dropped_date)"
|
||||
)
|
||||
)
|
||||
|
||||
# ---------------------------------------------------
|
||||
# 3) tld_prices composite index for trend computations
|
||||
# ---------------------------------------------------
|
||||
|
||||
@ -19,6 +19,7 @@ from app.config import get_settings
|
||||
from app.database import init_db
|
||||
from app.scheduler import start_scheduler, stop_scheduler
|
||||
from app.observability.metrics import instrument_app
|
||||
from app.services.http_client_pool import close_rdap_http_client
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@ -59,6 +60,7 @@ async def lifespan(app: FastAPI):
|
||||
# Shutdown
|
||||
if settings.enable_scheduler:
|
||||
stop_scheduler()
|
||||
await close_rdap_http_client()
|
||||
logger.info("Application shutdown complete")
|
||||
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ from app.models.telemetry import TelemetryEvent
|
||||
from app.models.ops_alert import OpsAlertEvent
|
||||
from app.models.domain_analysis_cache import DomainAnalysisCache
|
||||
from app.models.zone_file import ZoneSnapshot, DroppedDomain
|
||||
from app.models.llm_artifact import LLMArtifact
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@ -55,4 +56,6 @@ __all__ = [
|
||||
# New: Zone file drops
|
||||
"ZoneSnapshot",
|
||||
"DroppedDomain",
|
||||
# New: LLM artifacts / cache
|
||||
"LLMArtifact",
|
||||
]
|
||||
|
||||
@ -11,6 +11,7 @@ class DomainStatus(str, Enum):
|
||||
"""Domain availability status."""
|
||||
AVAILABLE = "available"
|
||||
TAKEN = "taken"
|
||||
DROPPING_SOON = "dropping_soon" # In transition/pending delete
|
||||
ERROR = "error"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
@ -32,6 +33,7 @@ class Domain(Base):
|
||||
# WHOIS data (optional)
|
||||
registrar: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
expiration_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
deletion_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # When domain will be fully deleted
|
||||
|
||||
# User relationship
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
@ -40,6 +42,8 @@ class Domain(Base):
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
# How the current status was derived (rdap_iana, whois, dns, etc.)
|
||||
last_check_method: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
|
||||
# Check history relationship
|
||||
checks: Mapped[list["DomainCheck"]] = relationship(
|
||||
@ -52,6 +56,17 @@ class Domain(Base):
|
||||
def __repr__(self) -> str:
|
||||
return f"<Domain {self.name} ({self.status})>"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Canonical status fields (API stability for Terminal consistency)
|
||||
# ------------------------------------------------------------------
|
||||
@property
|
||||
def status_checked_at(self) -> datetime | None:
|
||||
return self.last_checked
|
||||
|
||||
@property
|
||||
def status_source(self) -> str | None:
|
||||
return self.last_check_method
|
||||
|
||||
|
||||
class DomainCheck(Base):
|
||||
"""History of domain availability checks."""
|
||||
|
||||
52
backend/app/models/llm_artifact.py
Normal file
52
backend/app/models/llm_artifact.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""
|
||||
LLM artifacts / cache.
|
||||
|
||||
Stores strict-JSON outputs from our internal LLM gateway for:
|
||||
- Vision (business concept + buyer matchmaker)
|
||||
- Yield landing page configs
|
||||
|
||||
Important:
|
||||
- Tier gating is enforced at the API layer; never expose artifacts to Scout users.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String, Text, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class LLMArtifact(Base):
|
||||
__tablename__ = "llm_artifacts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
|
||||
# Optional: who generated it (for auditing). Not used for access control.
|
||||
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
|
||||
# What this artifact represents.
|
||||
# Examples: "vision_v1", "yield_landing_v1"
|
||||
kind: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
|
||||
# Domain this artifact belongs to (lowercase).
|
||||
domain: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
|
||||
# Prompt/versioning for safe cache invalidation
|
||||
prompt_version: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
model: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
|
||||
# Strict JSON payload (string)
|
||||
payload_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_llm_artifacts_kind_domain_prompt", "kind", "domain", "prompt_version"),
|
||||
)
|
||||
|
||||
@ -12,13 +12,13 @@ class SubscriptionTier(str, Enum):
|
||||
"""
|
||||
Subscription tiers for pounce.ch
|
||||
|
||||
Scout (Free): 5 domains, daily checks, email alerts
|
||||
Trader (€19/mo): 50 domains, hourly checks, portfolio, valuation
|
||||
Tycoon (€49/mo): 500+ domains, 10-min checks, API, bulk tools
|
||||
Scout (Free): 10 watchlist, 3 portfolio, 1 listing, daily checks
|
||||
Trader ($9/mo): 100 watchlist, 50 portfolio, 10 listings, hourly checks
|
||||
Tycoon ($29/mo): Unlimited, 5-min checks, API, bulk tools, exclusive drops
|
||||
"""
|
||||
SCOUT = "scout" # Free tier
|
||||
TRADER = "trader" # €19/month
|
||||
TYCOON = "tycoon" # €49/month
|
||||
TRADER = "trader" # $9/month
|
||||
TYCOON = "tycoon" # $29/month
|
||||
|
||||
|
||||
class SubscriptionStatus(str, Enum):
|
||||
@ -31,35 +31,42 @@ class SubscriptionStatus(str, Enum):
|
||||
|
||||
|
||||
# Plan configuration - matches frontend pricing page
|
||||
# Updated 2024: Better conversion funnel with taste-before-pay model
|
||||
TIER_CONFIG = {
|
||||
SubscriptionTier.SCOUT: {
|
||||
"name": "Scout",
|
||||
"price": 0,
|
||||
"currency": "USD",
|
||||
"domain_limit": 5,
|
||||
"portfolio_limit": 0,
|
||||
"domain_limit": 5, # Watchlist: 5
|
||||
"portfolio_limit": 5, # Portfolio: 5
|
||||
"listing_limit": 0, # Listings: 0 (Trader+ only)
|
||||
"sniper_limit": 0, # Sniper alerts: 0 (Trader+ only)
|
||||
"check_frequency": "daily",
|
||||
"history_days": 0,
|
||||
"history_days": 7,
|
||||
"features": {
|
||||
"email_alerts": True,
|
||||
"sms_alerts": False,
|
||||
"priority_alerts": False,
|
||||
"full_whois": False,
|
||||
"expiration_tracking": False,
|
||||
"domain_valuation": False,
|
||||
"domain_valuation": True, # Basic score enabled
|
||||
"market_insights": False,
|
||||
"api_access": False,
|
||||
"webhooks": False,
|
||||
"bulk_tools": False,
|
||||
"seo_metrics": False,
|
||||
"yield": False,
|
||||
"daily_drop_digest": False,
|
||||
}
|
||||
},
|
||||
SubscriptionTier.TRADER: {
|
||||
"name": "Trader",
|
||||
"price": 9,
|
||||
"currency": "USD",
|
||||
"domain_limit": 50,
|
||||
"portfolio_limit": 25,
|
||||
"domain_limit": 50, # Watchlist: 50
|
||||
"portfolio_limit": 50, # Portfolio: 50
|
||||
"listing_limit": 10, # Listings: 10
|
||||
"sniper_limit": 10, # Sniper alerts: 10
|
||||
"check_frequency": "hourly",
|
||||
"history_days": 90,
|
||||
"features": {
|
||||
@ -74,16 +81,21 @@ TIER_CONFIG = {
|
||||
"webhooks": False,
|
||||
"bulk_tools": False,
|
||||
"seo_metrics": False,
|
||||
# Yield Preview only - can see landing page but not activate routing
|
||||
"yield": False,
|
||||
"daily_drop_digest": False,
|
||||
}
|
||||
},
|
||||
SubscriptionTier.TYCOON: {
|
||||
"name": "Tycoon",
|
||||
"price": 29,
|
||||
"currency": "USD",
|
||||
"domain_limit": 500,
|
||||
"portfolio_limit": -1, # Unlimited
|
||||
"check_frequency": "realtime", # Every 10 minutes
|
||||
"history_days": -1, # Unlimited
|
||||
"domain_limit": -1, # Unlimited watchlist
|
||||
"portfolio_limit": -1, # Unlimited portfolio
|
||||
"listing_limit": -1, # Unlimited listings
|
||||
"sniper_limit": 50, # Sniper alerts
|
||||
"check_frequency": "5min", # Every 5 minutes (was 10min)
|
||||
"history_days": -1, # Unlimited
|
||||
"features": {
|
||||
"email_alerts": True,
|
||||
"sms_alerts": True,
|
||||
@ -96,6 +108,8 @@ TIER_CONFIG = {
|
||||
"webhooks": True,
|
||||
"bulk_tools": True,
|
||||
"seo_metrics": True,
|
||||
"yield": True,
|
||||
"daily_drop_digest": True, # Tycoon exclusive: Curated top 10 drops daily
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@ -64,7 +64,10 @@ class User(Base):
|
||||
"PortfolioDomain", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
price_alerts: Mapped[List["PriceAlert"]] = relationship(
|
||||
"PriceAlert", cascade="all, delete-orphan", passive_deletes=True
|
||||
# NOTE:
|
||||
# We do not rely on DB-level ON DELETE CASCADE for this FK (it is not declared in the model),
|
||||
# so we must not set passive_deletes=True. Otherwise deleting a user can fail with FK violations.
|
||||
"PriceAlert", cascade="all, delete-orphan"
|
||||
)
|
||||
# For Sale Marketplace
|
||||
listings: Mapped[List["DomainListing"]] = relationship(
|
||||
|
||||
@ -98,6 +98,15 @@ class YieldDomain(Base):
|
||||
partner_id: Mapped[Optional[int]] = mapped_column(ForeignKey("affiliate_partners.id"), nullable=True)
|
||||
active_route: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # Partner slug
|
||||
landing_page_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# LLM-generated landing page config (used by routing when direct=false)
|
||||
landing_config_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
landing_template: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
landing_headline: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
|
||||
landing_intro: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
landing_cta_label: Mapped[Optional[str]] = mapped_column(String(120), nullable=True)
|
||||
landing_model: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
landing_generated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Status
|
||||
status: Mapped[str] = mapped_column(String(30), default="pending", index=True)
|
||||
|
||||
@ -36,8 +36,17 @@ class DroppedDomain(Base):
|
||||
is_numeric = Column(Boolean, default=False)
|
||||
has_hyphen = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Real-time availability status (checked via RDAP)
|
||||
# Possible values: 'available', 'dropping_soon', 'taken', 'unknown'
|
||||
availability_status = Column(String(20), default='unknown', index=True)
|
||||
rdap_status = Column(String(255), nullable=True) # Raw RDAP status string
|
||||
last_status_check = Column(DateTime, nullable=True)
|
||||
deletion_date = Column(DateTime, nullable=True) # When domain will be fully deleted
|
||||
last_check_method = Column(String(30), nullable=True) # rdap_iana, rdap_ch, error, etc.
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_dropped_domains_tld_date', 'tld', 'dropped_date'),
|
||||
Index('ix_dropped_domains_length', 'length'),
|
||||
Index('ix_dropped_domains_availability', 'availability_status'),
|
||||
)
|
||||
|
||||
@ -59,24 +59,35 @@ async def check_domains_by_frequency(frequency: str):
|
||||
|
||||
Args:
|
||||
frequency: One of 'daily', 'hourly', 'realtime' (10-min)
|
||||
|
||||
This function now detects BOTH transitions:
|
||||
- taken -> available: Domain dropped, notify user to register
|
||||
- available -> taken: Domain was registered, notify user they missed it
|
||||
"""
|
||||
logger.info(f"Starting {frequency} domain check...")
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Get users with matching check frequency
|
||||
# IMPORTANT: Higher tiers get MORE frequent checks, not the other way around
|
||||
# - daily: checks ALL tiers (minimum service level for everyone)
|
||||
# - hourly: checks Trader + Tycoon only
|
||||
# - realtime: checks Tycoon only
|
||||
tiers_for_frequency = []
|
||||
for tier, config in TIER_CONFIG.items():
|
||||
if config['check_frequency'] == frequency:
|
||||
tier_freq = config['check_frequency']
|
||||
|
||||
if frequency == 'daily':
|
||||
# Daily job checks ALL tiers (this is the baseline)
|
||||
tiers_for_frequency.append(tier)
|
||||
# Realtime includes hourly and daily too (more frequent = superset)
|
||||
elif frequency == 'realtime':
|
||||
elif frequency == 'hourly' and tier_freq in ['hourly', 'realtime']:
|
||||
# Hourly job checks Trader + Tycoon
|
||||
tiers_for_frequency.append(tier)
|
||||
elif frequency == 'hourly' and config['check_frequency'] in ['hourly', 'realtime']:
|
||||
elif frequency == 'realtime' and tier_freq == 'realtime':
|
||||
# Realtime job checks ONLY Tycoon (premium feature)
|
||||
tiers_for_frequency.append(tier)
|
||||
|
||||
# Get domains from users with matching subscription tier
|
||||
from sqlalchemy.orm import joinedload
|
||||
result = await db.execute(
|
||||
select(Domain)
|
||||
.join(User, Domain.user_id == User.id)
|
||||
@ -87,33 +98,81 @@ async def check_domains_by_frequency(frequency: str):
|
||||
)
|
||||
)
|
||||
domains = result.scalars().all()
|
||||
|
||||
|
||||
logger.info(f"Checking {len(domains)} domains...")
|
||||
|
||||
|
||||
checked = 0
|
||||
errors = 0
|
||||
newly_available = []
|
||||
|
||||
for domain in domains:
|
||||
try:
|
||||
# Check domain availability
|
||||
check_result = await domain_checker.check_domain(domain.name)
|
||||
|
||||
# Track if domain became available
|
||||
was_taken = not domain.is_available
|
||||
newly_taken = [] # Track domains that became taken
|
||||
status_changes = [] # All status changes for logging
|
||||
|
||||
# Concurrency control + polite pacing (prevents RDAP/WHOIS bans)
|
||||
max_concurrent = max(1, int(getattr(settings, "domain_check_max_concurrent", 3) or 3))
|
||||
delay = float(getattr(settings, "domain_check_delay_seconds", 0.3) or 0.3)
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def _check_one(d: Domain) -> tuple[Domain, object | None, Exception | None]:
|
||||
async with semaphore:
|
||||
try:
|
||||
res = await domain_checker.check_domain(d.name)
|
||||
# small delay after each external request
|
||||
await asyncio.sleep(delay)
|
||||
return d, res, None
|
||||
except Exception as e:
|
||||
return d, None, e
|
||||
|
||||
# Process in chunks to avoid huge gather lists
|
||||
chunk_size = 200
|
||||
for i in range(0, len(domains), chunk_size):
|
||||
chunk = domains[i : i + chunk_size]
|
||||
results = await asyncio.gather(*[_check_one(d) for d in chunk])
|
||||
|
||||
for domain, check_result, err in results:
|
||||
if err is not None or check_result is None:
|
||||
logger.error(f"Error checking domain {domain.name}: {err}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
# Track status transitions
|
||||
was_available = domain.is_available
|
||||
is_now_available = check_result.is_available
|
||||
|
||||
if was_taken and is_now_available and domain.notify_on_available:
|
||||
newly_available.append(domain)
|
||||
|
||||
# Update domain
|
||||
|
||||
# Detect transition: taken -> available (domain dropped!)
|
||||
if not was_available and is_now_available:
|
||||
status_changes.append(
|
||||
{
|
||||
"domain": domain.name,
|
||||
"change": "became_available",
|
||||
"old_registrar": domain.registrar,
|
||||
}
|
||||
)
|
||||
if domain.notify_on_available:
|
||||
newly_available.append(domain)
|
||||
logger.info(f"🎯 Domain AVAILABLE: {domain.name} (was registered by {domain.registrar})")
|
||||
|
||||
# Detect transition: available -> taken (someone registered it!)
|
||||
elif was_available and not is_now_available:
|
||||
status_changes.append(
|
||||
{
|
||||
"domain": domain.name,
|
||||
"change": "became_taken",
|
||||
"new_registrar": check_result.registrar,
|
||||
}
|
||||
)
|
||||
if domain.notify_on_available: # Notify if alerts are on
|
||||
newly_taken.append({"domain": domain, "registrar": check_result.registrar})
|
||||
logger.info(f"⚠️ Domain TAKEN: {domain.name} (now registered by {check_result.registrar})")
|
||||
|
||||
# Update domain with fresh data
|
||||
domain.status = check_result.status
|
||||
domain.is_available = check_result.is_available
|
||||
domain.registrar = check_result.registrar
|
||||
domain.expiration_date = check_result.expiration_date
|
||||
domain.last_checked = datetime.utcnow()
|
||||
|
||||
# Create check record
|
||||
domain.last_check_method = getattr(check_result, "check_method", None)
|
||||
|
||||
# Create check record for history
|
||||
check = DomainCheck(
|
||||
domain_id=domain.id,
|
||||
status=check_result.status,
|
||||
@ -122,28 +181,26 @@ async def check_domains_by_frequency(frequency: str):
|
||||
checked_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(check)
|
||||
|
||||
checked += 1
|
||||
|
||||
# Small delay to avoid rate limiting
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking domain {domain.name}: {e}")
|
||||
errors += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
elapsed = (datetime.utcnow() - start_time).total_seconds()
|
||||
logger.info(
|
||||
f"Domain check complete. Checked: {checked}, Errors: {errors}, "
|
||||
f"Newly available: {len(newly_available)}, Time: {elapsed:.2f}s"
|
||||
f"Newly available: {len(newly_available)}, Newly taken: {len(newly_taken)}, "
|
||||
f"Total changes: {len(status_changes)}, Time: {elapsed:.2f}s"
|
||||
)
|
||||
|
||||
# Send notifications for newly available domains
|
||||
if newly_available:
|
||||
logger.info(f"Domains that became available: {[d.name for d in newly_available]}")
|
||||
await send_domain_availability_alerts(db, newly_available)
|
||||
|
||||
# Send notifications for domains that got taken (user missed them!)
|
||||
if newly_taken:
|
||||
logger.info(f"Domains that were taken: {[d['domain'].name for d in newly_taken]}")
|
||||
await send_domain_taken_alerts(db, newly_taken)
|
||||
|
||||
|
||||
async def check_all_domains():
|
||||
@ -335,30 +392,51 @@ async def run_health_checks():
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Get all watched domains (registered, not available)
|
||||
result = await db.execute(
|
||||
select(Domain).where(Domain.is_available == False)
|
||||
)
|
||||
result = await db.execute(select(Domain).where(Domain.is_available == False))
|
||||
domains = result.scalars().all()
|
||||
|
||||
|
||||
logger.info(f"Running health checks on {len(domains)} domains...")
|
||||
|
||||
|
||||
if not domains:
|
||||
return
|
||||
|
||||
# Prefetch caches to avoid N+1 queries
|
||||
domain_ids = [d.id for d in domains]
|
||||
caches_result = await db.execute(
|
||||
select(DomainHealthCache).where(DomainHealthCache.domain_id.in_(domain_ids))
|
||||
)
|
||||
caches = caches_result.scalars().all()
|
||||
cache_by_domain_id = {c.domain_id: c for c in caches}
|
||||
|
||||
health_checker = get_health_checker()
|
||||
checked = 0
|
||||
errors = 0
|
||||
status_changes = []
|
||||
|
||||
for domain in domains:
|
||||
try:
|
||||
# Run health check
|
||||
report = await health_checker.check_domain(domain.name)
|
||||
|
||||
# Check for status changes (if we have previous data)
|
||||
# Get existing cache
|
||||
cache_result = await db.execute(
|
||||
select(DomainHealthCache).where(DomainHealthCache.domain_id == domain.id)
|
||||
)
|
||||
existing_cache = cache_result.scalar_one_or_none()
|
||||
|
||||
|
||||
max_concurrent = max(1, int(getattr(settings, "domain_check_max_concurrent", 3) or 3))
|
||||
delay = float(getattr(settings, "domain_check_delay_seconds", 0.3) or 0.3)
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def _check_one(d: Domain):
|
||||
async with semaphore:
|
||||
report = await health_checker.check_domain(d.name)
|
||||
await asyncio.sleep(delay)
|
||||
return d, report
|
||||
|
||||
chunk_size = 100
|
||||
for i in range(0, len(domains), chunk_size):
|
||||
chunk = domains[i : i + chunk_size]
|
||||
results = await asyncio.gather(*[_check_one(d) for d in chunk], return_exceptions=True)
|
||||
|
||||
for item in results:
|
||||
if isinstance(item, Exception):
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
domain, report = item
|
||||
|
||||
existing_cache = cache_by_domain_id.get(domain.id)
|
||||
|
||||
old_status = existing_cache.status if existing_cache else None
|
||||
new_status = report.status.value
|
||||
|
||||
@ -390,7 +468,6 @@ async def run_health_checks():
|
||||
existing_cache.ssl_data = ssl_json
|
||||
existing_cache.checked_at = datetime.utcnow()
|
||||
else:
|
||||
# Create new cache entry
|
||||
new_cache = DomainHealthCache(
|
||||
domain_id=domain.id,
|
||||
status=new_status,
|
||||
@ -402,15 +479,9 @@ async def run_health_checks():
|
||||
checked_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(new_cache)
|
||||
cache_by_domain_id[domain.id] = new_cache
|
||||
|
||||
checked += 1
|
||||
|
||||
# Small delay to avoid overwhelming DNS servers
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed for {domain.name}: {e}")
|
||||
errors += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
@ -684,6 +755,17 @@ def setup_scheduler():
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# Drops availability verification - DISABLED to prevent RDAP bans
|
||||
# The domains from zone files are already verified as "dropped" by the zone diff
|
||||
# We don't need to double-check via RDAP - this causes rate limiting!
|
||||
# scheduler.add_job(
|
||||
# verify_drops,
|
||||
# CronTrigger(hour=12, minute=0), # Once a day at noon if needed
|
||||
# id="drops_verification",
|
||||
# name="Drops Availability Check (daily)",
|
||||
# replace_existing=True,
|
||||
# )
|
||||
|
||||
logger.info(
|
||||
f"Scheduler configured:"
|
||||
f"\n - Scout domain check at {settings.check_hour:02d}:{settings.check_minute:02d} (daily)"
|
||||
@ -692,9 +774,11 @@ def setup_scheduler():
|
||||
f"\n - TLD price scrape 2x daily at 03:00 & 15:00 UTC"
|
||||
f"\n - Price change alerts at 04:00 & 16:00 UTC"
|
||||
f"\n - Auction scrape every 2 hours at :30"
|
||||
f"\n - Expired auction cleanup every 15 minutes"
|
||||
f"\n - Expired auction cleanup every 5 minutes"
|
||||
f"\n - Sniper alert matching every 30 minutes"
|
||||
f"\n - Zone file sync daily at 05:00 UTC"
|
||||
f"\n - Switch.ch zone sync daily at 05:00 UTC (.ch, .li)"
|
||||
f"\n - ICANN CZDS zone sync daily at 06:00 UTC (gTLDs)"
|
||||
f"\n - Zone cleanup hourly at :45"
|
||||
)
|
||||
|
||||
|
||||
@ -758,6 +842,77 @@ async def send_domain_availability_alerts(db, domains: list[Domain]):
|
||||
logger.info(f"Sent {alerts_sent} domain availability alerts")
|
||||
|
||||
|
||||
async def send_domain_taken_alerts(db, taken_domains: list[dict]):
|
||||
"""
|
||||
Send email alerts when watched available domains get registered by someone.
|
||||
|
||||
This notifies users that a domain they were watching (and was available)
|
||||
has now been taken - either by them or someone else.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
taken_domains: List of dicts with 'domain' (Domain object) and 'registrar' (str)
|
||||
"""
|
||||
if not email_service.is_configured():
|
||||
logger.info("Email service not configured, skipping domain taken alerts")
|
||||
return
|
||||
|
||||
alerts_sent = 0
|
||||
|
||||
for item in taken_domains:
|
||||
domain = item['domain']
|
||||
registrar = item.get('registrar') or 'Unknown registrar'
|
||||
|
||||
try:
|
||||
# Get domain owner
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == domain.user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.email:
|
||||
# Send notification that the domain is no longer available
|
||||
success = await email_service.send_email(
|
||||
to_email=user.email,
|
||||
subject=f"⚡ Domain Update: {domain.name} was registered",
|
||||
html_content=f"""
|
||||
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
|
||||
Domain Status Changed
|
||||
</h2>
|
||||
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;">
|
||||
A domain on your watchlist has been registered:
|
||||
</p>
|
||||
<div style="margin: 24px 0; padding: 20px; background: #f8f8f8; border-radius: 6px; border-left: 3px solid #f59e0b;">
|
||||
<p style="margin: 0 0 8px 0; font-size: 18px; font-weight: bold; font-family: monospace;">
|
||||
{domain.name}
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #666;">
|
||||
Registrar: {registrar}
|
||||
</p>
|
||||
</div>
|
||||
<p style="margin: 24px 0 0 0; font-size: 14px; color: #666666;">
|
||||
If you registered this domain yourself, congratulations! 🎉<br>
|
||||
If not, the domain might become available again in the future.
|
||||
</p>
|
||||
<p style="margin: 16px 0 0 0;">
|
||||
<a href="https://pounce.ch/terminal/watchlist"
|
||||
style="color: #000; text-decoration: underline;">
|
||||
View your watchlist
|
||||
</a>
|
||||
</p>
|
||||
""",
|
||||
text_content=f"Domain {domain.name} on your watchlist was registered by {registrar}."
|
||||
)
|
||||
if success:
|
||||
alerts_sent += 1
|
||||
logger.info(f"📧 Domain taken alert sent for {domain.name} to {user.email}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send domain taken alert for {domain.name}: {e}")
|
||||
|
||||
logger.info(f"Sent {alerts_sent} domain taken alerts")
|
||||
|
||||
|
||||
async def check_price_changes():
|
||||
"""Check for TLD price changes and send alerts."""
|
||||
logger.info("Checking for TLD price changes...")
|
||||
@ -879,9 +1034,43 @@ async def cleanup_zone_data():
|
||||
logger.exception(f"Zone data cleanup failed: {e}")
|
||||
|
||||
|
||||
async def verify_drops():
|
||||
"""
|
||||
Verify availability of dropped domains and remove taken ones.
|
||||
|
||||
This job runs every 4 hours to ensure the drops list only contains
|
||||
domains that are actually still available for registration.
|
||||
"""
|
||||
logger.info("Starting drops availability verification...")
|
||||
|
||||
try:
|
||||
from app.services.zone_file import verify_drops_availability
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await verify_drops_availability(
|
||||
db,
|
||||
batch_size=100,
|
||||
max_checks=500 # Check up to 500 domains per run
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Drops verification complete: "
|
||||
f"{result['checked']} checked, "
|
||||
f"{result['available']} still available, "
|
||||
f"{result['removed']} removed (taken), "
|
||||
f"{result['errors']} errors"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Drops verification failed: {e}")
|
||||
|
||||
|
||||
async def sync_zone_files():
|
||||
"""Sync zone files from Switch.ch (.ch, .li) and ICANN CZDS (gTLDs)."""
|
||||
logger.info("Starting zone file sync...")
|
||||
"""Sync zone files from Switch.ch (.ch, .li)."""
|
||||
logger.info("Starting Switch.ch zone file sync...")
|
||||
|
||||
results = {"ch": None, "li": None}
|
||||
errors = []
|
||||
|
||||
try:
|
||||
from app.services.zone_file import ZoneFileService
|
||||
@ -893,14 +1082,41 @@ async def sync_zone_files():
|
||||
for tld in ["ch", "li"]:
|
||||
try:
|
||||
result = await service.run_daily_sync(db, tld)
|
||||
logger.info(f".{tld} zone sync: {len(result.get('dropped', []))} dropped, {result.get('new_count', 0)} new")
|
||||
dropped_count = len(result.get('dropped', []))
|
||||
results[tld] = {"status": "success", "dropped": dropped_count, "new": result.get('new_count', 0)}
|
||||
logger.info(f".{tld} zone sync: {dropped_count} dropped, {result.get('new_count', 0)} new")
|
||||
except Exception as e:
|
||||
logger.error(f".{tld} zone sync failed: {e}")
|
||||
results[tld] = {"status": "error", "error": str(e)}
|
||||
errors.append(f".{tld}: {e}")
|
||||
|
||||
logger.info("Switch.ch zone file sync completed")
|
||||
|
||||
# Send alert if any zones failed
|
||||
if errors:
|
||||
from app.services.email_service import email_service
|
||||
await email_service.send_ops_alert(
|
||||
alert_type="Zone Sync",
|
||||
title=f"Switch.ch Sync: {len(errors)} zone(s) failed",
|
||||
details=f"Results:\n" + "\n".join([
|
||||
f"- .{tld}: {r.get('status')} ({r.get('dropped', 0)} dropped)" if r else f"- .{tld}: not processed"
|
||||
for tld, r in results.items()
|
||||
]) + f"\n\nErrors:\n" + "\n".join(errors),
|
||||
severity="error",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Zone file sync failed: {e}")
|
||||
try:
|
||||
from app.services.email_service import email_service
|
||||
await email_service.send_ops_alert(
|
||||
alert_type="Zone Sync",
|
||||
title="Switch.ch Sync CRASHED",
|
||||
details=f"The Switch.ch sync job crashed:\n\n{str(e)}",
|
||||
severity="critical",
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
async def sync_czds_zones():
|
||||
@ -921,15 +1137,43 @@ async def sync_czds_zones():
|
||||
client = CZDSClient()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
results = await client.sync_all_zones(db, APPROVED_TLDS)
|
||||
results = await client.sync_all_zones(db, APPROVED_TLDS, parallel=True)
|
||||
|
||||
success_count = sum(1 for r in results if r["status"] == "success")
|
||||
error_count = sum(1 for r in results if r["status"] == "error")
|
||||
total_dropped = sum(r["dropped_count"] for r in results)
|
||||
|
||||
logger.info(f"CZDS sync complete: {success_count}/{len(APPROVED_TLDS)} zones, {total_dropped:,} dropped")
|
||||
|
||||
# Send alert if any zones failed
|
||||
if error_count > 0:
|
||||
from app.services.email_service import email_service
|
||||
error_details = "\n".join([
|
||||
f"- .{r['tld']}: {r.get('error', 'Unknown error')}"
|
||||
for r in results if r["status"] == "error"
|
||||
])
|
||||
await email_service.send_ops_alert(
|
||||
alert_type="Zone Sync",
|
||||
title=f"CZDS Sync: {error_count} zone(s) failed",
|
||||
details=f"Successful: {success_count}/{len(APPROVED_TLDS)}\n"
|
||||
f"Dropped domains: {total_dropped:,}\n\n"
|
||||
f"Failed zones:\n{error_details}",
|
||||
severity="error" if error_count > 2 else "warning",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"CZDS zone file sync failed: {e}")
|
||||
# Send critical alert for complete failure
|
||||
try:
|
||||
from app.services.email_service import email_service
|
||||
await email_service.send_ops_alert(
|
||||
alert_type="Zone Sync",
|
||||
title="CZDS Sync CRASHED",
|
||||
details=f"The entire CZDS sync job crashed:\n\n{str(e)}",
|
||||
severity="critical",
|
||||
)
|
||||
except:
|
||||
pass # Don't fail the error handler
|
||||
|
||||
|
||||
async def match_sniper_alerts():
|
||||
|
||||
@ -39,9 +39,13 @@ class DomainResponse(BaseModel):
|
||||
is_available: bool
|
||||
registrar: Optional[str]
|
||||
expiration_date: Optional[datetime]
|
||||
deletion_date: Optional[datetime] = None
|
||||
notify_on_available: bool
|
||||
created_at: datetime
|
||||
last_checked: Optional[datetime]
|
||||
# Canonical status metadata (stable across Terminal modules)
|
||||
status_checked_at: Optional[datetime] = None
|
||||
status_source: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@ -70,13 +74,14 @@ class DomainCheckRequest(BaseModel):
|
||||
class DomainCheckResponse(BaseModel):
|
||||
"""Schema for domain check response."""
|
||||
domain: str
|
||||
status: str
|
||||
status: DomainStatus
|
||||
is_available: bool
|
||||
registrar: Optional[str] = None
|
||||
expiration_date: Optional[datetime] = None
|
||||
creation_date: Optional[datetime] = None
|
||||
name_servers: Optional[List[str]] = None
|
||||
error_message: Optional[str] = None
|
||||
status_source: Optional[str] = None
|
||||
checked_at: datetime
|
||||
|
||||
|
||||
|
||||
@ -69,6 +69,14 @@ class YieldDomainResponse(BaseModel):
|
||||
# Routing
|
||||
active_route: Optional[str] = None
|
||||
partner_name: Optional[str] = None
|
||||
|
||||
# Landing page (generated at activation time)
|
||||
landing_template: Optional[str] = None
|
||||
landing_headline: Optional[str] = None
|
||||
landing_intro: Optional[str] = None
|
||||
landing_cta_label: Optional[str] = None
|
||||
landing_model: Optional[str] = None
|
||||
landing_generated_at: Optional[datetime] = None
|
||||
|
||||
# DNS
|
||||
dns_verified: bool = False
|
||||
@ -234,9 +242,11 @@ class DNSVerificationResult(BaseModel):
|
||||
"""Result of DNS verification check."""
|
||||
domain: str
|
||||
verified: bool
|
||||
method: Optional[str] = None # "a_record" | "cname" | "nameserver"
|
||||
|
||||
expected_ns: list[str]
|
||||
actual_ns: list[str]
|
||||
actual_a: list[str] = [] # A-records found for the domain
|
||||
|
||||
cname_ok: bool = False
|
||||
|
||||
@ -263,6 +273,16 @@ class DNSSetupInstructions(BaseModel):
|
||||
# Activation Flow
|
||||
# ============================================================================
|
||||
|
||||
class YieldLandingPreview(BaseModel):
|
||||
"""LLM-generated landing page config preview."""
|
||||
template: str
|
||||
headline: str
|
||||
seo_intro: str
|
||||
cta_label: str
|
||||
model: Optional[str] = None
|
||||
generated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ActivateYieldRequest(BaseModel):
|
||||
"""Request to activate a domain for yield."""
|
||||
domain: str = Field(..., min_length=3, max_length=255)
|
||||
@ -281,6 +301,9 @@ class ActivateYieldResponse(BaseModel):
|
||||
|
||||
# Setup
|
||||
dns_instructions: DNSSetupInstructions
|
||||
|
||||
# Generated landing page config (so user can preview instantly)
|
||||
landing: Optional[YieldLandingPreview] = None
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
@ -55,10 +55,27 @@ def set_auth_cookie(response: Response, token: str, max_age_seconds: int) -> Non
|
||||
|
||||
|
||||
def clear_auth_cookie(response: Response) -> None:
|
||||
"""Clear auth cookie with explicit expiry to ensure removal."""
|
||||
# Delete with same settings used when setting (required for proper removal)
|
||||
response.delete_cookie(
|
||||
key=AUTH_COOKIE_NAME,
|
||||
path="/",
|
||||
domain=cookie_domain(),
|
||||
secure=True,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
# Also set with max_age=0 as fallback (some browsers need this)
|
||||
response.set_cookie(
|
||||
key=AUTH_COOKIE_NAME,
|
||||
value="",
|
||||
max_age=0,
|
||||
expires=0,
|
||||
path="/",
|
||||
domain=cookie_domain(),
|
||||
secure=True,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -10,7 +10,12 @@ class TldMatrixAnalyzer:
|
||||
ttl_seconds = 60 * 30 # 30m (availability can change)
|
||||
|
||||
async def analyze(self, ctx: AnalyzeContext) -> list[AnalyzerContribution]:
|
||||
rows = await run_tld_matrix(ctx.domain)
|
||||
# If main domain check says it's taken, pass that info to TLD matrix
|
||||
# This ensures the original TLD shows correctly as "taken" even if
|
||||
# DNS-based checks fail (e.g., domain registered but no DNS records)
|
||||
original_is_taken = ctx.check and not ctx.check.is_available
|
||||
|
||||
rows = await run_tld_matrix(ctx.domain, original_is_taken=original_is_taken)
|
||||
item = AnalyzeItem(
|
||||
key="tld_matrix",
|
||||
label="TLD Matrix",
|
||||
|
||||
@ -48,11 +48,21 @@ async def _check_one(domain: str) -> TldMatrixRow:
|
||||
)
|
||||
|
||||
|
||||
async def run_tld_matrix(domain: str, tlds: list[str] | None = None) -> list[TldMatrixRow]:
|
||||
sld = (domain or "").split(".")[0].lower().strip()
|
||||
async def run_tld_matrix(domain: str, tlds: list[str] | None = None, original_is_taken: bool = False) -> list[TldMatrixRow]:
|
||||
"""
|
||||
Check availability for the same SLD across multiple TLDs.
|
||||
|
||||
Args:
|
||||
domain: The full domain being analyzed (e.g., "akaya.ch")
|
||||
tlds: List of TLDs to check (defaults to DEFAULT_TLDS)
|
||||
original_is_taken: If True, force the original domain's TLD to show as taken
|
||||
"""
|
||||
parts = (domain or "").lower().strip().split(".")
|
||||
sld = parts[0] if parts else ""
|
||||
original_tld = parts[-1] if len(parts) > 1 else ""
|
||||
tlds = [t.lower().lstrip(".") for t in (tlds or DEFAULT_TLDS)]
|
||||
|
||||
# Avoid repeated checks and the original TLD duplication
|
||||
# Avoid repeated checks
|
||||
seen = set()
|
||||
candidates: list[str] = []
|
||||
for t in tlds:
|
||||
@ -62,5 +72,23 @@ async def run_tld_matrix(domain: str, tlds: list[str] | None = None) -> list[Tld
|
||||
seen.add(d)
|
||||
|
||||
rows = await asyncio.gather(*[_check_one(d) for d in candidates])
|
||||
return list(rows)
|
||||
result = list(rows)
|
||||
|
||||
# If the original domain is known to be taken, ensure its TLD shows as taken
|
||||
# This fixes cases where DNS-based quick checks incorrectly show "available"
|
||||
# for domains that are registered but have no DNS records
|
||||
if original_is_taken and original_tld:
|
||||
result = [
|
||||
TldMatrixRow(
|
||||
tld=r.tld,
|
||||
domain=r.domain,
|
||||
is_available=False,
|
||||
status="taken",
|
||||
method=r.method,
|
||||
error=r.error,
|
||||
) if r.tld == original_tld else r
|
||||
for r in result
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@ -174,14 +174,14 @@ class CZDSClient:
|
||||
return None
|
||||
|
||||
def extract_zone_file(self, gz_path: Path) -> Path:
|
||||
"""Extract gzipped zone file."""
|
||||
output_path = gz_path.with_suffix('') # Remove .gz
|
||||
"""
|
||||
Extract gzipped zone file to RAM drive for fastest access.
|
||||
Falls back to disk if RAM drive unavailable.
|
||||
"""
|
||||
from app.services.zone_file_parser import HighPerformanceZoneParser
|
||||
|
||||
logger.info(f"Extracting {gz_path.name}...")
|
||||
|
||||
with gzip.open(gz_path, 'rb') as f_in:
|
||||
with open(output_path, 'wb') as f_out:
|
||||
shutil.copyfileobj(f_in, f_out)
|
||||
parser = HighPerformanceZoneParser(use_ram_drive=True)
|
||||
output_path = parser.extract_to_ram(gz_path)
|
||||
|
||||
# Remove gz file to save space
|
||||
gz_path.unlink()
|
||||
@ -192,43 +192,21 @@ class CZDSClient:
|
||||
"""
|
||||
Parse zone file and extract unique domain names.
|
||||
|
||||
Zone files contain various record types. We extract domains from:
|
||||
- NS records (most reliable indicator of active domain)
|
||||
- A/AAAA records
|
||||
Uses high-performance parallel parser with all CPU cores
|
||||
and RAM drive for maximum speed on large zone files.
|
||||
|
||||
Returns set of domain names (without TLD suffix).
|
||||
"""
|
||||
logger.info(f"Parsing zone file for .{tld}...")
|
||||
from app.services.zone_file_parser import HighPerformanceZoneParser
|
||||
|
||||
domains = set()
|
||||
line_count = 0
|
||||
# Use parallel parser with RAM drive
|
||||
parser = HighPerformanceZoneParser(use_ram_drive=True)
|
||||
|
||||
with open(zone_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
for line in f:
|
||||
line_count += 1
|
||||
|
||||
# Skip comments and empty lines
|
||||
if line.startswith(';') or not line.strip():
|
||||
continue
|
||||
|
||||
# Look for NS records which indicate delegated domains
|
||||
# Format: example.tld. 86400 IN NS ns1.registrar.com.
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
# First column is the domain name
|
||||
name = parts[0].rstrip('.')
|
||||
|
||||
# Must end with our TLD
|
||||
if name.lower().endswith(f'.{tld}'):
|
||||
# Extract just the domain name part
|
||||
domain_name = name[:-(len(tld) + 1)]
|
||||
|
||||
# Skip the TLD itself and subdomains
|
||||
if domain_name and '.' not in domain_name:
|
||||
domains.add(domain_name.lower())
|
||||
|
||||
logger.info(f"Parsed .{tld}: {len(domains):,} unique domains from {line_count:,} lines")
|
||||
return domains
|
||||
try:
|
||||
domains = parser.parse_zone_file_parallel(zone_path, tld)
|
||||
return domains
|
||||
finally:
|
||||
parser.cleanup_ram_drive()
|
||||
|
||||
def compute_checksum(self, domains: set[str]) -> str:
|
||||
"""Compute SHA256 checksum of sorted domain list."""
|
||||
@ -249,11 +227,43 @@ class CZDSClient:
|
||||
return None
|
||||
|
||||
async def save_domains(self, tld: str, domains: set[str]):
|
||||
"""Save current domains to cache file."""
|
||||
"""Save current domains to cache file with date-based retention."""
|
||||
from app.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
# Save current file (for next sync comparison)
|
||||
cache_file = self.data_dir / f"{tld}_domains.txt"
|
||||
cache_file.write_text("\n".join(sorted(domains)))
|
||||
|
||||
# Also save dated snapshot for retention
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
dated_file = self.data_dir / f"{tld}_domains_{today}.txt"
|
||||
if not dated_file.exists():
|
||||
dated_file.write_text("\n".join(sorted(domains)))
|
||||
logger.info(f"Saved snapshot: {dated_file.name}")
|
||||
|
||||
# Cleanup old snapshots (keep last N days)
|
||||
retention_days = getattr(settings, 'zone_retention_days', 3)
|
||||
await self._cleanup_old_snapshots(tld, retention_days)
|
||||
|
||||
logger.info(f"Saved {len(domains):,} domains for .{tld}")
|
||||
|
||||
async def _cleanup_old_snapshots(self, tld: str, keep_days: int = 3):
|
||||
"""Remove zone file snapshots older than keep_days."""
|
||||
import re
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = datetime.now() - timedelta(days=keep_days)
|
||||
pattern = re.compile(rf"^{tld}_domains_(\d{{4}}-\d{{2}}-\d{{2}})\.txt$")
|
||||
|
||||
for file in self.data_dir.glob(f"{tld}_domains_*.txt"):
|
||||
match = pattern.match(file.name)
|
||||
if match:
|
||||
file_date = datetime.strptime(match.group(1), "%Y-%m-%d")
|
||||
if file_date < cutoff:
|
||||
file.unlink()
|
||||
logger.info(f"Deleted old snapshot: {file.name}")
|
||||
|
||||
async def process_drops(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@ -261,49 +271,67 @@ class CZDSClient:
|
||||
previous: set[str],
|
||||
current: set[str]
|
||||
) -> list[dict]:
|
||||
"""Find and store dropped domains."""
|
||||
"""
|
||||
Find dropped domains and store them directly.
|
||||
|
||||
NOTE: We do NOT verify availability here to avoid RDAP rate limits/bans.
|
||||
Verification happens separately in the 'verify_drops' scheduler job
|
||||
which runs in small batches throughout the day.
|
||||
"""
|
||||
dropped = previous - current
|
||||
|
||||
if not dropped:
|
||||
logger.info(f"No dropped domains found for .{tld}")
|
||||
return []
|
||||
|
||||
logger.info(f"Found {len(dropped):,} dropped domains for .{tld}")
|
||||
logger.info(f"Found {len(dropped):,} dropped domains for .{tld}, saving to database...")
|
||||
|
||||
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Batch insert for performance
|
||||
# Store all drops - availability will be verified separately
|
||||
dropped_records = []
|
||||
batch_size = 1000
|
||||
batch = []
|
||||
dropped_list = list(dropped)
|
||||
|
||||
for name in dropped:
|
||||
record = DroppedDomain(
|
||||
domain=f"{name}.{tld}",
|
||||
tld=tld,
|
||||
dropped_date=today,
|
||||
length=len(name),
|
||||
is_numeric=name.isdigit(),
|
||||
has_hyphen='-' in name
|
||||
)
|
||||
batch.append(record)
|
||||
dropped_records.append({
|
||||
"domain": f"{name}.{tld}",
|
||||
"length": len(name),
|
||||
"is_numeric": name.isdigit(),
|
||||
"has_hyphen": '-' in name
|
||||
})
|
||||
for i in range(0, len(dropped_list), batch_size):
|
||||
batch = dropped_list[i:i + batch_size]
|
||||
|
||||
if len(batch) >= batch_size:
|
||||
db.add_all(batch)
|
||||
await db.flush()
|
||||
batch = []
|
||||
for name in batch:
|
||||
try:
|
||||
record = DroppedDomain(
|
||||
domain=name, # Just the name, not full domain!
|
||||
tld=tld,
|
||||
dropped_date=today,
|
||||
length=len(name),
|
||||
is_numeric=name.isdigit(),
|
||||
has_hyphen='-' in name,
|
||||
availability_status='unknown' # Will be verified later
|
||||
)
|
||||
db.add(record)
|
||||
dropped_records.append({
|
||||
"domain": f"{name}.{tld}",
|
||||
"length": len(name),
|
||||
})
|
||||
except Exception as e:
|
||||
# Duplicate or other error - skip
|
||||
pass
|
||||
|
||||
# Commit batch
|
||||
try:
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
|
||||
if (i + batch_size) % 5000 == 0:
|
||||
logger.info(f"Saved {min(i + batch_size, len(dropped_list)):,}/{len(dropped_list):,} drops")
|
||||
|
||||
# Add remaining
|
||||
if batch:
|
||||
db.add_all(batch)
|
||||
# Final commit
|
||||
try:
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"CZDS drops for .{tld}: {len(dropped_records):,} saved (verification pending)")
|
||||
|
||||
return dropped_records
|
||||
|
||||
@ -354,7 +382,9 @@ class CZDSClient:
|
||||
result["current_count"] = len(current_domains)
|
||||
|
||||
# Clean up zone file (can be very large)
|
||||
zone_path.unlink()
|
||||
# Note: Parser may have already deleted the file during cleanup_ram_drive()
|
||||
if zone_path.exists():
|
||||
zone_path.unlink()
|
||||
|
||||
# Get previous snapshot
|
||||
previous_domains = await self.get_previous_domains(tld)
|
||||
@ -399,7 +429,9 @@ class CZDSClient:
|
||||
async def sync_all_zones(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tlds: Optional[list[str]] = None
|
||||
tlds: Optional[list[str]] = None,
|
||||
parallel: bool = True,
|
||||
max_concurrent: int = 3
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Sync all approved zone files.
|
||||
@ -407,26 +439,32 @@ class CZDSClient:
|
||||
Args:
|
||||
db: Database session
|
||||
tlds: Optional list of TLDs to sync. Defaults to APPROVED_TLDS.
|
||||
parallel: If True, download zones in parallel (faster)
|
||||
max_concurrent: Max concurrent downloads (to be nice to ICANN)
|
||||
|
||||
Returns:
|
||||
List of sync results for each TLD.
|
||||
"""
|
||||
target_tlds = tlds or APPROVED_TLDS
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
# Get available zones with their download URLs
|
||||
available_zones = await self.get_available_zones()
|
||||
|
||||
logger.info(f"Starting CZDS sync for {len(target_tlds)} zones: {target_tlds}")
|
||||
logger.info(f"Available zones: {list(available_zones.keys())}")
|
||||
logger.info(f"Mode: {'PARALLEL' if parallel else 'SEQUENTIAL'} (max {max_concurrent} concurrent)")
|
||||
|
||||
# Prepare tasks with their download URLs
|
||||
tasks_to_run = []
|
||||
unavailable_results = []
|
||||
|
||||
results = []
|
||||
for tld in target_tlds:
|
||||
# Get the actual download URL for this TLD
|
||||
download_url = available_zones.get(tld)
|
||||
|
||||
if not download_url:
|
||||
logger.warning(f"No download URL available for .{tld}")
|
||||
results.append({
|
||||
unavailable_results.append({
|
||||
"tld": tld,
|
||||
"status": "not_available",
|
||||
"current_count": 0,
|
||||
@ -435,20 +473,55 @@ class CZDSClient:
|
||||
"new_count": 0,
|
||||
"error": f"No access to .{tld} zone"
|
||||
})
|
||||
continue
|
||||
else:
|
||||
tasks_to_run.append((tld, download_url))
|
||||
|
||||
results = unavailable_results.copy()
|
||||
|
||||
if parallel and len(tasks_to_run) > 1:
|
||||
# Parallel execution with semaphore for rate limiting
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
result = await self.sync_zone(db, tld, download_url)
|
||||
results.append(result)
|
||||
async def sync_with_semaphore(tld: str, url: str) -> dict:
|
||||
async with semaphore:
|
||||
return await self.sync_zone(db, tld, url)
|
||||
|
||||
# Small delay between zones to be nice to ICANN servers
|
||||
await asyncio.sleep(2)
|
||||
# Run all tasks in parallel
|
||||
parallel_results = await asyncio.gather(
|
||||
*[sync_with_semaphore(tld, url) for tld, url in tasks_to_run],
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
# Process results
|
||||
for i, result in enumerate(parallel_results):
|
||||
tld = tasks_to_run[i][0]
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"Parallel sync failed for .{tld}: {result}")
|
||||
results.append({
|
||||
"tld": tld,
|
||||
"status": "error",
|
||||
"current_count": 0,
|
||||
"previous_count": 0,
|
||||
"dropped_count": 0,
|
||||
"new_count": 0,
|
||||
"error": str(result)
|
||||
})
|
||||
else:
|
||||
results.append(result)
|
||||
else:
|
||||
# Sequential execution (fallback)
|
||||
for tld, download_url in tasks_to_run:
|
||||
result = await self.sync_zone(db, tld, download_url)
|
||||
results.append(result)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Summary
|
||||
elapsed = (datetime.utcnow() - start_time).total_seconds()
|
||||
success_count = sum(1 for r in results if r["status"] == "success")
|
||||
total_dropped = sum(r["dropped_count"] for r in results)
|
||||
|
||||
logger.info(
|
||||
f"CZDS sync complete: "
|
||||
f"CZDS sync complete in {elapsed:.1f}s: "
|
||||
f"{success_count}/{len(target_tlds)} zones successful, "
|
||||
f"{total_dropped:,} total dropped domains"
|
||||
)
|
||||
|
||||
256
backend/app/services/dns_zone_manager.py
Normal file
256
backend/app/services/dns_zone_manager.py
Normal file
@ -0,0 +1,256 @@
|
||||
"""
|
||||
DNS Zone Manager for Pounce Yield.
|
||||
|
||||
Manages CoreDNS zone files for yield domains.
|
||||
When a domain is activated for yield, we add it to the zone file.
|
||||
When deactivated, we remove it.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# CoreDNS zone file path
|
||||
ZONE_FILE = Path("/opt/coredns/zones/db.yield")
|
||||
SERVER_IP = "46.235.147.194"
|
||||
|
||||
|
||||
def _get_serial() -> str:
|
||||
"""Generate zone serial in YYYYMMDDNN format."""
|
||||
today = datetime.utcnow().strftime("%Y%m%d")
|
||||
# Read current serial and increment if same day
|
||||
if ZONE_FILE.exists():
|
||||
content = ZONE_FILE.read_text()
|
||||
match = re.search(r"(\d{8})(\d{2})\s*;\s*Serial", content)
|
||||
if match:
|
||||
existing_date = match.group(1)
|
||||
existing_nn = int(match.group(2))
|
||||
if existing_date == today:
|
||||
return f"{today}{(existing_nn + 1):02d}"
|
||||
return f"{today}01"
|
||||
|
||||
|
||||
def _generate_zone_file(domains: list[str]) -> str:
|
||||
"""Generate the complete zone file content."""
|
||||
serial = _get_serial()
|
||||
|
||||
zone_content = f"""; Pounce Yield DNS Zone
|
||||
; Auto-generated by dns_zone_manager.py
|
||||
; Last updated: {datetime.utcnow().isoformat()}Z
|
||||
|
||||
$TTL 300
|
||||
$ORIGIN yield.pounce.ch.
|
||||
|
||||
@ IN SOA ns1.pounce.ch. admin.pounce.ch. (
|
||||
{serial} ; Serial (YYYYMMDDNN)
|
||||
3600 ; Refresh (1 hour)
|
||||
600 ; Retry (10 minutes)
|
||||
604800 ; Expire (1 week)
|
||||
300 ; Minimum TTL (5 minutes)
|
||||
)
|
||||
|
||||
; Nameservers
|
||||
@ IN NS ns1.pounce.ch.
|
||||
@ IN NS ns2.pounce.ch.
|
||||
|
||||
; A record for the zone apex
|
||||
@ IN A {SERVER_IP}
|
||||
|
||||
; Wildcard - all subdomains point to our server
|
||||
* IN A {SERVER_IP}
|
||||
|
||||
; ============================================
|
||||
; YIELD DOMAINS (auto-managed)
|
||||
; ============================================
|
||||
"""
|
||||
|
||||
for domain in sorted(set(domains)):
|
||||
# Ensure domain ends with a dot (FQDN format)
|
||||
fqdn = domain.lower().strip()
|
||||
if not fqdn.endswith("."):
|
||||
fqdn += "."
|
||||
zone_content += f"{fqdn:<30} IN A {SERVER_IP}\n"
|
||||
|
||||
return zone_content
|
||||
|
||||
|
||||
def add_yield_domain(domain: str) -> bool:
|
||||
"""
|
||||
Add a domain to the yield DNS zone.
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
if not ZONE_FILE.exists():
|
||||
logger.warning(f"Zone file not found: {ZONE_FILE}. CoreDNS may not be installed.")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Read current domains from zone file
|
||||
content = ZONE_FILE.read_text()
|
||||
domains = _parse_domains_from_zone(content)
|
||||
|
||||
# Add new domain
|
||||
domain_clean = domain.lower().strip().rstrip(".")
|
||||
if domain_clean not in domains:
|
||||
domains.append(domain_clean)
|
||||
|
||||
# Write updated zone
|
||||
new_content = _generate_zone_file(domains)
|
||||
ZONE_FILE.write_text(new_content)
|
||||
|
||||
# Reload CoreDNS
|
||||
_reload_coredns()
|
||||
|
||||
logger.info(f"Added yield domain to DNS: {domain_clean}")
|
||||
else:
|
||||
logger.info(f"Domain already in DNS zone: {domain_clean}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add domain {domain} to DNS: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def remove_yield_domain(domain: str) -> bool:
|
||||
"""
|
||||
Remove a domain from the yield DNS zone.
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
if not ZONE_FILE.exists():
|
||||
logger.warning(f"Zone file not found: {ZONE_FILE}")
|
||||
return False
|
||||
|
||||
try:
|
||||
content = ZONE_FILE.read_text()
|
||||
domains = _parse_domains_from_zone(content)
|
||||
|
||||
domain_clean = domain.lower().strip().rstrip(".")
|
||||
if domain_clean in domains:
|
||||
domains.remove(domain_clean)
|
||||
|
||||
new_content = _generate_zone_file(domains)
|
||||
ZONE_FILE.write_text(new_content)
|
||||
|
||||
_reload_coredns()
|
||||
|
||||
logger.info(f"Removed yield domain from DNS: {domain_clean}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove domain {domain} from DNS: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def sync_yield_domains(domains: list[str]) -> bool:
|
||||
"""
|
||||
Sync all yield domains to the DNS zone.
|
||||
|
||||
Replaces all existing yield domain entries with the provided list.
|
||||
"""
|
||||
if not ZONE_FILE.parent.exists():
|
||||
logger.warning(f"Zone directory not found: {ZONE_FILE.parent}")
|
||||
return False
|
||||
|
||||
try:
|
||||
clean_domains = [d.lower().strip().rstrip(".") for d in domains]
|
||||
new_content = _generate_zone_file(clean_domains)
|
||||
|
||||
# Ensure directory exists
|
||||
ZONE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
ZONE_FILE.write_text(new_content)
|
||||
|
||||
_reload_coredns()
|
||||
|
||||
logger.info(f"Synced {len(clean_domains)} yield domains to DNS")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync yield domains: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _parse_domains_from_zone(content: str) -> list[str]:
|
||||
"""Extract yield domain names from zone file content."""
|
||||
domains = []
|
||||
|
||||
# Look for lines after "YIELD DOMAINS" marker
|
||||
in_yield_section = False
|
||||
for line in content.split("\n"):
|
||||
if "YIELD DOMAINS" in line:
|
||||
in_yield_section = True
|
||||
continue
|
||||
|
||||
if in_yield_section and line.strip() and not line.strip().startswith(";"):
|
||||
# Parse: domain.tld. IN A IP
|
||||
match = re.match(r"^([a-z0-9.-]+)\.\s+IN\s+A\s+", line, re.IGNORECASE)
|
||||
if match:
|
||||
domain = match.group(1).rstrip(".")
|
||||
# Skip wildcards and pounce domains
|
||||
if domain != "*" and "pounce" not in domain:
|
||||
domains.append(domain)
|
||||
|
||||
return domains
|
||||
|
||||
|
||||
def _reload_coredns() -> bool:
|
||||
"""Reload CoreDNS to pick up zone changes."""
|
||||
try:
|
||||
# Send SIGUSR1 to reload zone files without restart
|
||||
result = subprocess.run(
|
||||
["systemctl", "reload", "coredns"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# Try restart as fallback
|
||||
subprocess.run(
|
||||
["systemctl", "restart", "coredns"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not reload CoreDNS: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def check_coredns_status() -> dict:
|
||||
"""Check if CoreDNS is running and healthy."""
|
||||
status = {
|
||||
"installed": ZONE_FILE.parent.exists(),
|
||||
"running": False,
|
||||
"zone_file_exists": ZONE_FILE.exists(),
|
||||
"domain_count": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "is-active", "coredns"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
status["running"] = result.stdout.strip() == "active"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if status["zone_file_exists"]:
|
||||
content = ZONE_FILE.read_text()
|
||||
status["domain_count"] = len(_parse_domains_from_zone(content))
|
||||
|
||||
return status
|
||||
|
||||
@ -22,6 +22,7 @@ import whodap
|
||||
import httpx
|
||||
|
||||
from app.models.domain import DomainStatus
|
||||
from app.services.http_client_pool import get_rdap_http_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -73,16 +74,17 @@ class DomainChecker:
|
||||
'de', 'uk', 'fr', 'nl', 'eu', 'be', 'at', 'us',
|
||||
}
|
||||
|
||||
# TLDs with custom RDAP endpoints (not in whodap but have their own RDAP servers)
|
||||
# These registries have their own RDAP APIs that we query directly
|
||||
# TLDs with preferred direct RDAP endpoints (faster than IANA bootstrap)
|
||||
CUSTOM_RDAP_ENDPOINTS = {
|
||||
'ch': 'https://rdap.nic.ch/domain/', # Swiss .ch domains (SWITCH)
|
||||
'li': 'https://rdap.nic.ch/domain/', # Liechtenstein .li (same registry)
|
||||
'de': 'https://rdap.denic.de/domain/', # German .de domains (DENIC)
|
||||
}
|
||||
|
||||
# TLDs that only support WHOIS (no RDAP at all)
|
||||
# Note: .ch and .li removed - they have custom RDAP!
|
||||
# IANA Bootstrap - works for ALL TLDs (redirects to correct registry)
|
||||
IANA_BOOTSTRAP_URL = 'https://rdap.org/domain/'
|
||||
|
||||
# TLDs that only support WHOIS (no RDAP at all - very rare)
|
||||
WHOIS_ONLY_TLDS = {
|
||||
'ru', 'su', 'ua', 'by', 'kz',
|
||||
}
|
||||
@ -163,102 +165,116 @@ class DomainChecker:
|
||||
url = f"{endpoint}{domain}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, follow_redirects=True)
|
||||
|
||||
if response.status_code == 404:
|
||||
# Domain not found = available
|
||||
client = await get_rdap_http_client()
|
||||
response = await client.get(url, timeout=10.0)
|
||||
|
||||
if response.status_code == 404:
|
||||
# Domain not found = available
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.AVAILABLE,
|
||||
is_available=True,
|
||||
check_method="rdap_custom",
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
# Domain exists in registry - but check status for pending delete
|
||||
data = response.json()
|
||||
|
||||
# Check if domain is pending deletion (dropped but not yet purged)
|
||||
domain_status = data.get("status", [])
|
||||
pending_delete_statuses = [
|
||||
"pending delete",
|
||||
"pendingdelete",
|
||||
"redemption period",
|
||||
"redemptionperiod",
|
||||
"pending purge",
|
||||
"pendingpurge",
|
||||
]
|
||||
|
||||
is_pending_delete = any(
|
||||
any(pds in str(s).lower() for pds in pending_delete_statuses)
|
||||
for s in domain_status
|
||||
)
|
||||
|
||||
if is_pending_delete:
|
||||
logger.info(
|
||||
f"{domain} is in transition/pending delete (status: {domain_status})"
|
||||
)
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.AVAILABLE,
|
||||
is_available=True,
|
||||
status=DomainStatus.DROPPING_SOON, # In transition, not yet available
|
||||
is_available=False, # Not yet registrable
|
||||
check_method="rdap_custom",
|
||||
raw_data={"rdap_status": domain_status, "note": "pending_delete"},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
# Domain exists = taken
|
||||
data = response.json()
|
||||
|
||||
# Extract dates from events
|
||||
expiration_date = None
|
||||
creation_date = None
|
||||
updated_date = None
|
||||
registrar = None
|
||||
name_servers = []
|
||||
|
||||
# Parse events - different registries use different event actions
|
||||
# SWITCH (.ch/.li): uses "expiration"
|
||||
# DENIC (.de): uses "last changed" but no expiration in RDAP (only WHOIS)
|
||||
events = data.get('events', [])
|
||||
for event in events:
|
||||
action = event.get('eventAction', '').lower()
|
||||
date_str = event.get('eventDate', '')
|
||||
|
||||
# Expiration date - check multiple variations
|
||||
if not expiration_date:
|
||||
if any(x in action for x in ['expiration', 'expire']):
|
||||
expiration_date = self._parse_datetime(date_str)
|
||||
|
||||
# Creation/registration date
|
||||
if not creation_date:
|
||||
if any(x in action for x in ['registration', 'created']):
|
||||
creation_date = self._parse_datetime(date_str)
|
||||
|
||||
# Update date
|
||||
if any(x in action for x in ['changed', 'update', 'last changed']):
|
||||
updated_date = self._parse_datetime(date_str)
|
||||
|
||||
# Parse nameservers
|
||||
nameservers = data.get('nameservers', [])
|
||||
for ns in nameservers:
|
||||
if isinstance(ns, dict):
|
||||
ns_name = ns.get('ldhName', '')
|
||||
if ns_name:
|
||||
name_servers.append(ns_name.lower())
|
||||
|
||||
# Parse registrar from entities - check multiple roles
|
||||
entities = data.get('entities', [])
|
||||
for entity in entities:
|
||||
roles = entity.get('roles', [])
|
||||
# Look for registrar or technical contact as registrar source
|
||||
if any(r in roles for r in ['registrar', 'technical']):
|
||||
# Try vcardArray first
|
||||
vcard = entity.get('vcardArray', [])
|
||||
if isinstance(vcard, list) and len(vcard) > 1:
|
||||
for item in vcard[1]:
|
||||
if isinstance(item, list) and len(item) > 3:
|
||||
if item[0] in ('fn', 'org') and item[3]:
|
||||
registrar = str(item[3])
|
||||
break
|
||||
# Try handle as fallback
|
||||
if not registrar:
|
||||
handle = entity.get('handle', '')
|
||||
if handle:
|
||||
registrar = handle
|
||||
if registrar:
|
||||
break
|
||||
|
||||
# For .de domains: DENIC doesn't expose expiration via RDAP
|
||||
# We need to use WHOIS as fallback for expiration date
|
||||
if tld == 'de' and not expiration_date:
|
||||
logger.debug(f"No expiration in RDAP for {domain}, will try WHOIS")
|
||||
# Return what we have, scheduler will update via WHOIS later
|
||||
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.TAKEN,
|
||||
is_available=False,
|
||||
registrar=registrar,
|
||||
expiration_date=expiration_date,
|
||||
creation_date=creation_date,
|
||||
updated_date=updated_date,
|
||||
name_servers=name_servers if name_servers else None,
|
||||
check_method="rdap_custom",
|
||||
)
|
||||
|
||||
# Other status codes - try fallback
|
||||
logger.warning(f"Custom RDAP returned {response.status_code} for {domain}")
|
||||
return None
|
||||
|
||||
# Extract dates from events
|
||||
expiration_date = None
|
||||
creation_date = None
|
||||
updated_date = None
|
||||
registrar = None
|
||||
name_servers: list[str] = []
|
||||
|
||||
# Parse events
|
||||
events = data.get("events", [])
|
||||
for event in events:
|
||||
action = event.get("eventAction", "").lower()
|
||||
date_str = event.get("eventDate", "")
|
||||
|
||||
if not expiration_date and any(x in action for x in ["expiration", "expire"]):
|
||||
expiration_date = self._parse_datetime(date_str)
|
||||
|
||||
if not creation_date and any(x in action for x in ["registration", "created"]):
|
||||
creation_date = self._parse_datetime(date_str)
|
||||
|
||||
if any(x in action for x in ["changed", "update", "last changed"]):
|
||||
updated_date = self._parse_datetime(date_str)
|
||||
|
||||
# Parse nameservers
|
||||
for ns in data.get("nameservers", []):
|
||||
if isinstance(ns, dict):
|
||||
ns_name = ns.get("ldhName", "")
|
||||
if ns_name:
|
||||
name_servers.append(ns_name.lower())
|
||||
|
||||
# Parse registrar from entities
|
||||
for entity in data.get("entities", []):
|
||||
roles = entity.get("roles", [])
|
||||
if any(r in roles for r in ["registrar", "technical"]):
|
||||
vcard = entity.get("vcardArray", [])
|
||||
if isinstance(vcard, list) and len(vcard) > 1:
|
||||
for item in vcard[1]:
|
||||
if isinstance(item, list) and len(item) > 3:
|
||||
if item[0] in ("fn", "org") and item[3]:
|
||||
registrar = str(item[3])
|
||||
break
|
||||
if not registrar:
|
||||
handle = entity.get("handle", "")
|
||||
if handle:
|
||||
registrar = handle
|
||||
if registrar:
|
||||
break
|
||||
|
||||
# For .de domains: DENIC doesn't expose expiration via RDAP
|
||||
if tld == "de" and not expiration_date:
|
||||
logger.debug(f"No expiration in RDAP for {domain}, will try WHOIS")
|
||||
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.TAKEN,
|
||||
is_available=False,
|
||||
registrar=registrar,
|
||||
expiration_date=expiration_date,
|
||||
creation_date=creation_date,
|
||||
updated_date=updated_date,
|
||||
name_servers=name_servers if name_servers else None,
|
||||
check_method="rdap_custom",
|
||||
)
|
||||
|
||||
# Other status codes - try fallback
|
||||
logger.warning(f"Custom RDAP returned {response.status_code} for {domain}")
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(f"Custom RDAP timeout for {domain}")
|
||||
@ -267,9 +283,101 @@ class DomainChecker:
|
||||
logger.warning(f"Custom RDAP error for {domain}: {e}")
|
||||
return None
|
||||
|
||||
async def _check_rdap_iana(self, domain: str) -> Optional[DomainCheckResult]:
|
||||
"""
|
||||
Check domain using IANA Bootstrap RDAP service.
|
||||
|
||||
This is the most reliable method as rdap.org automatically
|
||||
redirects to the correct registry for any TLD.
|
||||
"""
|
||||
url = f"{self.IANA_BOOTSTRAP_URL}{domain}"
|
||||
|
||||
try:
|
||||
client = await get_rdap_http_client()
|
||||
response = await client.get(url, timeout=15.0)
|
||||
|
||||
if response.status_code == 404:
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.AVAILABLE,
|
||||
is_available=True,
|
||||
check_method="rdap_iana",
|
||||
)
|
||||
|
||||
if response.status_code == 429:
|
||||
logger.warning(f"RDAP rate limited for {domain}")
|
||||
return None
|
||||
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Parse events for dates
|
||||
expiration_date = None
|
||||
creation_date = None
|
||||
registrar = None
|
||||
|
||||
for event in data.get('events', []):
|
||||
action = event.get('eventAction', '').lower()
|
||||
date_str = event.get('eventDate', '')
|
||||
if 'expiration' in action and date_str:
|
||||
expiration_date = self._parse_datetime(date_str)
|
||||
elif 'registration' in action and date_str:
|
||||
creation_date = self._parse_datetime(date_str)
|
||||
|
||||
# Extract registrar
|
||||
for entity in data.get('entities', []):
|
||||
roles = entity.get('roles', [])
|
||||
if 'registrar' in roles:
|
||||
vcard = entity.get('vcardArray', [])
|
||||
if isinstance(vcard, list) and len(vcard) > 1:
|
||||
for item in vcard[1]:
|
||||
if isinstance(item, list) and len(item) > 3:
|
||||
if item[0] == 'fn' and item[3]:
|
||||
registrar = str(item[3])
|
||||
break
|
||||
|
||||
# Check status for pending delete
|
||||
status_list = data.get('status', [])
|
||||
status_str = ' '.join(str(s).lower() for s in status_list)
|
||||
|
||||
is_dropping = any(x in status_str for x in [
|
||||
'pending delete', 'pendingdelete',
|
||||
'redemption period', 'redemptionperiod',
|
||||
])
|
||||
|
||||
if is_dropping:
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.DROPPING_SOON,
|
||||
is_available=False,
|
||||
registrar=registrar,
|
||||
expiration_date=expiration_date,
|
||||
creation_date=creation_date,
|
||||
check_method="rdap_iana",
|
||||
)
|
||||
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.TAKEN,
|
||||
is_available=False,
|
||||
registrar=registrar,
|
||||
expiration_date=expiration_date,
|
||||
creation_date=creation_date,
|
||||
check_method="rdap_iana",
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.debug(f"IANA RDAP timeout for {domain}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"IANA RDAP error for {domain}: {e}")
|
||||
return None
|
||||
|
||||
async def _check_rdap(self, domain: str) -> Optional[DomainCheckResult]:
|
||||
"""
|
||||
Check domain using RDAP (Registration Data Access Protocol).
|
||||
Check domain using RDAP (Registration Data Access Protocol) via whodap library.
|
||||
|
||||
Returns None if RDAP is not available for this TLD.
|
||||
"""
|
||||
@ -292,7 +400,6 @@ class DomainChecker:
|
||||
|
||||
if response.events:
|
||||
for event in response.events:
|
||||
# Access event data from __dict__
|
||||
event_dict = event.__dict__ if hasattr(event, '__dict__') else {}
|
||||
action = event_dict.get('eventAction', '')
|
||||
date_str = event_dict.get('eventDate', '')
|
||||
@ -339,12 +446,10 @@ class DomainChecker:
|
||||
)
|
||||
|
||||
except NotImplementedError:
|
||||
# No RDAP server for this TLD
|
||||
logger.debug(f"No RDAP server for TLD .{tld}")
|
||||
return None
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
# Check if domain is not found (available)
|
||||
if 'not found' in error_msg or '404' in error_msg:
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
@ -352,7 +457,7 @@ class DomainChecker:
|
||||
is_available=True,
|
||||
check_method="rdap",
|
||||
)
|
||||
logger.warning(f"RDAP check failed for {domain}: {e}")
|
||||
logger.debug(f"RDAP check failed for {domain}: {e}")
|
||||
return None
|
||||
|
||||
async def _check_whois(self, domain: str) -> DomainCheckResult:
|
||||
@ -575,32 +680,35 @@ class DomainChecker:
|
||||
# If custom RDAP fails, fall through to DNS check
|
||||
logger.info(f"Custom RDAP failed for {domain}, using DNS fallback")
|
||||
|
||||
# Priority 2: Try standard RDAP via whodap
|
||||
# Priority 2: Try IANA Bootstrap RDAP (works for ALL TLDs!)
|
||||
if tld not in self.WHOIS_ONLY_TLDS and tld not in self.CUSTOM_RDAP_ENDPOINTS:
|
||||
rdap_result = await self._check_rdap(domain)
|
||||
if rdap_result:
|
||||
iana_result = await self._check_rdap_iana(domain)
|
||||
if iana_result:
|
||||
# Validate with DNS if RDAP says available
|
||||
if rdap_result.is_available:
|
||||
if iana_result.is_available:
|
||||
dns_available = await self._check_dns(domain)
|
||||
if not dns_available:
|
||||
rdap_result.status = DomainStatus.TAKEN
|
||||
rdap_result.is_available = False
|
||||
return rdap_result
|
||||
iana_result.status = DomainStatus.TAKEN
|
||||
iana_result.is_available = False
|
||||
return iana_result
|
||||
|
||||
# Priority 3: Fall back to WHOIS (skip for TLDs that block it like .ch)
|
||||
# Priority 3: Fall back to WHOIS
|
||||
if tld not in self.CUSTOM_RDAP_ENDPOINTS:
|
||||
whois_result = await self._check_whois(domain)
|
||||
|
||||
# Validate with DNS
|
||||
if whois_result.is_available:
|
||||
dns_available = await self._check_dns(domain)
|
||||
if not dns_available:
|
||||
whois_result.status = DomainStatus.TAKEN
|
||||
whois_result.is_available = False
|
||||
|
||||
return whois_result
|
||||
try:
|
||||
whois_result = await self._check_whois(domain)
|
||||
|
||||
# Validate with DNS
|
||||
if whois_result.is_available:
|
||||
dns_available = await self._check_dns(domain)
|
||||
if not dns_available:
|
||||
whois_result.status = DomainStatus.TAKEN
|
||||
whois_result.is_available = False
|
||||
|
||||
return whois_result
|
||||
except Exception as e:
|
||||
logger.debug(f"WHOIS failed for {domain}: {e}")
|
||||
|
||||
# Final fallback: DNS-only check (for TLDs where everything else failed)
|
||||
# Final fallback: DNS-only check
|
||||
dns_available = await self._check_dns(domain)
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
@ -684,24 +792,28 @@ async def check_all_domains(db):
|
||||
taken = 0
|
||||
errors = 0
|
||||
|
||||
from app.utils.datetime import to_naive_utc
|
||||
|
||||
for domain_obj in domains:
|
||||
try:
|
||||
check_result = await domain_checker.check_domain(domain_obj.domain)
|
||||
check_result = await domain_checker.check_domain(domain_obj.name)
|
||||
|
||||
# Update domain status
|
||||
domain_obj.status = check_result.status.value
|
||||
domain_obj.status = check_result.status
|
||||
domain_obj.is_available = check_result.is_available
|
||||
domain_obj.last_checked = datetime.utcnow()
|
||||
domain_obj.last_check_method = check_result.check_method
|
||||
|
||||
if check_result.expiration_date:
|
||||
domain_obj.expiration_date = check_result.expiration_date
|
||||
domain_obj.expiration_date = to_naive_utc(check_result.expiration_date)
|
||||
|
||||
# Create check record
|
||||
domain_check = DomainCheck(
|
||||
domain_id=domain_obj.id,
|
||||
status=check_result.status.value,
|
||||
status=check_result.status,
|
||||
is_available=check_result.is_available,
|
||||
check_method=check_result.check_method,
|
||||
response_data=str(check_result.to_dict()),
|
||||
checked_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(domain_check)
|
||||
|
||||
@ -711,10 +823,10 @@ async def check_all_domains(db):
|
||||
else:
|
||||
taken += 1
|
||||
|
||||
logger.info(f"Checked {domain_obj.domain}: {check_result.status.value}")
|
||||
logger.info(f"Checked {domain_obj.name}: {check_result.status.value}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking {domain_obj.domain}: {e}")
|
||||
logger.error(f"Error checking {domain_obj.name}: {e}")
|
||||
errors += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
243
backend/app/services/drop_status_checker.py
Normal file
243
backend/app/services/drop_status_checker.py
Normal file
@ -0,0 +1,243 @@
|
||||
"""
|
||||
Drop Status Checker
|
||||
====================
|
||||
Dedicated RDAP checker for dropped domains.
|
||||
Correctly identifies pending_delete, redemption, and available status.
|
||||
Extracts deletion date for countdown display.
|
||||
|
||||
Uses IANA Bootstrap (rdap.org) as universal fallback for all TLDs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.services.http_client_pool import get_rdap_http_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# RDAP CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
# Preferred direct endpoints (faster, more reliable)
|
||||
PREFERRED_ENDPOINTS = {
|
||||
'ch': 'https://rdap.nic.ch/domain/',
|
||||
'li': 'https://rdap.nic.ch/domain/',
|
||||
'de': 'https://rdap.denic.de/domain/',
|
||||
}
|
||||
|
||||
# IANA Bootstrap - works for ALL TLDs (redirects to correct registry)
|
||||
IANA_BOOTSTRAP = 'https://rdap.org/domain/'
|
||||
|
||||
# Rate limiting settings
|
||||
RDAP_TIMEOUT = 15 # seconds
|
||||
RATE_LIMIT_DELAY = 0.3 # 300ms between requests = ~3 req/s
|
||||
|
||||
|
||||
@dataclass
|
||||
class DropStatus:
|
||||
"""Status of a dropped domain."""
|
||||
domain: str
|
||||
status: str # 'available', 'dropping_soon', 'taken', 'unknown'
|
||||
rdap_status: list[str]
|
||||
can_register_now: bool
|
||||
should_monitor: bool
|
||||
message: str
|
||||
deletion_date: Optional[datetime] = None
|
||||
check_method: str = "rdap"
|
||||
|
||||
|
||||
async def _make_rdap_request(client: httpx.AsyncClient, url: str, domain: str) -> Optional[dict]:
|
||||
"""Make a single RDAP request with proper error handling."""
|
||||
try:
|
||||
resp = await client.get(url, timeout=RDAP_TIMEOUT)
|
||||
|
||||
if resp.status_code == 404:
|
||||
# Domain not found = available
|
||||
return {"_available": True, "_status_code": 404}
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
data["_status_code"] = 200
|
||||
return data
|
||||
|
||||
if resp.status_code == 429:
|
||||
logger.warning(f"RDAP rate limited for {domain}")
|
||||
return {"_rate_limited": True, "_status_code": 429}
|
||||
|
||||
logger.warning(f"RDAP returned {resp.status_code} for {domain}")
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.debug(f"RDAP timeout for {domain} at {url}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"RDAP error for {domain}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def check_drop_status(domain: str) -> DropStatus:
|
||||
"""
|
||||
Check the real status of a dropped domain via RDAP.
|
||||
|
||||
Strategy:
|
||||
1. Try preferred direct endpoint (if available for TLD)
|
||||
2. Fall back to IANA Bootstrap (works for all TLDs)
|
||||
|
||||
Returns:
|
||||
DropStatus with one of:
|
||||
- 'available': Domain can be registered NOW
|
||||
- 'dropping_soon': Domain is in pending delete/redemption
|
||||
- 'taken': Domain was re-registered
|
||||
- 'unknown': Could not determine status
|
||||
"""
|
||||
tld = domain.split('.')[-1].lower()
|
||||
|
||||
# Try preferred endpoint first
|
||||
data = None
|
||||
check_method = "rdap"
|
||||
client = await get_rdap_http_client()
|
||||
|
||||
if tld in PREFERRED_ENDPOINTS:
|
||||
url = f"{PREFERRED_ENDPOINTS[tld]}{domain}"
|
||||
data = await _make_rdap_request(client, url, domain)
|
||||
check_method = f"rdap_{tld}"
|
||||
|
||||
# Fall back to IANA Bootstrap if no data yet
|
||||
if data is None:
|
||||
url = f"{IANA_BOOTSTRAP}{domain}"
|
||||
data = await _make_rdap_request(client, url, domain)
|
||||
check_method = "rdap_iana"
|
||||
|
||||
# Still no data? Return unknown
|
||||
if data is None:
|
||||
return DropStatus(
|
||||
domain=domain,
|
||||
status='unknown',
|
||||
rdap_status=[],
|
||||
can_register_now=False,
|
||||
should_monitor=True,
|
||||
message="RDAP check failed - will retry later",
|
||||
check_method="failed",
|
||||
)
|
||||
|
||||
# Rate limited
|
||||
if data.get("_rate_limited"):
|
||||
return DropStatus(
|
||||
domain=domain,
|
||||
status='unknown',
|
||||
rdap_status=[],
|
||||
can_register_now=False,
|
||||
should_monitor=True,
|
||||
message="Rate limited - will retry later",
|
||||
check_method="rate_limited",
|
||||
)
|
||||
|
||||
# Domain available (404)
|
||||
if data.get("_available"):
|
||||
return DropStatus(
|
||||
domain=domain,
|
||||
status='available',
|
||||
rdap_status=[],
|
||||
can_register_now=True,
|
||||
should_monitor=False,
|
||||
message="Domain is available for registration!",
|
||||
check_method=check_method,
|
||||
)
|
||||
|
||||
# Domain exists - parse status
|
||||
rdap_status = data.get('status', [])
|
||||
status_lower = ' '.join(str(s).lower() for s in rdap_status)
|
||||
|
||||
# Extract deletion date from events
|
||||
deletion_date = None
|
||||
events = data.get('events', [])
|
||||
for event in events:
|
||||
action = event.get('eventAction', '').lower()
|
||||
date_str = event.get('eventDate', '')
|
||||
if action in ('deletion', 'expiration') and date_str:
|
||||
try:
|
||||
deletion_date = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Check for pending delete / redemption status
|
||||
is_pending = any(x in status_lower for x in [
|
||||
'pending delete', 'pendingdelete',
|
||||
'pending purge', 'pendingpurge',
|
||||
'redemption period', 'redemptionperiod',
|
||||
'pending restore', 'pendingrestore',
|
||||
'pending renewal', 'pendingrenewal',
|
||||
])
|
||||
|
||||
if is_pending:
|
||||
return DropStatus(
|
||||
domain=domain,
|
||||
status='dropping_soon',
|
||||
rdap_status=rdap_status,
|
||||
can_register_now=False,
|
||||
should_monitor=True,
|
||||
message="Domain is being deleted. Track it to get notified!",
|
||||
deletion_date=deletion_date,
|
||||
check_method=check_method,
|
||||
)
|
||||
|
||||
# Domain is actively registered
|
||||
return DropStatus(
|
||||
domain=domain,
|
||||
status='taken',
|
||||
rdap_status=rdap_status,
|
||||
can_register_now=False,
|
||||
should_monitor=False,
|
||||
message="Domain was re-registered",
|
||||
deletion_date=None,
|
||||
check_method=check_method,
|
||||
)
|
||||
|
||||
|
||||
async def check_drops_batch(
|
||||
domains: list[tuple[int, str]],
|
||||
delay_between_requests: float = RATE_LIMIT_DELAY,
|
||||
max_concurrent: int = 3,
|
||||
) -> list[tuple[int, DropStatus]]:
|
||||
"""
|
||||
Check multiple drops with rate limiting and concurrency control.
|
||||
|
||||
Args:
|
||||
domains: List of (drop_id, full_domain) tuples
|
||||
delay_between_requests: Seconds to wait between requests
|
||||
max_concurrent: Maximum concurrent requests
|
||||
|
||||
Returns:
|
||||
List of (drop_id, DropStatus) tuples
|
||||
"""
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
results = []
|
||||
|
||||
async def check_with_semaphore(drop_id: int, domain: str) -> tuple[int, DropStatus]:
|
||||
async with semaphore:
|
||||
try:
|
||||
status = await check_drop_status(domain)
|
||||
await asyncio.sleep(delay_between_requests)
|
||||
return (drop_id, status)
|
||||
except Exception as e:
|
||||
logger.error(f"Batch check failed for {domain}: {e}")
|
||||
return (drop_id, DropStatus(
|
||||
domain=domain,
|
||||
status='unknown',
|
||||
rdap_status=[],
|
||||
can_register_now=False,
|
||||
should_monitor=False,
|
||||
message=str(e),
|
||||
check_method="error",
|
||||
))
|
||||
|
||||
# Run with limited concurrency
|
||||
tasks = [check_with_semaphore(drop_id, domain) for drop_id, domain in domains]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
return list(results)
|
||||
@ -727,5 +727,63 @@ class EmailService:
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
async def send_ops_alert(
|
||||
alert_type: str,
|
||||
title: str,
|
||||
details: str,
|
||||
severity: str = "warning", # info, warning, error, critical
|
||||
) -> bool:
|
||||
"""
|
||||
Send operational alert to admin email.
|
||||
|
||||
Used for:
|
||||
- Zone sync failures
|
||||
- Database connection issues
|
||||
- Scheduler job failures
|
||||
- Security incidents
|
||||
"""
|
||||
settings = get_settings()
|
||||
admin_email = settings.smtp_from_email # Send to ourselves for now
|
||||
|
||||
# Build HTML content
|
||||
severity_colors = {
|
||||
"info": "#3b82f6",
|
||||
"warning": "#f59e0b",
|
||||
"error": "#ef4444",
|
||||
"critical": "#dc2626",
|
||||
}
|
||||
color = severity_colors.get(severity, "#6b7280")
|
||||
|
||||
html = f"""
|
||||
<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto; background: #0a0a0a; color: #fff; padding: 24px;">
|
||||
<div style="border-left: 4px solid {color}; padding-left: 16px; margin-bottom: 24px;">
|
||||
<h1 style="margin: 0 0 8px 0; font-size: 18px; color: {color}; text-transform: uppercase;">
|
||||
[{severity.upper()}] {alert_type}
|
||||
</h1>
|
||||
<h2 style="margin: 0; font-size: 24px; color: #fff;">{title}</h2>
|
||||
</div>
|
||||
|
||||
<div style="background: #111; padding: 16px; border: 1px solid #222; font-family: monospace; font-size: 13px; white-space: pre-wrap;">
|
||||
{details}
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 24px; font-size: 12px; color: #666;">
|
||||
<p>Timestamp: {datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")}</p>
|
||||
<p>Server: pounce.ch</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
subject = f"[POUNCE OPS] {severity.upper()}: {title}"
|
||||
|
||||
return await EmailService.send_email(
|
||||
to_email=admin_email,
|
||||
subject=subject,
|
||||
html_content=html,
|
||||
text_content=f"[{severity.upper()}] {alert_type}: {title}\n\n{details}",
|
||||
)
|
||||
|
||||
|
||||
# Global instance
|
||||
email_service = EmailService()
|
||||
|
||||
70
backend/app/services/http_client_pool.py
Normal file
70
backend/app/services/http_client_pool.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""
|
||||
Shared HTTP clients for performance.
|
||||
|
||||
Why:
|
||||
- Creating a new httpx.AsyncClient per request is expensive (TLS handshakes, no connection reuse).
|
||||
- For high-frequency lookups (RDAP), we keep one pooled AsyncClient per process.
|
||||
|
||||
Notes:
|
||||
- Per-request timeouts can still be overridden in client.get(..., timeout=...).
|
||||
- Call close_* on shutdown for clean exit (optional but recommended).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
_rdap_client: Optional[httpx.AsyncClient] = None
|
||||
_rdap_client_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _rdap_limits() -> httpx.Limits:
|
||||
# Conservative but effective defaults (works well for bursty traffic).
|
||||
return httpx.Limits(max_connections=50, max_keepalive_connections=20, keepalive_expiry=30.0)
|
||||
|
||||
|
||||
def _rdap_timeout() -> httpx.Timeout:
|
||||
# Overall timeout can be overridden per request.
|
||||
return httpx.Timeout(15.0, connect=5.0)
|
||||
|
||||
|
||||
async def get_rdap_http_client() -> httpx.AsyncClient:
|
||||
"""
|
||||
Get a shared httpx.AsyncClient for RDAP requests.
|
||||
Safe for concurrent use within the same event loop.
|
||||
"""
|
||||
global _rdap_client
|
||||
if _rdap_client is not None and not _rdap_client.is_closed:
|
||||
return _rdap_client
|
||||
|
||||
async with _rdap_client_lock:
|
||||
if _rdap_client is not None and not _rdap_client.is_closed:
|
||||
return _rdap_client
|
||||
|
||||
_rdap_client = httpx.AsyncClient(
|
||||
timeout=_rdap_timeout(),
|
||||
follow_redirects=True,
|
||||
limits=_rdap_limits(),
|
||||
headers={
|
||||
# Be a good citizen; many registries/redirectors are sensitive.
|
||||
"User-Agent": "pounce/1.0 (+https://pounce.ch)",
|
||||
"Accept": "application/rdap+json, application/json",
|
||||
},
|
||||
)
|
||||
return _rdap_client
|
||||
|
||||
|
||||
async def close_rdap_http_client() -> None:
|
||||
"""Close the shared RDAP client (best-effort)."""
|
||||
global _rdap_client
|
||||
if _rdap_client is None:
|
||||
return
|
||||
try:
|
||||
if not _rdap_client.is_closed:
|
||||
await _rdap_client.aclose()
|
||||
finally:
|
||||
_rdap_client = None
|
||||
|
||||
53
backend/app/services/llm_gateway.py
Normal file
53
backend/app/services/llm_gateway.py
Normal file
@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, AsyncIterator, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class LLMGatewayError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _auth_headers() -> dict[str, str]:
|
||||
key = (settings.llm_gateway_api_key or "").strip()
|
||||
if not key:
|
||||
raise LLMGatewayError("LLM gateway not configured (missing llm_gateway_api_key)")
|
||||
return {"Authorization": f"Bearer {key}"}
|
||||
|
||||
|
||||
async def chat_completions(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Non-streaming call to the LLM gateway (OpenAI-ish format).
|
||||
"""
|
||||
url = settings.llm_gateway_url.rstrip("/") + "/v1/chat/completions"
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
r = await client.post(url, headers=_auth_headers(), json=payload)
|
||||
if r.status_code >= 400:
|
||||
raise LLMGatewayError(f"LLM gateway error: {r.status_code} {r.text[:500]}")
|
||||
return r.json()
|
||||
|
||||
|
||||
async def chat_completions_stream(payload: dict[str, Any]) -> AsyncIterator[bytes]:
|
||||
"""
|
||||
Streaming call to the LLM gateway. The gateway returns SSE; we proxy bytes through.
|
||||
"""
|
||||
url = settings.llm_gateway_url.rstrip("/") + "/v1/chat/completions"
|
||||
timeout = httpx.Timeout(connect=10, read=None, write=10, pool=10)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("POST", url, headers=_auth_headers(), json=payload) as r:
|
||||
if r.status_code >= 400:
|
||||
body = await r.aread()
|
||||
raise LLMGatewayError(f"LLM gateway stream error: {r.status_code} {body[:500].decode('utf-8','ignore')}")
|
||||
|
||||
async for chunk in r.aiter_bytes():
|
||||
if chunk:
|
||||
yield chunk
|
||||
|
||||
|
||||
179
backend/app/services/llm_naming.py
Normal file
179
backend/app/services/llm_naming.py
Normal file
@ -0,0 +1,179 @@
|
||||
"""
|
||||
LLM-powered naming suggestions for Trends and Forge tabs.
|
||||
Uses simple prompts for focused tasks - no complex agent loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services.llm_gateway import chat_completions
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
async def expand_trend_keywords(trend: str, geo: str = "US") -> list[str]:
|
||||
"""
|
||||
Given a trending topic, generate related domain-friendly keywords.
|
||||
Returns a list of 5-10 short, brandable keywords.
|
||||
"""
|
||||
prompt = f"""You are a domain naming expert. Given the trending topic "{trend}" (trending in {geo}),
|
||||
suggest 8-10 short, memorable keywords that would make good domain names.
|
||||
|
||||
Rules:
|
||||
- Each keyword should be 4-10 characters
|
||||
- No spaces, hyphens, or special characters
|
||||
- Mix of: related words, abbreviations, creative variations
|
||||
- Think like a domain investor looking for valuable names
|
||||
|
||||
Return ONLY a JSON array of lowercase strings, nothing else.
|
||||
Example: ["swiftie", "erastour", "taylormerch", "tswift"]"""
|
||||
|
||||
try:
|
||||
res = await chat_completions({
|
||||
"model": settings.llm_default_model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.8,
|
||||
"stream": False,
|
||||
})
|
||||
content = res.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
# Extract JSON array from response
|
||||
match = re.search(r'\[.*?\]', content, re.DOTALL)
|
||||
if match:
|
||||
keywords = json.loads(match.group(0))
|
||||
# Filter and clean
|
||||
return [
|
||||
kw.lower().strip()[:15]
|
||||
for kw in keywords
|
||||
if isinstance(kw, str) and 3 <= len(kw.strip()) <= 15 and kw.isalnum()
|
||||
][:10]
|
||||
except Exception as e:
|
||||
print(f"LLM keyword expansion failed: {e}")
|
||||
|
||||
return []
|
||||
|
||||
|
||||
async def analyze_trend(trend: str, geo: str = "US") -> str:
|
||||
"""
|
||||
Provide a brief analysis of why a trend is relevant for domain investors.
|
||||
Returns 2-3 sentences max.
|
||||
"""
|
||||
prompt = f"""You are a domain investing analyst. The topic "{trend}" is currently trending in {geo}.
|
||||
|
||||
In 2-3 short sentences, explain:
|
||||
1. Why this is trending (if obvious)
|
||||
2. What domain opportunity this presents
|
||||
|
||||
Be concise and actionable. No fluff."""
|
||||
|
||||
try:
|
||||
res = await chat_completions({
|
||||
"model": settings.llm_default_model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.5,
|
||||
"stream": False,
|
||||
})
|
||||
content = res.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
# Clean up and limit length
|
||||
content = content.strip()[:500]
|
||||
return content
|
||||
except Exception as e:
|
||||
print(f"LLM trend analysis failed: {e}")
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
async def generate_brandable_names(
|
||||
concept: str,
|
||||
style: Optional[str] = None,
|
||||
count: int = 15
|
||||
) -> list[str]:
|
||||
"""
|
||||
Generate brandable domain names based on a concept description.
|
||||
|
||||
Args:
|
||||
concept: Description like "AI startup for legal documents"
|
||||
style: Optional style hint like "professional", "playful", "tech"
|
||||
count: Number of names to generate
|
||||
|
||||
Returns list of brandable name suggestions (without TLD).
|
||||
"""
|
||||
style_hint = f" The style should be {style}." if style else ""
|
||||
|
||||
prompt = f"""You are an expert brand naming consultant. Generate {count} unique, brandable domain names for: "{concept}"{style_hint}
|
||||
|
||||
Rules:
|
||||
- Names must be 4-8 characters (shorter is better)
|
||||
- Easy to spell and pronounce
|
||||
- Memorable and unique
|
||||
- No dictionary words (invented names only)
|
||||
- Mix of patterns: CVCVC (Zalor), CVCCV (Bento), short words (Lyft)
|
||||
|
||||
Return ONLY a JSON array of lowercase strings, nothing else.
|
||||
Example: ["zenix", "klaro", "voxly", "nimbl", "brivv"]"""
|
||||
|
||||
try:
|
||||
res = await chat_completions({
|
||||
"model": settings.llm_default_model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.9, # Higher creativity
|
||||
"stream": False,
|
||||
})
|
||||
content = res.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
# Extract JSON array
|
||||
match = re.search(r'\[.*?\]', content, re.DOTALL)
|
||||
if match:
|
||||
names = json.loads(match.group(0))
|
||||
# Filter and clean
|
||||
return [
|
||||
name.lower().strip()
|
||||
for name in names
|
||||
if isinstance(name, str) and 3 <= len(name.strip()) <= 12 and name.isalnum()
|
||||
][:count]
|
||||
except Exception as e:
|
||||
print(f"LLM brandable generation failed: {e}")
|
||||
|
||||
return []
|
||||
|
||||
|
||||
async def generate_similar_names(brand: str, count: int = 12) -> list[str]:
|
||||
"""
|
||||
Generate names similar to an existing brand.
|
||||
Useful for finding alternatives or inspired names.
|
||||
"""
|
||||
prompt = f"""You are a brand naming expert. Generate {count} new brandable names INSPIRED BY (but not copying) "{brand}".
|
||||
|
||||
The names should:
|
||||
- Have similar length and rhythm to "{brand}"
|
||||
- Feel like they belong in the same industry
|
||||
- Be completely original (not existing brands)
|
||||
- Be 4-8 characters, easy to spell
|
||||
|
||||
Return ONLY a JSON array of lowercase strings, nothing else."""
|
||||
|
||||
try:
|
||||
res = await chat_completions({
|
||||
"model": settings.llm_default_model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.85,
|
||||
"stream": False,
|
||||
})
|
||||
content = res.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
match = re.search(r'\[.*?\]', content, re.DOTALL)
|
||||
if match:
|
||||
names = json.loads(match.group(0))
|
||||
return [
|
||||
name.lower().strip()
|
||||
for name in names
|
||||
if isinstance(name, str) and 3 <= len(name.strip()) <= 12 and name.isalnum()
|
||||
][:count]
|
||||
except Exception as e:
|
||||
print(f"LLM similar names failed: {e}")
|
||||
|
||||
return []
|
||||
|
||||
166
backend/app/services/llm_vision.py
Normal file
166
backend/app/services/llm_vision.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""
|
||||
Vision + Yield landing generation via internal LLM gateway.
|
||||
|
||||
Outputs MUST be strict JSON to support caching + UI rendering.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError, conint
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services.llm_gateway import chat_completions
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
VISION_PROMPT_VERSION = "v1"
|
||||
YIELD_LANDING_PROMPT_VERSION = "v1"
|
||||
|
||||
|
||||
class VisionResult(BaseModel):
|
||||
business_concept: str = Field(..., min_length=10, max_length=240)
|
||||
industry_vertical: str = Field(..., min_length=2, max_length=60)
|
||||
buyer_persona: str = Field(..., min_length=5, max_length=120)
|
||||
cold_email_subject: str = Field(..., min_length=5, max_length=120)
|
||||
cold_email_body: str = Field(..., min_length=20, max_length=800)
|
||||
monetization_idea: str = Field(..., min_length=10, max_length=240)
|
||||
radio_test_score: conint(ge=1, le=10) # type: ignore[valid-type]
|
||||
reasoning: str = Field(..., min_length=20, max_length=800)
|
||||
|
||||
|
||||
class YieldLandingConfig(BaseModel):
|
||||
template: str = Field(..., min_length=2, max_length=50) # e.g. "nature", "commerce", "tech"
|
||||
headline: str = Field(..., min_length=10, max_length=180)
|
||||
seo_intro: str = Field(..., min_length=80, max_length=800)
|
||||
cta_label: str = Field(..., min_length=4, max_length=60)
|
||||
niche: str = Field(..., min_length=2, max_length=60)
|
||||
color_scheme: str = Field(..., min_length=2, max_length=30)
|
||||
|
||||
|
||||
def _extract_first_json_object(text: str) -> str:
|
||||
"""
|
||||
Extract the first {...} JSON object from text.
|
||||
We do NOT generate fallback content; if parsing fails, caller must raise.
|
||||
"""
|
||||
s = (text or "").strip()
|
||||
if not s:
|
||||
raise ValueError("Empty LLM response")
|
||||
if s.startswith("{") and s.endswith("}"):
|
||||
return s
|
||||
start = s.find("{")
|
||||
end = s.rfind("}")
|
||||
if start == -1 or end == -1 or end <= start:
|
||||
raise ValueError("LLM response is not JSON")
|
||||
return s[start : end + 1]
|
||||
|
||||
|
||||
async def generate_vision(domain: str) -> tuple[VisionResult, str]:
|
||||
"""
|
||||
Returns (VisionResult, model_used).
|
||||
"""
|
||||
model = settings.llm_default_model
|
||||
system = (
|
||||
"You are the Pounce AI, a domain intelligence engine.\n"
|
||||
"You must respond with STRICT JSON only. No markdown. No commentary.\n"
|
||||
"Language: English.\n"
|
||||
"If a field is unknown, make a best-effort realistic assumption.\n"
|
||||
)
|
||||
user = (
|
||||
f"Analyze domain '{domain}'.\n"
|
||||
"Act as a VC + domain broker.\n"
|
||||
"Create a realistic business concept and a buyer/outreach angle.\n"
|
||||
"Output STRICT JSON with exactly these keys:\n"
|
||||
"{\n"
|
||||
' "business_concept": "...",\n'
|
||||
' "industry_vertical": "...",\n'
|
||||
' "buyer_persona": "...",\n'
|
||||
' "cold_email_subject": "...",\n'
|
||||
' "cold_email_body": "...",\n'
|
||||
' "monetization_idea": "...",\n'
|
||||
' "radio_test_score": 1,\n'
|
||||
' "reasoning": "..."\n'
|
||||
"}\n"
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"temperature": 0.6,
|
||||
"stream": False,
|
||||
}
|
||||
res = await chat_completions(payload)
|
||||
content = (
|
||||
res.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
)
|
||||
json_str = _extract_first_json_object(str(content))
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to parse LLM JSON: {e}") from e
|
||||
try:
|
||||
return VisionResult.model_validate(data), model
|
||||
except ValidationError as e:
|
||||
raise ValueError(f"LLM JSON schema mismatch: {e}") from e
|
||||
|
||||
|
||||
async def generate_yield_landing(domain: str) -> tuple[YieldLandingConfig, str]:
|
||||
"""
|
||||
Returns (YieldLandingConfig, model_used).
|
||||
"""
|
||||
model = settings.llm_default_model
|
||||
system = (
|
||||
"You are the Pounce AI, a domain monetization engine.\n"
|
||||
"You must respond with STRICT JSON only. No markdown. No commentary.\n"
|
||||
"Language: English.\n"
|
||||
"Write helpful, non-spammy copy. Avoid medical/legal claims.\n"
|
||||
)
|
||||
user = (
|
||||
f"Analyze domain '{domain}'.\n"
|
||||
"Goal: create a minimal SEO-friendly landing page plan that can route visitors to an affiliate offer.\n"
|
||||
"Output STRICT JSON with exactly these keys:\n"
|
||||
"{\n"
|
||||
' "template": "tech|commerce|finance|nature|local|generic",\n'
|
||||
' "headline": "...",\n'
|
||||
' "seo_intro": "...",\n'
|
||||
' "cta_label": "...",\n'
|
||||
' "niche": "...",\n'
|
||||
' "color_scheme": "..." \n'
|
||||
"}\n"
|
||||
"Keep seo_intro 120-220 words.\n"
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"temperature": 0.5,
|
||||
"stream": False,
|
||||
}
|
||||
res = await chat_completions(payload)
|
||||
content = (
|
||||
res.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
)
|
||||
json_str = _extract_first_json_object(str(content))
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to parse LLM JSON: {e}") from e
|
||||
try:
|
||||
return YieldLandingConfig.model_validate(data), model
|
||||
except ValidationError as e:
|
||||
raise ValueError(f"LLM JSON schema mismatch: {e}") from e
|
||||
|
||||
@ -206,6 +206,38 @@ class StripeService:
|
||||
logger.error(f"Stripe error creating portal session: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
async def cancel_subscription(stripe_subscription_id: str) -> bool:
|
||||
"""
|
||||
Cancel a subscription in Stripe.
|
||||
|
||||
Args:
|
||||
stripe_subscription_id: The Stripe subscription ID to cancel
|
||||
|
||||
Returns:
|
||||
True if cancelled successfully, False otherwise
|
||||
"""
|
||||
if not StripeService.is_configured():
|
||||
logger.warning("Stripe not configured, skipping cancel")
|
||||
return False
|
||||
|
||||
if not stripe_subscription_id:
|
||||
logger.warning("No Stripe subscription ID provided")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Cancel the subscription immediately
|
||||
stripe.Subscription.cancel(stripe_subscription_id)
|
||||
logger.info(f"Cancelled Stripe subscription: {stripe_subscription_id}")
|
||||
return True
|
||||
except stripe.error.InvalidRequestError as e:
|
||||
# Subscription might already be cancelled
|
||||
logger.warning(f"Stripe subscription cancel failed (may already be cancelled): {e}")
|
||||
return True # Consider it success if already cancelled
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error(f"Stripe error cancelling subscription: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def handle_webhook(
|
||||
payload: bytes,
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
Yield DNS verification helpers.
|
||||
|
||||
Production-grade DNS checks for the Yield Connect flow:
|
||||
- Option A (recommended): Nameserver delegation to our nameservers
|
||||
- Option B (simpler): CNAME/ALIAS to a shared target
|
||||
- Option A: A-record pointing directly to our server IP (simplest!)
|
||||
- Option B: CNAME/ALIAS to yield.pounce.ch
|
||||
- Option C: Nameserver delegation to our nameservers (requires DNS server)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -14,22 +15,34 @@ from typing import Optional
|
||||
import dns.resolver
|
||||
|
||||
|
||||
# Our server IP - domains with A-record pointing here are verified
|
||||
YIELD_SERVER_IP = "46.235.147.194"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class YieldDNSCheckResult:
|
||||
verified: bool
|
||||
method: Optional[str] # "nameserver" | "cname" | None
|
||||
method: Optional[str] # "a_record" | "cname" | "nameserver" | None
|
||||
actual_ns: list[str]
|
||||
actual_a: list[str]
|
||||
cname_ok: bool
|
||||
error: Optional[str]
|
||||
|
||||
|
||||
def _resolver() -> dns.resolver.Resolver:
|
||||
def _resolver(nameserver: str | None = None) -> dns.resolver.Resolver:
|
||||
"""Create a DNS resolver, optionally using a specific nameserver."""
|
||||
r = dns.resolver.Resolver()
|
||||
if nameserver:
|
||||
r.nameservers = [nameserver]
|
||||
r.timeout = 3
|
||||
r.lifetime = 5
|
||||
return r
|
||||
|
||||
|
||||
# Multiple public DNS servers to check (for propagation consistency)
|
||||
PUBLIC_DNS_SERVERS = ['8.8.8.8', '8.8.4.4', '9.9.9.9', '1.1.1.1']
|
||||
|
||||
|
||||
def _normalize_host(host: str) -> str:
|
||||
return host.rstrip(".").lower().strip()
|
||||
|
||||
@ -37,7 +50,6 @@ def _normalize_host(host: str) -> str:
|
||||
def _resolve_ns(domain: str) -> list[str]:
|
||||
r = _resolver()
|
||||
answers = r.resolve(domain, "NS")
|
||||
# NS answers are RRset with .target
|
||||
return sorted({_normalize_host(str(rr.target)) for rr in answers})
|
||||
|
||||
|
||||
@ -53,116 +65,129 @@ def _resolve_a(host: str) -> list[str]:
|
||||
return sorted({str(rr) for rr in answers})
|
||||
|
||||
|
||||
def _resolve_a_from_multiple_dns(host: str) -> set[str]:
|
||||
"""
|
||||
Resolve A records from multiple public DNS servers.
|
||||
Returns the union of all IPs found (handles propagation delays).
|
||||
"""
|
||||
all_ips: set[str] = set()
|
||||
for ns in PUBLIC_DNS_SERVERS:
|
||||
try:
|
||||
r = _resolver(ns)
|
||||
answers = r.resolve(host, "A")
|
||||
for rr in answers:
|
||||
all_ips.add(str(rr))
|
||||
except Exception:
|
||||
continue
|
||||
return all_ips
|
||||
|
||||
|
||||
def verify_yield_dns(domain: str, expected_nameservers: list[str], cname_target: str) -> YieldDNSCheckResult:
|
||||
"""
|
||||
Verify that a domain is connected for Yield.
|
||||
|
||||
We accept:
|
||||
- Nameserver delegation (NS contains all expected nameservers), OR
|
||||
- CNAME/ALIAS to `cname_target` (either CNAME matches, or A records match target A records)
|
||||
We accept (in order of simplicity):
|
||||
1. A-record pointing directly to our server IP (46.235.147.194)
|
||||
2. CNAME/ALIAS to `cname_target` (yield.pounce.ch)
|
||||
3. Nameserver delegation (NS contains all expected nameservers)
|
||||
"""
|
||||
domain = _normalize_host(domain)
|
||||
expected_ns = sorted({_normalize_host(ns) for ns in expected_nameservers if ns})
|
||||
target = _normalize_host(cname_target)
|
||||
actual_ns: list[str] = []
|
||||
actual_a: list[str] = []
|
||||
|
||||
if not domain:
|
||||
return YieldDNSCheckResult(
|
||||
verified=False,
|
||||
method=None,
|
||||
actual_ns=[],
|
||||
actual_a=[],
|
||||
cname_ok=False,
|
||||
error="Domain is empty",
|
||||
)
|
||||
if not expected_ns and not target:
|
||||
return YieldDNSCheckResult(
|
||||
verified=False,
|
||||
method=None,
|
||||
actual_ns=[],
|
||||
cname_ok=False,
|
||||
error="Yield DNS is not configured on server",
|
||||
)
|
||||
|
||||
# Option A: NS delegation
|
||||
# Option A: Direct A-record to our server IP (SIMPLEST!)
|
||||
# Check multiple DNS servers to handle propagation delays
|
||||
try:
|
||||
actual_ns = _resolve_ns(domain)
|
||||
if expected_ns and set(expected_ns).issubset(set(actual_ns)):
|
||||
all_ips = _resolve_a_from_multiple_dns(domain)
|
||||
actual_a = sorted(all_ips)
|
||||
if YIELD_SERVER_IP in all_ips:
|
||||
return YieldDNSCheckResult(
|
||||
verified=True,
|
||||
method="nameserver",
|
||||
actual_ns=actual_ns,
|
||||
method="a_record",
|
||||
actual_ns=[],
|
||||
actual_a=actual_a,
|
||||
cname_ok=False,
|
||||
error=None,
|
||||
)
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
actual_ns = []
|
||||
except Exception as e:
|
||||
return YieldDNSCheckResult(
|
||||
verified=False,
|
||||
method=None,
|
||||
actual_ns=[],
|
||||
actual_a=[],
|
||||
cname_ok=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# Option B: CNAME / ALIAS
|
||||
if not target:
|
||||
return YieldDNSCheckResult(
|
||||
verified=False,
|
||||
method=None,
|
||||
actual_ns=actual_ns,
|
||||
cname_ok=False,
|
||||
error="Yield CNAME target is not configured on server",
|
||||
)
|
||||
# Option B: CNAME to yield.pounce.ch
|
||||
if target:
|
||||
try:
|
||||
cnames = _resolve_cname(domain)
|
||||
if any(c == target for c in cnames):
|
||||
return YieldDNSCheckResult(
|
||||
verified=True,
|
||||
method="cname",
|
||||
actual_ns=[],
|
||||
actual_a=actual_a,
|
||||
cname_ok=True,
|
||||
error=None,
|
||||
)
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also check if A-records match target's A-records (ALIAS/ANAME flattening)
|
||||
try:
|
||||
target_as = set(_resolve_a(target))
|
||||
if target_as and actual_a and set(actual_a).issubset(target_as):
|
||||
return YieldDNSCheckResult(
|
||||
verified=True,
|
||||
method="cname",
|
||||
actual_ns=[],
|
||||
actual_a=actual_a,
|
||||
cname_ok=True,
|
||||
error=None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 1) Direct CNAME check (works for subdomain CNAME setups)
|
||||
try:
|
||||
cnames = _resolve_cname(domain)
|
||||
if any(c == target for c in cnames):
|
||||
return YieldDNSCheckResult(
|
||||
verified=True,
|
||||
method="cname",
|
||||
actual_ns=actual_ns,
|
||||
cname_ok=True,
|
||||
error=None,
|
||||
)
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
pass
|
||||
except Exception as e:
|
||||
return YieldDNSCheckResult(
|
||||
verified=False,
|
||||
method=None,
|
||||
actual_ns=actual_ns,
|
||||
cname_ok=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# 2) ALIAS/ANAME flattening: compare A records against target A records
|
||||
try:
|
||||
target_as = set(_resolve_a(target))
|
||||
domain_as = set(_resolve_a(domain))
|
||||
if target_as and domain_as and domain_as.issubset(target_as):
|
||||
return YieldDNSCheckResult(
|
||||
verified=True,
|
||||
method="cname",
|
||||
actual_ns=actual_ns,
|
||||
cname_ok=True,
|
||||
error=None,
|
||||
)
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
pass
|
||||
except Exception as e:
|
||||
return YieldDNSCheckResult(
|
||||
verified=False,
|
||||
method=None,
|
||||
actual_ns=actual_ns,
|
||||
cname_ok=False,
|
||||
error=str(e),
|
||||
)
|
||||
# Option C: NS delegation (requires DNS server on Port 53)
|
||||
if expected_ns:
|
||||
try:
|
||||
actual_ns = _resolve_ns(domain)
|
||||
if set(expected_ns).issubset(set(actual_ns)):
|
||||
return YieldDNSCheckResult(
|
||||
verified=True,
|
||||
method="nameserver",
|
||||
actual_ns=actual_ns,
|
||||
actual_a=actual_a,
|
||||
cname_ok=False,
|
||||
error=None,
|
||||
)
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Not verified
|
||||
return YieldDNSCheckResult(
|
||||
verified=False,
|
||||
method=None,
|
||||
actual_ns=actual_ns,
|
||||
actual_a=actual_a,
|
||||
cname_ok=False,
|
||||
error=None,
|
||||
)
|
||||
|
||||
105
backend/app/services/yield_landing_page.py
Normal file
105
backend/app/services/yield_landing_page.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""
|
||||
Generate a minimal, SEO-friendly landing page HTML for Yield domains.
|
||||
|
||||
The content comes from LLM-generated config stored on YieldDomain.
|
||||
No placeholders or demo content: if required fields are missing, caller should error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from html import escape
|
||||
|
||||
from app.models.yield_domain import YieldDomain
|
||||
|
||||
|
||||
def render_yield_landing_html(*, yield_domain: YieldDomain, cta_url: str) -> str:
|
||||
headline = (yield_domain.landing_headline or "").strip()
|
||||
intro = (yield_domain.landing_intro or "").strip()
|
||||
cta_label = (yield_domain.landing_cta_label or "").strip()
|
||||
|
||||
if not headline or not intro or not cta_label:
|
||||
raise ValueError("Yield landing config missing (headline/intro/cta_label)")
|
||||
|
||||
# Simple premium dark theme, fast to render, readable.
|
||||
# Important: CTA must point to cta_url (which will record the click + redirect).
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{escape(headline)}</title>
|
||||
<meta name="description" content="{escape(intro[:160])}" />
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #050505;
|
||||
--panel: rgba(255,255,255,0.03);
|
||||
--border: rgba(255,255,255,0.12);
|
||||
--text: rgba(255,255,255,0.92);
|
||||
--muted: rgba(255,255,255,0.60);
|
||||
--accent: #10b981;
|
||||
}}
|
||||
html, body {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
}}
|
||||
.wrap {{
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 20px;
|
||||
}}
|
||||
.card {{
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
padding: 28px;
|
||||
}}
|
||||
h1 {{
|
||||
font-size: 34px;
|
||||
line-height: 1.15;
|
||||
margin: 0 0 14px;
|
||||
letter-spacing: -0.02em;
|
||||
}}
|
||||
p {{
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}}
|
||||
.cta {{
|
||||
margin-top: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
height: 44px;
|
||||
padding: 0 18px;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}}
|
||||
.fine {{
|
||||
margin-top: 18px;
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.35);
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h1>{escape(headline)}</h1>
|
||||
<p>{escape(intro)}</p>
|
||||
<a class="cta" href="{escape(cta_url)}" rel="nofollow noopener">{escape(cta_label)}</a>
|
||||
<div class="fine">Powered by Pounce Yield</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@ -15,30 +15,17 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.zone_file import ZoneSnapshot, DroppedDomain
|
||||
from app.utils.datetime import to_iso_utc, to_naive_utc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TSIG KEYS (from Switch.ch documentation)
|
||||
# ============================================================================
|
||||
|
||||
TSIG_KEYS = {
|
||||
"ch": {
|
||||
"name": "tsig-zonedata-ch-public-21-01",
|
||||
"algorithm": "hmac-sha512",
|
||||
"secret": "stZwEGApYumtXkh73qMLPqfbIDozWKZLkqRvcjKSpRnsor6A6MxixRL6C2HeSVBQNfMW4wer+qjS0ZSfiWiJ3Q=="
|
||||
},
|
||||
"li": {
|
||||
"name": "tsig-zonedata-li-public-21-01",
|
||||
"algorithm": "hmac-sha512",
|
||||
"secret": "t8GgeCn+fhPaj+cRy1epox2Vj4hZ45ax6v3rQCkkfIQNg5fsxuU23QM5mzz+BxJ4kgF/jiQyBDBvL+XWPE6oCQ=="
|
||||
}
|
||||
}
|
||||
|
||||
ZONE_SERVER = "zonedata.switch.ch"
|
||||
|
||||
# ============================================================================
|
||||
@ -49,16 +36,36 @@ class ZoneFileService:
|
||||
"""Service for fetching and analyzing zone files"""
|
||||
|
||||
def __init__(self, data_dir: Optional[Path] = None):
|
||||
self.data_dir = data_dir or Path("/tmp/pounce_zones")
|
||||
settings = get_settings()
|
||||
self.data_dir = data_dir or Path(settings.switch_data_dir)
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._settings = settings
|
||||
# Store daily snapshots for N days (premium reliability)
|
||||
self.snapshots_dir = self.data_dir / "snapshots"
|
||||
self.snapshots_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_tsig_config(self, tld: str) -> dict:
|
||||
"""Resolve TSIG config from settings/env (no secrets in git)."""
|
||||
if tld == "ch":
|
||||
return {
|
||||
"name": self._settings.switch_tsig_ch_name,
|
||||
"algorithm": self._settings.switch_tsig_ch_algorithm,
|
||||
"secret": self._settings.switch_tsig_ch_secret,
|
||||
}
|
||||
if tld == "li":
|
||||
return {
|
||||
"name": self._settings.switch_tsig_li_name,
|
||||
"algorithm": self._settings.switch_tsig_li_algorithm,
|
||||
"secret": self._settings.switch_tsig_li_secret,
|
||||
}
|
||||
raise ValueError(f"Unknown TLD: {tld}")
|
||||
|
||||
def _get_key_file_path(self, tld: str) -> Path:
|
||||
"""Generate TSIG key file for dig command"""
|
||||
key_path = self.data_dir / f"{tld}_zonedata.key"
|
||||
key_info = TSIG_KEYS.get(tld)
|
||||
|
||||
if not key_info:
|
||||
raise ValueError(f"Unknown TLD: {tld}")
|
||||
key_info = self._get_tsig_config(tld)
|
||||
if not (key_info.get("secret") or "").strip():
|
||||
raise RuntimeError(f"Missing Switch TSIG secret for .{tld} (set SWITCH_TSIG_{tld.upper()}_SECRET)")
|
||||
|
||||
# Write TSIG key file in BIND format
|
||||
key_content = f"""key "{key_info['name']}" {{
|
||||
@ -74,7 +81,7 @@ class ZoneFileService:
|
||||
Fetch zone file via DNS AXFR transfer.
|
||||
Returns set of domain names (without TLD suffix).
|
||||
"""
|
||||
if tld not in TSIG_KEYS:
|
||||
if tld not in ("ch", "li"):
|
||||
raise ValueError(f"Unsupported TLD: {tld}. Only 'ch' and 'li' are supported.")
|
||||
|
||||
logger.info(f"Starting zone transfer for .{tld}")
|
||||
@ -141,22 +148,60 @@ class ZoneFileService:
|
||||
|
||||
async def get_previous_snapshot(self, db: AsyncSession, tld: str) -> Optional[set[str]]:
|
||||
"""Load previous day's domain set from cache file"""
|
||||
# Prefer most recent snapshot file before today (supports N-day retention)
|
||||
tld_dir = self.snapshots_dir / tld
|
||||
if tld_dir.exists():
|
||||
candidates = sorted([p for p in tld_dir.glob("*.domains.txt") if p.is_file()])
|
||||
if candidates:
|
||||
# Pick the latest snapshot file (by name sort = date sort)
|
||||
latest = candidates[-1]
|
||||
try:
|
||||
content = latest.read_text()
|
||||
return set(line.strip() for line in content.splitlines() if line.strip())
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load snapshot for .{tld} from {latest.name}: {e}")
|
||||
|
||||
# Fallback: legacy cache file
|
||||
cache_file = self.data_dir / f"{tld}_domains.txt"
|
||||
|
||||
if cache_file.exists():
|
||||
try:
|
||||
content = cache_file.read_text()
|
||||
return set(line.strip() for line in content.splitlines() if line.strip())
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load cache for .{tld}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _cleanup_snapshot_files(self, tld: str) -> None:
|
||||
"""Delete snapshot files older than retention window (best-effort)."""
|
||||
keep_days = int(self._settings.zone_retention_days or 3)
|
||||
cutoff = datetime.utcnow().date() - timedelta(days=keep_days)
|
||||
tld_dir = self.snapshots_dir / tld
|
||||
if not tld_dir.exists():
|
||||
return
|
||||
for p in tld_dir.glob("*.domains.txt"):
|
||||
try:
|
||||
# filename: YYYY-MM-DD.domains.txt
|
||||
date_part = p.name.split(".")[0]
|
||||
snap_date = datetime.fromisoformat(date_part).date()
|
||||
if snap_date < cutoff:
|
||||
p.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
# Don't let cleanup break sync
|
||||
continue
|
||||
|
||||
async def save_snapshot(self, db: AsyncSession, tld: str, domains: set[str]):
|
||||
"""Save current snapshot to cache and database"""
|
||||
# Save to cache file
|
||||
# Save to legacy cache file (fast path)
|
||||
cache_file = self.data_dir / f"{tld}_domains.txt"
|
||||
cache_file.write_text("\n".join(sorted(domains)))
|
||||
|
||||
# Save a daily snapshot file for retention/debugging
|
||||
tld_dir = self.snapshots_dir / tld
|
||||
tld_dir.mkdir(parents=True, exist_ok=True)
|
||||
today_str = datetime.utcnow().date().isoformat()
|
||||
snapshot_file = tld_dir / f"{today_str}.domains.txt"
|
||||
snapshot_file.write_text("\n".join(sorted(domains)))
|
||||
self._cleanup_snapshot_files(tld)
|
||||
|
||||
# Save metadata to database
|
||||
checksum = self.compute_checksum(domains)
|
||||
@ -178,39 +223,71 @@ class ZoneFileService:
|
||||
previous: set[str],
|
||||
current: set[str]
|
||||
) -> list[dict]:
|
||||
"""Find and store dropped domains"""
|
||||
"""
|
||||
Find dropped domains and store them directly.
|
||||
|
||||
NOTE: We do NOT verify availability via RDAP here to avoid rate limits/bans.
|
||||
Zone file diff is already a reliable signal that the domain was dropped.
|
||||
"""
|
||||
dropped = previous - current
|
||||
|
||||
if not dropped:
|
||||
logger.info(f"No dropped domains found for .{tld}")
|
||||
return []
|
||||
|
||||
logger.info(f"Found {len(dropped)} dropped domains for .{tld}")
|
||||
logger.info(f"Found {len(dropped):,} dropped domains for .{tld}, saving to database...")
|
||||
|
||||
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Store dropped domains
|
||||
dropped_records = []
|
||||
for name in dropped:
|
||||
record = DroppedDomain(
|
||||
domain=f"{name}.{tld}",
|
||||
tld=tld,
|
||||
dropped_date=today,
|
||||
length=len(name),
|
||||
is_numeric=name.isdigit(),
|
||||
has_hyphen='-' in name
|
||||
)
|
||||
db.add(record)
|
||||
dropped_records.append({
|
||||
"domain": f"{name}.{tld}",
|
||||
dropped_list = list(dropped)
|
||||
|
||||
rows = [
|
||||
{
|
||||
"domain": name,
|
||||
"tld": tld,
|
||||
"dropped_date": today,
|
||||
"length": len(name),
|
||||
"is_numeric": name.isdigit(),
|
||||
"has_hyphen": '-' in name
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
|
||||
return dropped_records
|
||||
"has_hyphen": "-" in name,
|
||||
"availability_status": "unknown",
|
||||
}
|
||||
for name in dropped_list
|
||||
]
|
||||
|
||||
# Bulk insert with conflict-ignore (needs unique index, see db_migrations.py)
|
||||
dialect = db.get_bind().dialect.name if db.get_bind() is not None else "unknown"
|
||||
batch_size = 5000
|
||||
inserted_total = 0
|
||||
|
||||
for i in range(0, len(rows), batch_size):
|
||||
batch = rows[i : i + batch_size]
|
||||
|
||||
if dialect == "postgresql":
|
||||
stmt = (
|
||||
pg_insert(DroppedDomain)
|
||||
.values(batch)
|
||||
.on_conflict_do_nothing(index_elements=["domain", "tld", "dropped_date"])
|
||||
)
|
||||
elif dialect == "sqlite":
|
||||
# SQLite: INSERT OR IGNORE (unique index is still respected)
|
||||
stmt = sqlite_insert(DroppedDomain).values(batch).prefix_with("OR IGNORE")
|
||||
else:
|
||||
# Fallback: best-effort plain insert; duplicates are handled by DB constraints if present.
|
||||
stmt = pg_insert(DroppedDomain).values(batch)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
# rowcount is driver-dependent; still useful for postgres/sqlite
|
||||
inserted_total += int(getattr(result, "rowcount", 0) or 0)
|
||||
await db.commit()
|
||||
|
||||
if (i + batch_size) % 20000 == 0:
|
||||
logger.info(f"Saved {min(i + batch_size, len(rows)):,}/{len(rows):,} drops (inserted so far: {inserted_total:,})")
|
||||
|
||||
logger.info(f"Zone drops for .{tld}: {inserted_total:,} inserted (out of {len(rows):,} diff)")
|
||||
|
||||
# Return a small preview list (avoid returning huge payloads)
|
||||
preview = [{"domain": f"{r['domain']}.{tld}", "length": r["length"]} for r in rows[:200]]
|
||||
return preview
|
||||
|
||||
async def run_daily_sync(self, db: AsyncSession, tld: str) -> dict:
|
||||
"""
|
||||
@ -313,12 +390,20 @@ async def get_dropped_domains(
|
||||
"total": total,
|
||||
"items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"domain": item.domain,
|
||||
"tld": item.tld,
|
||||
"dropped_date": item.dropped_date.isoformat(),
|
||||
"dropped_date": to_iso_utc(item.dropped_date),
|
||||
"length": item.length,
|
||||
"is_numeric": item.is_numeric,
|
||||
"has_hyphen": item.has_hyphen
|
||||
"has_hyphen": item.has_hyphen,
|
||||
# Canonical status fields (keep old key for backwards compat)
|
||||
"availability_status": getattr(item, "availability_status", "unknown") or "unknown",
|
||||
"status": getattr(item, "availability_status", "unknown") or "unknown",
|
||||
"last_status_check": to_iso_utc(item.last_status_check),
|
||||
"status_checked_at": to_iso_utc(item.last_status_check),
|
||||
"status_source": getattr(item, "last_check_method", None),
|
||||
"deletion_date": to_iso_utc(item.deletion_date),
|
||||
}
|
||||
for item in items
|
||||
]
|
||||
@ -398,3 +483,140 @@ async def cleanup_old_snapshots(db: AsyncSession, keep_days: int = 7) -> int:
|
||||
logger.info(f"Cleaned up {deleted} old zone snapshots (older than {keep_days}d)")
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
async def verify_drops_availability(
|
||||
db: AsyncSession,
|
||||
batch_size: int = 50,
|
||||
max_checks: int = 200
|
||||
) -> dict:
|
||||
"""
|
||||
Verify availability of dropped domains and update their status.
|
||||
|
||||
This runs periodically to check the real RDAP status of drops.
|
||||
Updates availability_status and deletion_date fields.
|
||||
|
||||
Rate limited: ~200ms between requests = ~5 req/sec
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
batch_size: Number of domains to check per batch
|
||||
max_checks: Maximum domains to check per run (to avoid overload)
|
||||
|
||||
Returns:
|
||||
dict with stats: checked, available, dropping_soon, taken, errors
|
||||
"""
|
||||
from sqlalchemy import update, bindparam, case
|
||||
from app.services.drop_status_checker import check_drops_batch
|
||||
from app.config import get_settings
|
||||
|
||||
logger.info(f"Starting drops status update (max {max_checks} checks)...")
|
||||
|
||||
# Get drops that haven't been checked recently (prioritize unchecked and short domains)
|
||||
cutoff = datetime.utcnow() - timedelta(hours=24)
|
||||
check_cutoff = datetime.utcnow() - timedelta(hours=2) # Re-check every 2 hours
|
||||
|
||||
# Prioritization (fast + predictable):
|
||||
# 1) never checked first
|
||||
# 2) then oldest check first
|
||||
# 3) then unknown status
|
||||
# 4) then shortest domains first
|
||||
unknown_first = case((DroppedDomain.availability_status == "unknown", 0), else_=1)
|
||||
never_checked_first = case((DroppedDomain.last_status_check.is_(None), 0), else_=1)
|
||||
|
||||
query = (
|
||||
select(DroppedDomain)
|
||||
.where(DroppedDomain.dropped_date >= cutoff)
|
||||
.where(
|
||||
(DroppedDomain.last_status_check.is_(None)) # Never checked
|
||||
| (DroppedDomain.last_status_check < check_cutoff) # Not checked recently
|
||||
)
|
||||
.order_by(
|
||||
never_checked_first.asc(),
|
||||
DroppedDomain.last_status_check.asc().nullsfirst(),
|
||||
unknown_first.asc(),
|
||||
DroppedDomain.length.asc(),
|
||||
)
|
||||
.limit(max_checks)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
drops = result.scalars().all()
|
||||
|
||||
if not drops:
|
||||
logger.info("No drops need status update")
|
||||
return {"checked": 0, "available": 0, "dropping_soon": 0, "taken": 0, "errors": 0}
|
||||
|
||||
checked = 0
|
||||
stats = {"available": 0, "dropping_soon": 0, "taken": 0, "unknown": 0}
|
||||
errors = 0
|
||||
|
||||
logger.info(f"Checking {len(drops)} dropped domains (batch mode)...")
|
||||
|
||||
settings = get_settings()
|
||||
delay = float(getattr(settings, "domain_check_delay_seconds", 0.3) or 0.3)
|
||||
max_concurrent = int(getattr(settings, "domain_check_max_concurrent", 3) or 3)
|
||||
|
||||
# Build (drop_id, domain) tuples for batch checker
|
||||
domain_tuples: list[tuple[int, str]] = [(d.id, f"{d.domain}.{d.tld}") for d in drops]
|
||||
|
||||
# Process in batches to bound memory + keep DB commits reasonable
|
||||
now = datetime.utcnow()
|
||||
for start in range(0, len(domain_tuples), batch_size):
|
||||
batch = domain_tuples[start : start + batch_size]
|
||||
results = await check_drops_batch(
|
||||
batch,
|
||||
delay_between_requests=delay,
|
||||
max_concurrent=max_concurrent,
|
||||
)
|
||||
|
||||
# Prepare bulk updates
|
||||
updates: list[dict] = []
|
||||
for drop_id, status_result in results:
|
||||
checked += 1
|
||||
stats[status_result.status] = stats.get(status_result.status, 0) + 1
|
||||
|
||||
updates.append(
|
||||
{
|
||||
"id": drop_id,
|
||||
"availability_status": status_result.status,
|
||||
"rdap_status": str(status_result.rdap_status)[:255] if status_result.rdap_status else None,
|
||||
"last_status_check": now,
|
||||
"deletion_date": to_naive_utc(status_result.deletion_date),
|
||||
"last_check_method": status_result.check_method,
|
||||
}
|
||||
)
|
||||
|
||||
# Bulk update using executemany
|
||||
stmt = (
|
||||
update(DroppedDomain)
|
||||
.where(DroppedDomain.id == bindparam("id"))
|
||||
.values(
|
||||
availability_status=bindparam("availability_status"),
|
||||
rdap_status=bindparam("rdap_status"),
|
||||
last_status_check=bindparam("last_status_check"),
|
||||
deletion_date=bindparam("deletion_date"),
|
||||
last_check_method=bindparam("last_check_method"),
|
||||
)
|
||||
)
|
||||
await db.execute(stmt, updates)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Checked {min(start + batch_size, len(domain_tuples))}/{len(domain_tuples)}: {stats}")
|
||||
|
||||
# Final commit
|
||||
# (already committed per batch)
|
||||
|
||||
logger.info(
|
||||
f"Drops status update complete: "
|
||||
f"{checked} checked, {stats['available']} available, "
|
||||
f"{stats['dropping_soon']} dropping_soon, {stats['taken']} taken, {errors} errors"
|
||||
)
|
||||
|
||||
return {
|
||||
"checked": checked,
|
||||
"available": stats['available'],
|
||||
"dropping_soon": stats['dropping_soon'],
|
||||
"taken": stats['taken'],
|
||||
"errors": errors
|
||||
}
|
||||
|
||||
318
backend/app/services/zone_file_parser.py
Normal file
318
backend/app/services/zone_file_parser.py
Normal file
@ -0,0 +1,318 @@
|
||||
"""
|
||||
High-Performance Zone File Parser with Multiprocessing
|
||||
=======================================================
|
||||
Optimized for servers with many CPU cores (e.g., Ryzen 9 with 32 threads).
|
||||
|
||||
Uses:
|
||||
- multiprocessing.Pool for parallel chunk processing
|
||||
- Memory-mapped files for fast I/O
|
||||
- RAM drive (/dev/shm) for temporary files
|
||||
- Batch operations for maximum throughput
|
||||
|
||||
This can parse 150+ million domain records in minutes instead of hours.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import logging
|
||||
import mmap
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParseResult:
|
||||
"""Result from parsing a zone file chunk."""
|
||||
domains: set[str]
|
||||
line_count: int
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def get_optimal_workers() -> int:
|
||||
"""Get optimal number of worker processes based on CPU count."""
|
||||
cpu_count = os.cpu_count() or 4
|
||||
# Use 75% of available cores to leave some for other tasks
|
||||
return max(4, int(cpu_count * 0.75))
|
||||
|
||||
|
||||
def get_ram_drive_path() -> Optional[Path]:
|
||||
"""
|
||||
Get path for temporary zone file processing.
|
||||
|
||||
Priority:
|
||||
1. CZDS_DATA_DIR environment variable (persistent storage)
|
||||
2. /data/czds (Docker volume mount)
|
||||
3. /tmp fallback
|
||||
|
||||
Note: We avoid /dev/shm in Docker as it's typically limited to 64MB.
|
||||
With 1.7TB disk and NVMe, disk-based processing is fast enough.
|
||||
"""
|
||||
from app.config import get_settings
|
||||
|
||||
# Use configured data directory (mounted volume)
|
||||
settings = get_settings()
|
||||
if settings.czds_data_dir:
|
||||
data_path = Path(settings.czds_data_dir) / "tmp"
|
||||
try:
|
||||
data_path.mkdir(parents=True, exist_ok=True)
|
||||
return data_path
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
# Docker volume mount
|
||||
if os.path.exists("/data/czds"):
|
||||
data_path = Path("/data/czds/tmp")
|
||||
try:
|
||||
data_path.mkdir(parents=True, exist_ok=True)
|
||||
return data_path
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
# Fall back to temp directory
|
||||
tmp_path = Path(tempfile.gettempdir()) / "pounce_zones"
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def parse_chunk(args: tuple) -> ParseResult:
|
||||
"""
|
||||
Parse a chunk of zone file content.
|
||||
|
||||
This function runs in a separate process for parallelization.
|
||||
|
||||
Args:
|
||||
args: Tuple of (chunk_content, tld, chunk_id)
|
||||
|
||||
Returns:
|
||||
ParseResult with extracted domains
|
||||
"""
|
||||
chunk_content, tld, chunk_id = args
|
||||
domains = set()
|
||||
line_count = 0
|
||||
tld_suffix = f".{tld}"
|
||||
tld_suffix_len = len(tld_suffix) + 1 # +1 for the dot before TLD
|
||||
|
||||
try:
|
||||
for line in chunk_content.split('\n'):
|
||||
line_count += 1
|
||||
|
||||
# Skip comments and empty lines
|
||||
if not line or line.startswith(';'):
|
||||
continue
|
||||
|
||||
# Fast parsing: split on whitespace and check first column
|
||||
# Zone file format: example.tld. 86400 IN NS ns1.example.com.
|
||||
space_idx = line.find('\t')
|
||||
if space_idx == -1:
|
||||
space_idx = line.find(' ')
|
||||
if space_idx == -1:
|
||||
continue
|
||||
|
||||
name = line[:space_idx].rstrip('.')
|
||||
|
||||
# Must end with our TLD
|
||||
name_lower = name.lower()
|
||||
if not name_lower.endswith(tld_suffix):
|
||||
continue
|
||||
|
||||
# Extract domain name (without TLD)
|
||||
domain_name = name_lower[:-len(tld_suffix)]
|
||||
|
||||
# Skip TLD itself and subdomains
|
||||
if domain_name and '.' not in domain_name:
|
||||
domains.add(domain_name)
|
||||
|
||||
return ParseResult(domains=domains, line_count=line_count)
|
||||
|
||||
except Exception as e:
|
||||
return ParseResult(domains=set(), line_count=line_count, error=str(e))
|
||||
|
||||
|
||||
class HighPerformanceZoneParser:
|
||||
"""
|
||||
High-performance zone file parser using multiprocessing.
|
||||
|
||||
Features:
|
||||
- Parallel chunk processing using all CPU cores
|
||||
- RAM drive utilization for faster I/O
|
||||
- Memory-efficient streaming for huge files
|
||||
- Progress logging for long operations
|
||||
"""
|
||||
|
||||
def __init__(self, use_ram_drive: bool = True, workers: Optional[int] = None):
|
||||
self.use_ram_drive = use_ram_drive
|
||||
self.workers = workers or get_optimal_workers()
|
||||
self.ram_drive_path = get_ram_drive_path() if use_ram_drive else None
|
||||
|
||||
logger.info(
|
||||
f"Zone parser initialized: {self.workers} workers, "
|
||||
f"RAM drive: {self.ram_drive_path or 'disabled'}"
|
||||
)
|
||||
|
||||
def extract_to_ram(self, gz_path: Path) -> Path:
|
||||
"""
|
||||
Extract gzipped zone file to RAM drive for fastest access.
|
||||
|
||||
Args:
|
||||
gz_path: Path to .gz file
|
||||
|
||||
Returns:
|
||||
Path to extracted file (in RAM drive if available)
|
||||
"""
|
||||
# Determine output path
|
||||
if self.ram_drive_path:
|
||||
output_path = self.ram_drive_path / gz_path.stem
|
||||
else:
|
||||
output_path = gz_path.with_suffix('')
|
||||
|
||||
logger.info(f"Extracting {gz_path.name} to {output_path}...")
|
||||
|
||||
# Stream extraction to handle large files
|
||||
with gzip.open(gz_path, 'rb') as f_in:
|
||||
with open(output_path, 'wb') as f_out:
|
||||
shutil.copyfileobj(f_in, f_out, length=64 * 1024 * 1024) # 64MB buffer
|
||||
|
||||
file_size_mb = output_path.stat().st_size / (1024 * 1024)
|
||||
logger.info(f"Extracted: {file_size_mb:.1f} MB")
|
||||
|
||||
return output_path
|
||||
|
||||
def split_file_into_chunks(self, file_path: Path, num_chunks: int) -> list[tuple[int, int]]:
|
||||
"""
|
||||
Calculate byte offsets to split file into roughly equal chunks.
|
||||
|
||||
Returns list of (start_offset, end_offset) tuples.
|
||||
"""
|
||||
file_size = file_path.stat().st_size
|
||||
chunk_size = file_size // num_chunks
|
||||
|
||||
offsets = []
|
||||
start = 0
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
for i in range(num_chunks):
|
||||
if i == num_chunks - 1:
|
||||
# Last chunk goes to end
|
||||
offsets.append((start, file_size))
|
||||
else:
|
||||
# Seek to approximate chunk boundary
|
||||
end = start + chunk_size
|
||||
f.seek(end)
|
||||
|
||||
# Find next newline to avoid cutting lines
|
||||
f.readline()
|
||||
end = f.tell()
|
||||
|
||||
offsets.append((start, end))
|
||||
start = end
|
||||
|
||||
return offsets
|
||||
|
||||
def read_chunk(self, file_path: Path, start: int, end: int) -> str:
|
||||
"""Read a chunk of file between byte offsets."""
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
f.seek(start)
|
||||
return f.read(end - start)
|
||||
|
||||
def parse_zone_file_parallel(self, zone_path: Path, tld: str) -> set[str]:
|
||||
"""
|
||||
Parse zone file using parallel processing.
|
||||
|
||||
Args:
|
||||
zone_path: Path to extracted zone file
|
||||
tld: TLD being parsed
|
||||
|
||||
Returns:
|
||||
Set of domain names (without TLD)
|
||||
"""
|
||||
file_size_mb = zone_path.stat().st_size / (1024 * 1024)
|
||||
logger.info(f"Parsing .{tld} zone file ({file_size_mb:.1f} MB) with {self.workers} workers...")
|
||||
|
||||
# Split file into chunks
|
||||
chunk_offsets = self.split_file_into_chunks(zone_path, self.workers)
|
||||
|
||||
# Read chunks and prepare for parallel processing
|
||||
chunks = []
|
||||
for i, (start, end) in enumerate(chunk_offsets):
|
||||
chunk_content = self.read_chunk(zone_path, start, end)
|
||||
chunks.append((chunk_content, tld, i))
|
||||
|
||||
# Process chunks in parallel
|
||||
all_domains = set()
|
||||
total_lines = 0
|
||||
|
||||
with ProcessPoolExecutor(max_workers=self.workers) as executor:
|
||||
futures = [executor.submit(parse_chunk, chunk) for chunk in chunks]
|
||||
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
all_domains.update(result.domains)
|
||||
total_lines += result.line_count
|
||||
|
||||
if result.error:
|
||||
logger.warning(f"Chunk error: {result.error}")
|
||||
|
||||
logger.info(
|
||||
f"Parsed .{tld}: {len(all_domains):,} unique domains "
|
||||
f"from {total_lines:,} lines using {self.workers} workers"
|
||||
)
|
||||
|
||||
return all_domains
|
||||
|
||||
def cleanup_ram_drive(self):
|
||||
"""Clean up temporary files from RAM drive."""
|
||||
if self.ram_drive_path and self.ram_drive_path.exists():
|
||||
for file in self.ram_drive_path.glob("*"):
|
||||
try:
|
||||
file.unlink()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete {file}: {e}")
|
||||
|
||||
|
||||
def compute_checksum(domains: set[str]) -> str:
|
||||
"""Compute SHA256 checksum of sorted domain list."""
|
||||
sorted_domains = "\n".join(sorted(domains))
|
||||
return hashlib.sha256(sorted_domains.encode()).hexdigest()
|
||||
|
||||
|
||||
def parse_zone_file_fast(
|
||||
zone_path: Path,
|
||||
tld: str,
|
||||
use_ram_drive: bool = True,
|
||||
workers: Optional[int] = None
|
||||
) -> set[str]:
|
||||
"""
|
||||
Convenience function to parse a zone file with optimal settings.
|
||||
|
||||
Args:
|
||||
zone_path: Path to zone file (can be .gz)
|
||||
tld: TLD being parsed
|
||||
use_ram_drive: Whether to use RAM drive for extraction
|
||||
workers: Number of worker processes (auto-detected if None)
|
||||
|
||||
Returns:
|
||||
Set of domain names
|
||||
"""
|
||||
parser = HighPerformanceZoneParser(use_ram_drive=use_ram_drive, workers=workers)
|
||||
|
||||
try:
|
||||
# Extract if gzipped
|
||||
if str(zone_path).endswith('.gz'):
|
||||
extracted_path = parser.extract_to_ram(zone_path)
|
||||
result = parser.parse_zone_file_parallel(extracted_path, tld)
|
||||
# Clean up extracted file
|
||||
extracted_path.unlink()
|
||||
else:
|
||||
result = parser.parse_zone_file_parallel(zone_path, tld)
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
parser.cleanup_ram_drive()
|
||||
230
backend/app/services/zone_retention.py
Normal file
230
backend/app/services/zone_retention.py
Normal file
@ -0,0 +1,230 @@
|
||||
"""
|
||||
Zone File Retention Management
|
||||
==============================
|
||||
Manages historical zone file snapshots with configurable retention period.
|
||||
Default: 3 days of history for reliable drop detection.
|
||||
|
||||
Features:
|
||||
- Daily snapshots with timestamps
|
||||
- Automatic cleanup of old snapshots
|
||||
- Reliable diff calculation across multiple days
|
||||
"""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class ZoneRetentionManager:
|
||||
"""
|
||||
Manages zone file snapshots with retention policy.
|
||||
|
||||
Directory structure:
|
||||
/data/czds/
|
||||
xyz_domains.txt <- current/latest
|
||||
xyz_domains_2024-01-15.txt <- daily snapshot
|
||||
xyz_domains_2024-01-14.txt
|
||||
xyz_domains_2024-01-13.txt
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: Optional[Path] = None, retention_days: int = 3):
|
||||
self.data_dir = data_dir or Path(settings.czds_data_dir)
|
||||
self.retention_days = retention_days or settings.zone_retention_days
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_snapshot_path(self, tld: str, date: datetime) -> Path:
|
||||
"""Get path for a dated snapshot."""
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
return self.data_dir / f"{tld}_domains_{date_str}.txt"
|
||||
|
||||
def get_current_path(self, tld: str) -> Path:
|
||||
"""Get path for current (latest) snapshot."""
|
||||
return self.data_dir / f"{tld}_domains.txt"
|
||||
|
||||
def save_snapshot(self, tld: str, domains: set[str], date: Optional[datetime] = None):
|
||||
"""
|
||||
Save a domain snapshot with date suffix and update current.
|
||||
|
||||
Args:
|
||||
tld: The TLD (e.g., 'xyz', 'ch')
|
||||
domains: Set of domain names
|
||||
date: Optional date for snapshot (defaults to today)
|
||||
"""
|
||||
date = date or datetime.utcnow()
|
||||
|
||||
# Save dated snapshot
|
||||
snapshot_path = self.get_snapshot_path(tld, date)
|
||||
content = "\n".join(sorted(domains))
|
||||
snapshot_path.write_text(content)
|
||||
|
||||
# Also update current pointer
|
||||
current_path = self.get_current_path(tld)
|
||||
current_path.write_text(content)
|
||||
|
||||
logger.info(f"Saved .{tld} snapshot: {len(domains):,} domains -> {snapshot_path.name}")
|
||||
|
||||
def load_snapshot(self, tld: str, date: Optional[datetime] = None) -> Optional[set[str]]:
|
||||
"""
|
||||
Load a snapshot from a specific date.
|
||||
|
||||
Args:
|
||||
tld: The TLD
|
||||
date: Date to load (None = current/latest)
|
||||
|
||||
Returns:
|
||||
Set of domain names or None if not found
|
||||
"""
|
||||
if date:
|
||||
path = self.get_snapshot_path(tld, date)
|
||||
else:
|
||||
path = self.get_current_path(tld)
|
||||
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
content = path.read_text()
|
||||
return set(line.strip() for line in content.splitlines() if line.strip())
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load snapshot {path.name}: {e}")
|
||||
return None
|
||||
|
||||
def get_previous_snapshot(self, tld: str, days_ago: int = 1) -> Optional[set[str]]:
|
||||
"""
|
||||
Load snapshot from N days ago.
|
||||
|
||||
Args:
|
||||
tld: The TLD
|
||||
days_ago: How many days back to look
|
||||
|
||||
Returns:
|
||||
Set of domain names or None
|
||||
"""
|
||||
target_date = datetime.utcnow() - timedelta(days=days_ago)
|
||||
return self.load_snapshot(tld, target_date)
|
||||
|
||||
def cleanup_old_snapshots(self, tld: Optional[str] = None) -> int:
|
||||
"""
|
||||
Remove snapshots older than retention period.
|
||||
|
||||
Args:
|
||||
tld: Optional TLD to clean (None = all TLDs)
|
||||
|
||||
Returns:
|
||||
Number of files deleted
|
||||
"""
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=self.retention_days)
|
||||
deleted = 0
|
||||
|
||||
# Pattern: *_domains_YYYY-MM-DD.txt
|
||||
pattern = f"{tld}_domains_*.txt" if tld else "*_domains_*.txt"
|
||||
|
||||
for file_path in self.data_dir.glob(pattern):
|
||||
# Skip current files (no date suffix)
|
||||
name = file_path.stem
|
||||
if not any(c.isdigit() for c in name):
|
||||
continue
|
||||
|
||||
# Extract date from filename
|
||||
try:
|
||||
# Get the date part (last 10 chars: YYYY-MM-DD)
|
||||
date_str = name[-10:]
|
||||
file_date = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
|
||||
if file_date < cutoff_date:
|
||||
file_path.unlink()
|
||||
deleted += 1
|
||||
logger.info(f"Deleted old snapshot: {file_path.name}")
|
||||
except (ValueError, IndexError):
|
||||
# Not a dated snapshot, skip
|
||||
continue
|
||||
|
||||
if deleted > 0:
|
||||
logger.info(f"Cleaned up {deleted} old zone file snapshots")
|
||||
|
||||
return deleted
|
||||
|
||||
def get_available_snapshots(self, tld: str) -> list[datetime]:
|
||||
"""
|
||||
List all available snapshot dates for a TLD.
|
||||
|
||||
Args:
|
||||
tld: The TLD
|
||||
|
||||
Returns:
|
||||
List of dates (sorted, newest first)
|
||||
"""
|
||||
dates = []
|
||||
pattern = f"{tld}_domains_*.txt"
|
||||
|
||||
for file_path in self.data_dir.glob(pattern):
|
||||
name = file_path.stem
|
||||
try:
|
||||
date_str = name[-10:]
|
||||
file_date = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
dates.append(file_date)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
return sorted(dates, reverse=True)
|
||||
|
||||
def get_storage_stats(self) -> dict:
|
||||
"""Get storage statistics for zone files."""
|
||||
stats = {
|
||||
"total_files": 0,
|
||||
"total_size_mb": 0.0,
|
||||
"tlds": {},
|
||||
}
|
||||
|
||||
for file_path in self.data_dir.glob("*_domains*.txt"):
|
||||
stats["total_files"] += 1
|
||||
size_mb = file_path.stat().st_size / (1024 * 1024)
|
||||
stats["total_size_mb"] += size_mb
|
||||
|
||||
# Extract TLD
|
||||
name = file_path.stem
|
||||
tld = name.split("_")[0]
|
||||
if tld not in stats["tlds"]:
|
||||
stats["tlds"][tld] = {"files": 0, "size_mb": 0.0}
|
||||
stats["tlds"][tld]["files"] += 1
|
||||
stats["tlds"][tld]["size_mb"] += size_mb
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def migrate_existing_snapshots():
|
||||
"""
|
||||
Migrate existing zone files to dated snapshot format.
|
||||
Call this once during deployment.
|
||||
"""
|
||||
manager = ZoneRetentionManager()
|
||||
today = datetime.utcnow()
|
||||
migrated = 0
|
||||
|
||||
for data_dir in [Path(settings.czds_data_dir), Path(settings.switch_data_dir)]:
|
||||
if not data_dir.exists():
|
||||
continue
|
||||
|
||||
for file_path in data_dir.glob("*_domains.txt"):
|
||||
name = file_path.stem
|
||||
# Skip if already has date
|
||||
if any(c.isdigit() for c in name[-10:]):
|
||||
continue
|
||||
|
||||
tld = name.replace("_domains", "")
|
||||
|
||||
# Create dated copy
|
||||
dated_path = data_dir / f"{tld}_domains_{today.strftime('%Y-%m-%d')}.txt"
|
||||
if not dated_path.exists():
|
||||
shutil.copy(file_path, dated_path)
|
||||
migrated += 1
|
||||
logger.info(f"Migrated {file_path.name} -> {dated_path.name}")
|
||||
|
||||
return migrated
|
||||
2
backend/app/utils/__init__.py
Normal file
2
backend/app/utils/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Shared utility helpers (small, dependency-free)."""
|
||||
|
||||
34
backend/app/utils/datetime.py
Normal file
34
backend/app/utils/datetime.py
Normal file
@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def to_naive_utc(dt: datetime | None) -> datetime | None:
|
||||
"""
|
||||
Convert a timezone-aware datetime to naive UTC (tzinfo removed).
|
||||
|
||||
Our DB columns are DateTime without timezone. Persisting timezone-aware
|
||||
datetimes can cause runtime errors (especially on Postgres).
|
||||
"""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt
|
||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def to_iso_utc(dt: datetime | None) -> str | None:
|
||||
"""
|
||||
Serialize a datetime as an ISO-8601 UTC string.
|
||||
|
||||
- If dt is timezone-aware: convert to UTC and use "Z".
|
||||
- If dt is naive: treat it as UTC and use "Z".
|
||||
"""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
dt = dt.astimezone(timezone.utc)
|
||||
return dt.isoformat().replace("+00:00", "Z")
|
||||
|
||||
@ -18,6 +18,7 @@ load_dotenv()
|
||||
from app.config import get_settings
|
||||
from app.database import init_db
|
||||
from app.scheduler import start_scheduler, stop_scheduler
|
||||
from app.services.http_client_pool import close_rdap_http_client
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@ -54,6 +55,7 @@ async def main() -> None:
|
||||
await stop_event.wait()
|
||||
|
||||
stop_scheduler()
|
||||
await close_rdap_http_client()
|
||||
logger.info("Scheduler stopped. Bye.")
|
||||
|
||||
|
||||
|
||||
181
backend/scripts/setup_dns_server.sh
Executable file
181
backend/scripts/setup_dns_server.sh
Executable file
@ -0,0 +1,181 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Pounce DNS Server Setup (CoreDNS)
|
||||
# ============================================================================
|
||||
# This script sets up CoreDNS as an authoritative DNS server for Yield domains.
|
||||
# Users point their domains' NS records to ns1.pounce.ch and ns2.pounce.ch,
|
||||
# which both resolve to this server's IP.
|
||||
#
|
||||
# Usage: sudo bash setup_dns_server.sh
|
||||
# ============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "Pounce DNS Server Setup (CoreDNS)"
|
||||
echo "=========================================="
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "ERROR: Please run as root (sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER_IP="46.235.147.194"
|
||||
COREDNS_VERSION="1.11.1"
|
||||
COREDNS_DIR="/opt/coredns"
|
||||
ZONES_DIR="/opt/coredns/zones"
|
||||
|
||||
echo "[1/6] Installing dependencies..."
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq wget curl jq
|
||||
|
||||
echo "[2/6] Downloading CoreDNS ${COREDNS_VERSION}..."
|
||||
mkdir -p "$COREDNS_DIR"
|
||||
cd "$COREDNS_DIR"
|
||||
|
||||
if [ ! -f "coredns" ]; then
|
||||
wget -q "https://github.com/coredns/coredns/releases/download/v${COREDNS_VERSION}/coredns_${COREDNS_VERSION}_linux_amd64.tgz"
|
||||
tar -xzf "coredns_${COREDNS_VERSION}_linux_amd64.tgz"
|
||||
rm "coredns_${COREDNS_VERSION}_linux_amd64.tgz"
|
||||
chmod +x coredns
|
||||
fi
|
||||
|
||||
echo "[3/6] Creating zone directory..."
|
||||
mkdir -p "$ZONES_DIR"
|
||||
|
||||
echo "[4/6] Creating CoreDNS config (Corefile)..."
|
||||
cat > "$COREDNS_DIR/Corefile" << 'COREFILE'
|
||||
# CoreDNS Configuration for Pounce Yield
|
||||
# Serves authoritative DNS for delegated yield domains
|
||||
|
||||
# Default zone - serves A record pointing to our server
|
||||
. {
|
||||
# Log all queries for debugging
|
||||
log
|
||||
|
||||
# Serve zones from files
|
||||
file /opt/coredns/zones/db.yield {
|
||||
reload 30s
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
health :8053
|
||||
|
||||
# Prometheus metrics
|
||||
prometheus :9153
|
||||
|
||||
# Forward unknown queries (shouldn't happen for authoritative)
|
||||
forward . 8.8.8.8 8.8.4.4 {
|
||||
max_concurrent 1000
|
||||
}
|
||||
|
||||
# Cache responses
|
||||
cache 300
|
||||
|
||||
# Error handling
|
||||
errors
|
||||
}
|
||||
COREFILE
|
||||
|
||||
echo "[5/6] Creating initial zone file..."
|
||||
cat > "$ZONES_DIR/db.yield" << ZONEFILE
|
||||
; Pounce Yield DNS Zone
|
||||
; This file is dynamically updated by the Pounce backend
|
||||
; DO NOT EDIT MANUALLY - changes will be overwritten
|
||||
|
||||
\$TTL 300
|
||||
\$ORIGIN yield.pounce.ch.
|
||||
|
||||
@ IN SOA ns1.pounce.ch. admin.pounce.ch. (
|
||||
$(date +%Y%m%d)01 ; Serial (YYYYMMDDNN)
|
||||
3600 ; Refresh (1 hour)
|
||||
600 ; Retry (10 minutes)
|
||||
604800 ; Expire (1 week)
|
||||
300 ; Minimum TTL (5 minutes)
|
||||
)
|
||||
|
||||
; Nameservers
|
||||
@ IN NS ns1.pounce.ch.
|
||||
@ IN NS ns2.pounce.ch.
|
||||
|
||||
; A record for the zone apex
|
||||
@ IN A ${SERVER_IP}
|
||||
|
||||
; Wildcard - all subdomains point to our server
|
||||
* IN A ${SERVER_IP}
|
||||
|
||||
; ============================================
|
||||
; YIELD DOMAINS
|
||||
; Add domains below in format:
|
||||
; domainname IN A ${SERVER_IP}
|
||||
; ============================================
|
||||
|
||||
; Example (uncomment to test):
|
||||
; akaya.ch. IN A ${SERVER_IP}
|
||||
|
||||
ZONEFILE
|
||||
|
||||
echo "[6/6] Creating systemd service..."
|
||||
cat > /etc/systemd/system/coredns.service << 'SERVICE'
|
||||
[Unit]
|
||||
Description=CoreDNS DNS Server
|
||||
Documentation=https://coredns.io
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/coredns
|
||||
ExecStart=/opt/coredns/coredns -conf /opt/coredns/Corefile
|
||||
ExecReload=/bin/kill -SIGUSR1 $MAINPID
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
LimitNOFILE=1048576
|
||||
LimitNPROC=512
|
||||
|
||||
# Security
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SERVICE
|
||||
|
||||
echo "[7/6] Opening firewall port 53..."
|
||||
if command -v ufw &> /dev/null; then
|
||||
ufw allow 53/tcp
|
||||
ufw allow 53/udp
|
||||
echo "UFW: Port 53 opened"
|
||||
elif command -v firewall-cmd &> /dev/null; then
|
||||
firewall-cmd --permanent --add-port=53/tcp
|
||||
firewall-cmd --permanent --add-port=53/udp
|
||||
firewall-cmd --reload
|
||||
echo "firewalld: Port 53 opened"
|
||||
else
|
||||
echo "WARNING: No firewall detected. Make sure port 53 is open!"
|
||||
fi
|
||||
|
||||
echo "[8/6] Starting CoreDNS..."
|
||||
systemctl daemon-reload
|
||||
systemctl enable coredns
|
||||
systemctl start coredns
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "✅ CoreDNS installed and running!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Status: $(systemctl is-active coredns)"
|
||||
echo "Config: $COREDNS_DIR/Corefile"
|
||||
echo "Zones: $ZONES_DIR/db.yield"
|
||||
echo ""
|
||||
echo "To add a yield domain, append to $ZONES_DIR/db.yield:"
|
||||
echo " akaya.ch. IN A $SERVER_IP"
|
||||
echo ""
|
||||
echo "Then reload: systemctl reload coredns"
|
||||
echo ""
|
||||
echo "Test with: dig @localhost akaya.ch"
|
||||
echo "=========================================="
|
||||
|
||||
98
backend/scripts/setup_yield_nginx.sh
Normal file
98
backend/scripts/setup_yield_nginx.sh
Normal file
@ -0,0 +1,98 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Pounce Yield HTTP Routing Setup
|
||||
# ============================================================================
|
||||
# This sets up Nginx to catch-all domains pointing to our server
|
||||
# and route them to the Pounce backend for Yield landing pages.
|
||||
#
|
||||
# Instead of Nameserver delegation (which requires Port 53),
|
||||
# users simply set an A-record pointing to our IP.
|
||||
#
|
||||
# Usage: sudo bash setup_yield_nginx.sh
|
||||
# ============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "Pounce Yield HTTP Routing Setup"
|
||||
echo "=========================================="
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "ERROR: Please run as root (sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NGINX_CONF="/etc/nginx/sites-available/yield-catchall"
|
||||
SERVER_IP="46.235.147.194"
|
||||
|
||||
echo "[1/3] Creating Nginx catch-all config for Yield..."
|
||||
|
||||
cat > "$NGINX_CONF" << 'NGINX'
|
||||
# Pounce Yield Catch-All Server
|
||||
# This catches all domains pointing to our server that aren't pounce.ch
|
||||
# and routes them to the Yield routing backend.
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
|
||||
# Catch all hostnames except pounce.ch
|
||||
server_name _;
|
||||
|
||||
# Skip if it's pounce.ch or www.pounce.ch
|
||||
if ($host ~* ^(www\.)?pounce\.ch$) {
|
||||
return 444; # Close connection, let the main server block handle it
|
||||
}
|
||||
|
||||
# Route all traffic to backend yield routing
|
||||
location / {
|
||||
# Rewrite to /api/v1/r/{hostname}
|
||||
set $yield_domain $host;
|
||||
|
||||
proxy_pass http://127.0.0.1:8000/api/v1/r/$yield_domain;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Yield-Domain $host;
|
||||
|
||||
# Handle errors gracefully
|
||||
proxy_intercept_errors on;
|
||||
error_page 404 502 503 504 = @yield_fallback;
|
||||
}
|
||||
|
||||
# Fallback for domains not configured in Yield
|
||||
location @yield_fallback {
|
||||
return 302 https://pounce.ch/yield?domain=$host;
|
||||
}
|
||||
}
|
||||
NGINX
|
||||
|
||||
echo "[2/3] Enabling site and testing config..."
|
||||
|
||||
# Enable the site if not already
|
||||
if [ ! -f "/etc/nginx/sites-enabled/yield-catchall" ]; then
|
||||
ln -sf "$NGINX_CONF" /etc/nginx/sites-enabled/yield-catchall
|
||||
fi
|
||||
|
||||
# Test nginx config
|
||||
nginx -t
|
||||
|
||||
echo "[3/3] Reloading Nginx..."
|
||||
systemctl reload nginx
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "✅ Yield HTTP Routing configured!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "How it works:"
|
||||
echo "1. User sets A-record for their domain to: $SERVER_IP"
|
||||
echo "2. When someone visits the domain, Nginx catches it"
|
||||
echo "3. Traffic is routed to /api/v1/r/{domain}"
|
||||
echo "4. Backend serves the Yield landing page"
|
||||
echo ""
|
||||
echo "No DNS server (Port 53) required!"
|
||||
echo "=========================================="
|
||||
@ -44,7 +44,9 @@ LOG_FILE = Path("/home/user/logs/zone_sync.log")
|
||||
COMPRESS_DOMAIN_LISTS = True
|
||||
|
||||
# CZDS TLDs we have access to
|
||||
CZDS_TLDS = ["app", "dev", "info", "online", "org", "xyz"]
|
||||
# Note: .org is HUGE (~10M domains, 442MB gz) - requires special handling
|
||||
CZDS_TLDS = ["app", "biz", "club", "dev", "info", "online", "xyz"] # org temporarily excluded due to memory
|
||||
CZDS_TLDS_LARGE = ["org"] # Process separately with streaming
|
||||
|
||||
# Switch.ch AXFR config
|
||||
SWITCH_CONFIG = {
|
||||
@ -56,7 +58,7 @@ SWITCH_CONFIG = {
|
||||
"li": {
|
||||
"server": "zonedata.switch.ch",
|
||||
"key_name": "tsig-zonedata-li-public-21-01.",
|
||||
"key_secret": "t8GgeCn+fhPaj+cRy/lakQPb6M45xz/NZwmcp4iqbBxKFCCH0/k3xNGe6sf3ObmoaKDBedge/La4cpPfLqtFkw=="
|
||||
"key_secret": "t8GgeCn+fhPaj+cRy1epox2Vj4hZ45ax6v3rQCkkfIQNg5fsxuU23QM5mzz+BxJ4kgF/jiQyBDBvL+XWPE6oCQ=="
|
||||
}
|
||||
}
|
||||
|
||||
@ -85,67 +87,82 @@ class ZoneSyncResult:
|
||||
|
||||
async def get_db_session():
|
||||
"""Create async database session"""
|
||||
from app.config import settings
|
||||
from app.config import get_settings
|
||||
|
||||
engine = create_async_engine(settings.database_url.replace("sqlite://", "sqlite+aiosqlite://"))
|
||||
db_url = get_settings().database_url
|
||||
if "aiosqlite" not in db_url:
|
||||
db_url = db_url.replace("sqlite://", "sqlite+aiosqlite://")
|
||||
engine = create_async_engine(db_url)
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
return async_session()
|
||||
|
||||
|
||||
def download_czds_zone(tld: str) -> Optional[Path]:
|
||||
"""Download a single CZDS zone file using pyCZDS"""
|
||||
try:
|
||||
from pyczds.client import CZDSClient
|
||||
|
||||
# Read credentials from .env
|
||||
env_file = Path(__file__).parent.parent / ".env"
|
||||
if not env_file.exists():
|
||||
env_file = Path("/home/user/pounce/backend/.env")
|
||||
|
||||
env_content = env_file.read_text()
|
||||
username = password = None
|
||||
|
||||
for line in env_content.splitlines():
|
||||
if line.startswith("CZDS_USERNAME="):
|
||||
username = line.split("=", 1)[1].strip()
|
||||
elif line.startswith("CZDS_PASSWORD="):
|
||||
password = line.split("=", 1)[1].strip()
|
||||
|
||||
if not username or not password:
|
||||
logger.error(f"CZDS credentials not found in .env")
|
||||
return None
|
||||
|
||||
client = CZDSClient(username, password)
|
||||
urls = client.get_zonefiles_list()
|
||||
|
||||
# Find URL for this TLD
|
||||
target_url = None
|
||||
for url in urls:
|
||||
if f"{tld}.zone" in url or f"/{tld}." in url:
|
||||
target_url = url
|
||||
break
|
||||
|
||||
if not target_url:
|
||||
logger.warning(f"No access to .{tld} zone file")
|
||||
return None
|
||||
|
||||
logger.info(f"Downloading .{tld} from CZDS...")
|
||||
result = client.get_zonefile(target_url, download_dir=str(CZDS_DIR))
|
||||
|
||||
# Find the downloaded file
|
||||
gz_file = CZDS_DIR / f"{tld}.txt.gz"
|
||||
if gz_file.exists():
|
||||
return gz_file
|
||||
|
||||
# Try alternative naming
|
||||
for f in CZDS_DIR.glob(f"*{tld}*.gz"):
|
||||
return f
|
||||
def download_czds_zone(tld: str, max_retries: int = 3) -> Optional[Path]:
|
||||
"""Download a single CZDS zone file using pyCZDS with retry logic"""
|
||||
import time
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
from pyczds.client import CZDSClient
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CZDS download failed for .{tld}: {e}")
|
||||
return None
|
||||
# Read credentials from .env
|
||||
env_file = Path(__file__).parent.parent / ".env"
|
||||
if not env_file.exists():
|
||||
env_file = Path("/home/user/pounce/backend/.env")
|
||||
|
||||
env_content = env_file.read_text()
|
||||
username = password = None
|
||||
|
||||
for line in env_content.splitlines():
|
||||
if line.startswith("CZDS_USERNAME="):
|
||||
username = line.split("=", 1)[1].strip()
|
||||
elif line.startswith("CZDS_PASSWORD="):
|
||||
password = line.split("=", 1)[1].strip()
|
||||
|
||||
if not username or not password:
|
||||
logger.error(f"CZDS credentials not found in .env")
|
||||
return None
|
||||
|
||||
client = CZDSClient(username, password)
|
||||
urls = client.get_zonefiles_list()
|
||||
|
||||
# Find URL for this TLD
|
||||
target_url = None
|
||||
for url in urls:
|
||||
if f"{tld}.zone" in url or f"/{tld}." in url:
|
||||
target_url = url
|
||||
break
|
||||
|
||||
if not target_url:
|
||||
logger.warning(f"No access to .{tld} zone file")
|
||||
return None
|
||||
|
||||
logger.info(f"Downloading .{tld} from CZDS... (attempt {attempt + 1}/{max_retries})")
|
||||
result = client.get_zonefile(target_url, download_dir=str(CZDS_DIR))
|
||||
|
||||
# Find the downloaded file
|
||||
gz_file = CZDS_DIR / f"{tld}.txt.gz"
|
||||
if gz_file.exists():
|
||||
return gz_file
|
||||
|
||||
# Try alternative naming (pyCZDS sometimes uses different names)
|
||||
for f in CZDS_DIR.glob(f"*{tld}*.gz"):
|
||||
return f
|
||||
|
||||
# File not found after download - raise exception to trigger retry
|
||||
raise FileNotFoundError(f"Downloaded file not found for .{tld} in {CZDS_DIR}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"CZDS download attempt {attempt + 1} failed for .{tld}: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = (attempt + 1) * 30 # 30s, 60s, 90s backoff
|
||||
logger.info(f"Retrying in {wait_time}s...")
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
logger.error(f"CZDS download failed for .{tld} after {max_retries} attempts")
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def download_switch_zone(tld: str) -> Optional[Path]:
|
||||
@ -509,16 +526,34 @@ async def main():
|
||||
logger.info("\n📊 Initial storage check...")
|
||||
initial_storage = log_storage_stats()
|
||||
|
||||
all_drops = []
|
||||
results = []
|
||||
total_drops_stored = 0
|
||||
|
||||
# Helper to store drops immediately after each TLD
|
||||
async def store_tld_drops(drops: list, tld: str):
|
||||
nonlocal total_drops_stored
|
||||
if not drops:
|
||||
return 0
|
||||
try:
|
||||
session = await get_db_session()
|
||||
stored = await store_drops_in_db(drops, session)
|
||||
await session.close()
|
||||
total_drops_stored += stored
|
||||
logger.info(f" 💾 Stored {stored} .{tld} drops in database")
|
||||
return stored
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Failed to store .{tld} drops: {e}")
|
||||
return 0
|
||||
|
||||
# Sync CZDS TLDs (sequentially to respect rate limits)
|
||||
logger.info("\n📦 Syncing ICANN CZDS zone files...")
|
||||
for tld in CZDS_TLDS:
|
||||
result = await sync_czds_tld(tld)
|
||||
results.append(result)
|
||||
if hasattr(result, 'drops'):
|
||||
all_drops.extend(result.drops)
|
||||
|
||||
# Store drops IMMEDIATELY after each TLD (crash-safe)
|
||||
if hasattr(result, 'drops') and result.drops:
|
||||
await store_tld_drops(result.drops, tld)
|
||||
|
||||
# Rate limit: wait between downloads
|
||||
if tld != CZDS_TLDS[-1]:
|
||||
@ -530,19 +565,10 @@ async def main():
|
||||
for tld in ["ch", "li"]:
|
||||
result = await sync_switch_tld(tld)
|
||||
results.append(result)
|
||||
if hasattr(result, 'drops'):
|
||||
all_drops.extend(result.drops)
|
||||
|
||||
# Store drops in database
|
||||
if all_drops:
|
||||
logger.info(f"\n💾 Storing {len(all_drops)} drops in database...")
|
||||
try:
|
||||
session = await get_db_session()
|
||||
stored = await store_drops_in_db(all_drops, session)
|
||||
await session.close()
|
||||
logger.info(f"✅ Stored {stored} drops in database")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to store drops: {e}")
|
||||
|
||||
# Store drops IMMEDIATELY
|
||||
if hasattr(result, 'drops') and result.drops:
|
||||
await store_tld_drops(result.drops, tld)
|
||||
|
||||
# Cleanup stray files
|
||||
logger.info("\n🧹 Cleaning up temporary files...")
|
||||
@ -575,7 +601,7 @@ async def main():
|
||||
|
||||
logger.info("-" * 60)
|
||||
logger.info(f" TOTAL: {total_domains:,} domains across {success_count}/{len(results)} TLDs")
|
||||
logger.info(f" DROPS: {total_drops:,} new drops detected")
|
||||
logger.info(f" DROPS: {total_drops:,} detected, {total_drops_stored:,} stored in DB")
|
||||
logger.info(f" TIME: {duration:.1f} seconds")
|
||||
|
||||
# Final storage stats
|
||||
@ -591,4 +617,4 @@ async def main():
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
sys.exit(exit_code)
|
||||
97
deploy-http.sh
Executable file
97
deploy-http.sh
Executable file
@ -0,0 +1,97 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# POUNCE HTTP DEPLOY (Backup method when SSH is unavailable)
|
||||
#
|
||||
# This uses the /api/v1/deploy endpoint to trigger deployments remotely.
|
||||
# Requires the internal API key to be configured in the backend.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Configuration
|
||||
API_URL="https://pounce.ch/api/v1"
|
||||
DEPLOY_KEY="${POUNCE_DEPLOY_KEY:-}"
|
||||
|
||||
# Check if deploy key is set
|
||||
if [ -z "$DEPLOY_KEY" ]; then
|
||||
echo -e "${RED}Error: POUNCE_DEPLOY_KEY environment variable not set${NC}"
|
||||
echo ""
|
||||
echo "Set your deploy key:"
|
||||
echo " export POUNCE_DEPLOY_KEY=your-internal-api-key"
|
||||
echo ""
|
||||
echo "The key should match the 'internal_api_key' in your backend .env file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
show_help() {
|
||||
echo -e "${CYAN}Pounce HTTP Deploy${NC}"
|
||||
echo ""
|
||||
echo "Usage: ./deploy-http.sh [component]"
|
||||
echo ""
|
||||
echo "Components:"
|
||||
echo " all Deploy both backend and frontend (default)"
|
||||
echo " backend Deploy backend only"
|
||||
echo " frontend Deploy frontend only"
|
||||
echo " status Check last deployment status"
|
||||
echo ""
|
||||
}
|
||||
|
||||
trigger_deploy() {
|
||||
local component="${1:-all}"
|
||||
|
||||
echo -e "${CYAN}Triggering deployment for: $component${NC}"
|
||||
|
||||
response=$(curl -s -X POST "$API_URL/deploy/trigger" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Deploy-Key: $DEPLOY_KEY" \
|
||||
-d "{\"component\": \"$component\", \"git_pull\": true}")
|
||||
|
||||
if echo "$response" | grep -q '"status":"started"'; then
|
||||
echo -e "${GREEN}✓ Deployment started${NC}"
|
||||
echo "$response" | python3 -m json.tool 2>/dev/null || echo "$response"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Deployment running in background...${NC}"
|
||||
echo "Check status with: ./deploy-http.sh status"
|
||||
else
|
||||
echo -e "${RED}✗ Failed to trigger deployment${NC}"
|
||||
echo "$response"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_status() {
|
||||
echo -e "${CYAN}Checking deployment status...${NC}"
|
||||
|
||||
response=$(curl -s "$API_URL/deploy/status" \
|
||||
-H "X-Deploy-Key: $DEPLOY_KEY")
|
||||
|
||||
if echo "$response" | grep -q '"status"'; then
|
||||
echo "$response" | python3 -m json.tool 2>/dev/null || echo "$response"
|
||||
else
|
||||
echo -e "${RED}✗ Failed to get status${NC}"
|
||||
echo "$response"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Main
|
||||
case "${1:-all}" in
|
||||
help|-h|--help)
|
||||
show_help
|
||||
;;
|
||||
status)
|
||||
check_status
|
||||
;;
|
||||
*)
|
||||
trigger_deploy "$1"
|
||||
;;
|
||||
esac
|
||||
804
deploy.sh
804
deploy.sh
@ -1,14 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# POUNCE ZERO-DOWNTIME DEPLOY SCRIPT
|
||||
# - Builds locally first (optional)
|
||||
# - Syncs only changed files
|
||||
# - Hot-reloads backend without full restart
|
||||
# - Rebuilds frontend in background
|
||||
# POUNCE ZERO-DOWNTIME DEPLOY PIPELINE v3.0
|
||||
#
|
||||
# Features:
|
||||
# - ZERO-DOWNTIME: Build happens while old server still runs
|
||||
# - Atomic switchover only after successful build
|
||||
# - Multiple connection methods (DNS, public IP, internal IP)
|
||||
# - Automatic retry with exponential backoff
|
||||
# - Health checks before and after deployment
|
||||
# - Parallel file sync for speed
|
||||
# - Detailed logging
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
set -uo pipefail
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
@ -16,93 +21,179 @@ YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
GRAY='\033[0;90m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ============================================================================
|
||||
# CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
# Server config
|
||||
SERVER_USER="user"
|
||||
SERVER_HOST="10.42.0.73"
|
||||
SERVER_PATH="/home/user/pounce"
|
||||
SERVER_PASS="user"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
SERVER_PATH="/home/user/pounce"
|
||||
|
||||
if ! command -v sshpass >/dev/null 2>&1; then
|
||||
echo -e "${RED}✗ sshpass is required but not installed.${NC}"
|
||||
echo -e " Install with: ${CYAN}brew install sshpass${NC}"
|
||||
exit 1
|
||||
fi
|
||||
# Multiple server addresses to try (in order of preference)
|
||||
declare -a SERVER_HOSTS=(
|
||||
"pounce.ch"
|
||||
"46.235.147.194"
|
||||
"10.42.0.73"
|
||||
)
|
||||
|
||||
# Parse flags
|
||||
QUICK_MODE=false
|
||||
BACKEND_ONLY=false
|
||||
FRONTEND_ONLY=false
|
||||
# SSH options
|
||||
SSH_TIMEOUT=15
|
||||
SSH_RETRIES=3
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=$SSH_TIMEOUT -o ServerAliveInterval=10 -o ServerAliveCountMax=3"
|
||||
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case $1 in
|
||||
-q|--quick) QUICK_MODE=true ;;
|
||||
-b|--backend) BACKEND_ONLY=true ;;
|
||||
-f|--frontend) FRONTEND_ONLY=true ;;
|
||||
*) COMMIT_MSG="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
# URLs for health checks
|
||||
FRONTEND_URL="https://pounce.ch"
|
||||
API_URL="https://pounce.ch/api/v1/health"
|
||||
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE} POUNCE ZERO-DOWNTIME DEPLOY ${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}"
|
||||
# Log file
|
||||
LOG_FILE="/tmp/pounce-deploy-$(date +%Y%m%d-%H%M%S).log"
|
||||
|
||||
if $QUICK_MODE; then
|
||||
echo -e "${CYAN}⚡ Quick mode: Skipping git, only syncing changes${NC}"
|
||||
fi
|
||||
# ============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
if $BACKEND_ONLY; then
|
||||
echo -e "${CYAN}🔧 Backend only mode${NC}"
|
||||
fi
|
||||
log() {
|
||||
local msg="[$(date '+%H:%M:%S')] $1"
|
||||
echo -e "$msg" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
if $FRONTEND_ONLY; then
|
||||
echo -e "${CYAN}🎨 Frontend only mode${NC}"
|
||||
fi
|
||||
log_success() { log "${GREEN}✓ $1${NC}"; }
|
||||
log_error() { log "${RED}✗ $1${NC}"; }
|
||||
log_warn() { log "${YELLOW}⚠ $1${NC}"; }
|
||||
log_info() { log "${BLUE}→ $1${NC}"; }
|
||||
log_debug() { log "${GRAY} $1${NC}"; }
|
||||
|
||||
# Step 1: Git (unless quick mode)
|
||||
if ! $QUICK_MODE; then
|
||||
echo -e "\n${YELLOW}[1/4] Git operations...${NC}"
|
||||
|
||||
# Check for changes (including untracked)
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
echo " No changes to commit"
|
||||
else
|
||||
git add -A
|
||||
|
||||
if [ -z "$COMMIT_MSG" ]; then
|
||||
COMMIT_MSG="Deploy: $(date '+%Y-%m-%d %H:%M')"
|
||||
# Check if command exists
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
log_error "$1 is required but not installed"
|
||||
if [ "$1" = "sshpass" ]; then
|
||||
echo -e " Install with: ${CYAN}brew install hudochenkov/sshpass/sshpass${NC}"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
git commit -m "$COMMIT_MSG" || true
|
||||
echo " ✓ Committed: $COMMIT_MSG"
|
||||
# ============================================================================
|
||||
# CONNECTION FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
# Find working server address
|
||||
find_server() {
|
||||
log_info "Finding reachable server..."
|
||||
|
||||
for host in "${SERVER_HOSTS[@]}"; do
|
||||
log_debug "Trying $host..."
|
||||
if curl -s --connect-timeout 5 "https://$host" >/dev/null 2>&1 || \
|
||||
curl -s --connect-timeout 5 "http://$host" >/dev/null 2>&1; then
|
||||
ACTIVE_HOST="$host"
|
||||
log_success "Server reachable via HTTPS at $host"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
log_error "No server reachable"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Test SSH connection with retries
|
||||
test_ssh() {
|
||||
local host="$1"
|
||||
local retries="${2:-$SSH_RETRIES}"
|
||||
|
||||
for i in $(seq 1 $retries); do
|
||||
if sshpass -p "$SERVER_PASS" ssh $SSH_OPTS "$SERVER_USER@$host" "echo 'SSH OK'" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if [ $i -lt $retries ]; then
|
||||
log_debug "Retry $i/$retries in ${i}s..."
|
||||
sleep $((i * 2))
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Find working SSH connection
|
||||
find_ssh() {
|
||||
log_info "Testing SSH connections..."
|
||||
|
||||
for host in "${SERVER_HOSTS[@]}"; do
|
||||
log_debug "Trying SSH to $host..."
|
||||
if test_ssh "$host" 2; then
|
||||
SSH_HOST="$host"
|
||||
log_success "SSH connected to $host"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
SSH_HOST=""
|
||||
log_warn "No SSH connection available"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Execute remote command with timeout
|
||||
remote_exec() {
|
||||
local cmd="$1"
|
||||
local timeout="${2:-1}" # 1=no timeout limit for builds
|
||||
|
||||
if [ -z "$SSH_HOST" ]; then
|
||||
log_error "No SSH connection"
|
||||
return 1
|
||||
fi
|
||||
|
||||
git push origin main 2>/dev/null && echo " ✓ Pushed to git.6bit.ch" || echo " ⚠ Push failed or nothing to push"
|
||||
else
|
||||
echo -e "\n${YELLOW}[1/4] Skipping git (quick mode)${NC}"
|
||||
fi
|
||||
sshpass -p "$SERVER_PASS" ssh $SSH_OPTS "$SERVER_USER@$SSH_HOST" "$cmd" 2>&1 | tee -a "$LOG_FILE"
|
||||
return ${PIPESTATUS[0]}
|
||||
}
|
||||
|
||||
# Step 2: Sync files (only changed)
|
||||
echo -e "\n${YELLOW}[2/4] Syncing changed files...${NC}"
|
||||
# ============================================================================
|
||||
# HEALTH CHECK FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
# Using compression (-z), checksum-based detection, and bandwidth throttling for stability
|
||||
RSYNC_OPTS="-avz --delete --compress-level=9 --checksum"
|
||||
check_api_health() {
|
||||
log_info "Checking API health..."
|
||||
|
||||
local status
|
||||
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 10 --max-time 30 "$API_URL" 2>/dev/null)
|
||||
|
||||
if [ "$status" = "200" ]; then
|
||||
log_success "API is healthy"
|
||||
return 0
|
||||
else
|
||||
log_error "API health check failed (HTTP $status)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
if ! $BACKEND_ONLY; then
|
||||
echo " Frontend:"
|
||||
sshpass -p "$SERVER_PASS" rsync -e "ssh $SSH_OPTS" $RSYNC_OPTS \
|
||||
--exclude 'node_modules' \
|
||||
--exclude '.next' \
|
||||
--exclude '.git' \
|
||||
frontend/ $SERVER_USER@$SERVER_HOST:$SERVER_PATH/frontend/
|
||||
fi
|
||||
check_frontend_health() {
|
||||
log_info "Checking frontend health..."
|
||||
|
||||
local status
|
||||
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 10 --max-time 30 "$FRONTEND_URL" 2>/dev/null)
|
||||
|
||||
if [ "$status" = "200" ]; then
|
||||
log_success "Frontend is healthy (HTTP $status)"
|
||||
return 0
|
||||
else
|
||||
log_error "Frontend health check failed (HTTP $status)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
if ! $FRONTEND_ONLY; then
|
||||
echo " Backend:"
|
||||
sshpass -p "$SERVER_PASS" rsync -e "ssh $SSH_OPTS" $RSYNC_OPTS \
|
||||
# ============================================================================
|
||||
# SYNC FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
sync_backend() {
|
||||
log_info "Syncing backend files..."
|
||||
|
||||
local host="${SSH_HOST:-$ACTIVE_HOST}"
|
||||
|
||||
sshpass -p "$SERVER_PASS" rsync -e "ssh $SSH_OPTS" \
|
||||
-avz --delete --compress-level=9 --checksum \
|
||||
--exclude '__pycache__' \
|
||||
--exclude '.pytest_cache' \
|
||||
--exclude 'venv' \
|
||||
@ -110,152 +201,451 @@ if ! $FRONTEND_ONLY; then
|
||||
--exclude '*.pyc' \
|
||||
--exclude '.env' \
|
||||
--exclude '*.db' \
|
||||
backend/ $SERVER_USER@$SERVER_HOST:$SERVER_PATH/backend/
|
||||
fi
|
||||
--exclude 'logs/' \
|
||||
backend/ "$SERVER_USER@$host:$SERVER_PATH/backend/" 2>&1 | tee -a "$LOG_FILE"
|
||||
|
||||
if [ ${PIPESTATUS[0]} -eq 0 ]; then
|
||||
log_success "Backend files synced"
|
||||
return 0
|
||||
else
|
||||
log_error "Backend sync failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Step 3: Reload backend (graceful, no restart)
|
||||
if ! $FRONTEND_ONLY; then
|
||||
echo -e "\n${YELLOW}[3/4] Reloading backend (graceful)...${NC}"
|
||||
sshpass -p "$SERVER_PASS" ssh $SSH_OPTS $SERVER_USER@$SERVER_HOST << 'BACKEND_EOF'
|
||||
set -e
|
||||
sync_frontend() {
|
||||
log_info "Syncing frontend files..."
|
||||
|
||||
local host="${SSH_HOST:-$ACTIVE_HOST}"
|
||||
|
||||
sshpass -p "$SERVER_PASS" rsync -e "ssh $SSH_OPTS" \
|
||||
-avz --delete --compress-level=9 --checksum \
|
||||
--exclude 'node_modules' \
|
||||
--exclude '.next' \
|
||||
--exclude '.git' \
|
||||
frontend/ "$SERVER_USER@$host:$SERVER_PATH/frontend/" 2>&1 | tee -a "$LOG_FILE"
|
||||
|
||||
if [ ${PIPESTATUS[0]} -eq 0 ]; then
|
||||
log_success "Frontend files synced"
|
||||
return 0
|
||||
else
|
||||
log_error "Frontend sync failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
cd ~/pounce/backend
|
||||
if [ -f "venv/bin/activate" ]; then
|
||||
# ============================================================================
|
||||
# DEPLOY FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
deploy_backend() {
|
||||
log_info "Deploying backend..."
|
||||
|
||||
if [ -z "$SSH_HOST" ]; then
|
||||
log_warn "SSH not available, backend will use synced files on next restart"
|
||||
return 0
|
||||
fi
|
||||
|
||||
remote_exec "
|
||||
cd $SERVER_PATH/backend
|
||||
|
||||
# Activate virtualenv
|
||||
if [ -f 'venv/bin/activate' ]; then
|
||||
source venv/bin/activate
|
||||
elif [ -f "../venv/bin/activate" ]; then
|
||||
source ../venv/bin/activate
|
||||
else
|
||||
echo " ✗ venv not found (expected backend/venv or ../venv)"
|
||||
echo 'venv not found, creating...'
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
# Run migrations
|
||||
echo 'Running database migrations...'
|
||||
python -c 'from app.database import init_db; import asyncio; asyncio.run(init_db())' 2>&1 || true
|
||||
|
||||
# Graceful restart (SIGHUP for uvicorn)
|
||||
if systemctl is-active --quiet pounce-backend 2>/dev/null; then
|
||||
echo 'Graceful backend restart via systemd...'
|
||||
echo '$SERVER_PASS' | sudo -S systemctl reload-or-restart pounce-backend
|
||||
sleep 2
|
||||
else
|
||||
echo 'Starting backend with nohup...'
|
||||
pkill -f 'uvicorn app.main:app' 2>/dev/null || true
|
||||
sleep 1
|
||||
cd $SERVER_PATH/backend
|
||||
source venv/bin/activate
|
||||
nohup uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/backend.log 2>&1 &
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
echo 'Backend deployment complete'
|
||||
" 3
|
||||
|
||||
return $?
|
||||
}
|
||||
|
||||
# ZERO-DOWNTIME FRONTEND DEPLOYMENT
|
||||
deploy_frontend_zero_downtime() {
|
||||
log_info "Zero-downtime frontend deployment..."
|
||||
|
||||
if [ -z "$SSH_HOST" ]; then
|
||||
log_warn "SSH not available, cannot build frontend remotely"
|
||||
return 1
|
||||
fi
|
||||
|
||||
remote_exec "
|
||||
cd $SERVER_PATH/frontend
|
||||
|
||||
# Create build timestamp for tracking
|
||||
BUILD_ID=\$(date +%Y%m%d-%H%M%S)
|
||||
echo \"Starting build \$BUILD_ID while server continues running...\"
|
||||
|
||||
# Check if dependencies need update
|
||||
LOCKFILE_HASH=''
|
||||
if [ -f '.lockfile_hash' ]; then
|
||||
LOCKFILE_HASH=\$(cat .lockfile_hash)
|
||||
fi
|
||||
CURRENT_HASH=\$(md5sum package-lock.json 2>/dev/null | cut -d' ' -f1 || echo 'none')
|
||||
|
||||
if [ \"\$LOCKFILE_HASH\" != \"\$CURRENT_HASH\" ]; then
|
||||
echo 'Installing dependencies...'
|
||||
npm ci --prefer-offline --no-audit --no-fund
|
||||
echo \"\$CURRENT_HASH\" > .lockfile_hash
|
||||
else
|
||||
echo 'Dependencies up to date (skipping npm ci)'
|
||||
fi
|
||||
|
||||
# ===== CRITICAL: Build WHILE old server still runs =====
|
||||
echo ''
|
||||
echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
|
||||
echo '🚀 Building new version (server still running)...'
|
||||
echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
|
||||
echo ''
|
||||
|
||||
# Build to .next directory
|
||||
NEXT_PUBLIC_API_URL=https://pounce.ch/api/v1 NODE_OPTIONS='--max-old-space-size=2048' npm run build
|
||||
|
||||
if [ \$? -ne 0 ]; then
|
||||
echo '❌ Build failed! Server continues with old version.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Update CZDS credentials if not set
|
||||
if ! grep -q "CZDS_USERNAME=" .env 2>/dev/null; then
|
||||
echo "" >> .env
|
||||
echo "# ICANN CZDS Zone File Service" >> .env
|
||||
echo "CZDS_USERNAME=guggeryves@hotmail.com" >> .env
|
||||
echo "CZDS_PASSWORD=Achiarorocco1278!" >> .env
|
||||
echo "CZDS_DATA_DIR=/home/user/pounce_czds" >> .env
|
||||
echo " ✓ CZDS credentials added to .env"
|
||||
else
|
||||
echo " ✓ CZDS credentials already configured"
|
||||
fi
|
||||
|
||||
echo " Running DB migrations..."
|
||||
python -c "from app.database import init_db; import asyncio; asyncio.run(init_db())"
|
||||
echo " ✓ DB migrations applied"
|
||||
|
||||
# Restart backend process (production typically runs without --reload)
|
||||
BACKEND_PID=$(pgrep -f 'uvicorn app.main:app' | awk 'NR==1{print; exit}')
|
||||
|
||||
if [ -n "$BACKEND_PID" ]; then
|
||||
echo " Restarting backend (PID: $BACKEND_PID)..."
|
||||
kill "$BACKEND_PID" 2>/dev/null || true
|
||||
sleep 1
|
||||
nohup uvicorn app.main:app --host 0.0.0.0 --port 8000 > backend.log 2>&1 &
|
||||
echo ''
|
||||
echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
|
||||
echo '✅ Build successful! Preparing atomic switchover...'
|
||||
echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
|
||||
echo ''
|
||||
|
||||
# Setup standalone directory with new build
|
||||
mkdir -p .next/standalone/.next
|
||||
|
||||
# Copy static assets (must be real files, not symlinks for reliability)
|
||||
rm -rf .next/standalone/.next/static
|
||||
cp -r .next/static .next/standalone/.next/
|
||||
|
||||
rm -rf .next/standalone/public
|
||||
cp -r public .next/standalone/public
|
||||
|
||||
echo 'New build prepared. Starting atomic switchover...'
|
||||
|
||||
# ===== ATOMIC SWITCHOVER: Stop old, start new immediately =====
|
||||
if systemctl is-active --quiet pounce-frontend 2>/dev/null; then
|
||||
echo 'Restarting frontend via systemd (fast restart)...'
|
||||
echo '$SERVER_PASS' | sudo -S systemctl restart pounce-frontend
|
||||
sleep 2
|
||||
echo " ✓ Backend restarted"
|
||||
else
|
||||
echo " ⚠ Backend not running, starting..."
|
||||
nohup uvicorn app.main:app --host 0.0.0.0 --port 8000 > backend.log 2>&1 &
|
||||
sleep 2
|
||||
echo " ✓ Backend started"
|
||||
fi
|
||||
BACKEND_EOF
|
||||
else
|
||||
echo -e "\n${YELLOW}[3/4] Skipping backend (frontend only)${NC}"
|
||||
fi
|
||||
|
||||
# Step 4: Rebuild frontend (in background to minimize downtime)
|
||||
if ! $BACKEND_ONLY; then
|
||||
echo -e "\n${YELLOW}[4/4] Rebuilding frontend...${NC}"
|
||||
sshpass -p "$SERVER_PASS" ssh $SSH_OPTS $SERVER_USER@$SERVER_HOST << 'FRONTEND_EOF'
|
||||
set -e
|
||||
cd ~/pounce/frontend
|
||||
|
||||
# Check if package.json changed (skip npm ci if not)
|
||||
LOCKFILE_HASH=""
|
||||
if [ -f ".lockfile_hash" ]; then
|
||||
LOCKFILE_HASH=$(cat .lockfile_hash)
|
||||
fi
|
||||
CURRENT_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d' ' -f1 || echo "none")
|
||||
|
||||
if [ "$LOCKFILE_HASH" != "$CURRENT_HASH" ]; then
|
||||
echo " Installing dependencies (package-lock.json changed)..."
|
||||
npm ci --prefer-offline --no-audit --no-fund
|
||||
echo "$CURRENT_HASH" > .lockfile_hash
|
||||
else
|
||||
echo " ✓ Dependencies unchanged, skipping npm ci"
|
||||
fi
|
||||
|
||||
# Build new version (with reduced memory for stability)
|
||||
# Set NEXT_PUBLIC_API_URL for client-side API calls
|
||||
echo " Building..."
|
||||
NEXT_PUBLIC_API_URL=https://pounce.ch/api/v1 NODE_OPTIONS="--max-old-space-size=2048" npm run build
|
||||
BUILD_EXIT=$?
|
||||
|
||||
if [ $BUILD_EXIT -eq 0 ]; then
|
||||
# Next.js standalone output requires public + static inside standalone folder
|
||||
mkdir -p .next/standalone/.next
|
||||
ln -sfn ../../static .next/standalone/.next/static
|
||||
# Manual restart - minimize gap
|
||||
echo 'Manual restart - minimizing downtime...'
|
||||
|
||||
# Copy public folder (symlinks don't work reliably)
|
||||
rm -rf .next/standalone/public
|
||||
cp -r public .next/standalone/public
|
||||
echo " ✓ Public files copied to standalone"
|
||||
|
||||
# Gracefully restart Next.js
|
||||
NEXT_PID=$(pgrep -af 'node \\.next/standalone/server\\.js|next start|next-server|next-serv' | awk 'NR==1{print $1; exit}')
|
||||
# Get old PID
|
||||
OLD_PID=\$(lsof -ti:3000 2>/dev/null || echo '')
|
||||
|
||||
if [ -n "$NEXT_PID" ]; then
|
||||
echo " Restarting Next.js (PID: $NEXT_PID)..."
|
||||
kill $NEXT_PID 2>/dev/null
|
||||
# Start new server first (on different internal port temporarily)
|
||||
cd $SERVER_PATH/frontend/.next/standalone
|
||||
NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3001 BACKEND_URL=http://127.0.0.1:8000 node server.js &
|
||||
NEW_PID=\$!
|
||||
sleep 3
|
||||
|
||||
# Verify new server is healthy
|
||||
if curl -s -o /dev/null -w '%{http_code}' http://localhost:3001 | grep -q '200'; then
|
||||
echo 'New server healthy on port 3001'
|
||||
|
||||
# Kill old server
|
||||
if [ -n \"\$OLD_PID\" ]; then
|
||||
kill -9 \$OLD_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Kill new server on temp port and restart on correct port
|
||||
kill -9 \$NEW_PID 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
# Ensure port is free (avoid EADDRINUSE)
|
||||
lsof -ti:3000 2>/dev/null | xargs -r kill -9 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Start new instance with internal backend URL
|
||||
if [ -f ".next/standalone/server.js" ]; then
|
||||
echo " Starting Next.js (standalone)..."
|
||||
nohup env NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 BACKEND_URL=http://127.0.0.1:8000 node .next/standalone/server.js > frontend.log 2>&1 &
|
||||
|
||||
# Start on correct port
|
||||
cd $SERVER_PATH/frontend/.next/standalone
|
||||
nohup env NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 BACKEND_URL=http://127.0.0.1:8000 node server.js > /tmp/frontend.log 2>&1 &
|
||||
sleep 2
|
||||
echo 'New server running on port 3000'
|
||||
else
|
||||
echo " Starting Next.js (npm start)..."
|
||||
nohup env NODE_ENV=production BACKEND_URL=http://127.0.0.1:8000 npm run start > frontend.log 2>&1 &
|
||||
echo '⚠️ New server failed health check, keeping old server'
|
||||
kill -9 \$NEW_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
|
||||
# Verify
|
||||
NEW_PID=$(pgrep -af 'node \\.next/standalone/server\\.js|next start|next-server|next-serv' | awk 'NR==1{print $1; exit}')
|
||||
if [ -n "$NEW_PID" ]; then
|
||||
echo " ✓ Frontend running (PID: $NEW_PID)"
|
||||
else
|
||||
echo " ⚠ Frontend may not have started correctly"
|
||||
echo " Last 80 lines of frontend.log:"
|
||||
tail -n 80 frontend.log || true
|
||||
fi
|
||||
else
|
||||
echo " ✗ Build failed, keeping old version"
|
||||
echo " Last 120 lines of build output (frontend.log):"
|
||||
tail -n 120 frontend.log || true
|
||||
fi
|
||||
FRONTEND_EOF
|
||||
else
|
||||
echo -e "\n${YELLOW}[4/4] Skipping frontend (backend only)${NC}"
|
||||
fi
|
||||
|
||||
echo ''
|
||||
echo '✅ Zero-downtime deployment complete!'
|
||||
echo \"Build ID: \$BUILD_ID\"
|
||||
" 1
|
||||
|
||||
return $?
|
||||
}
|
||||
|
||||
# Summary
|
||||
echo -e "\n${GREEN}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} ✅ DEPLOY COMPLETE! ${NC}"
|
||||
echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e ""
|
||||
echo -e " Frontend: ${BLUE}https://pounce.ch${NC} / http://$SERVER_HOST:3000"
|
||||
echo -e " Backend: ${BLUE}https://pounce.ch/api${NC} / http://$SERVER_HOST:8000"
|
||||
echo -e ""
|
||||
echo -e "${CYAN}Quick commands:${NC}"
|
||||
echo -e " ./deploy.sh -q # Quick sync, no git"
|
||||
echo -e " ./deploy.sh -b # Backend only"
|
||||
echo -e " ./deploy.sh -f # Frontend only"
|
||||
echo -e " ./deploy.sh \"message\" # Full deploy with commit message"
|
||||
# Legacy deploy (with downtime) - kept as fallback
|
||||
deploy_frontend_legacy() {
|
||||
log_info "Deploying frontend (legacy mode with downtime)..."
|
||||
|
||||
if [ -z "$SSH_HOST" ]; then
|
||||
log_warn "SSH not available, cannot build frontend remotely"
|
||||
return 1
|
||||
fi
|
||||
|
||||
remote_exec "
|
||||
cd $SERVER_PATH/frontend
|
||||
|
||||
# Stop server during build
|
||||
echo 'Stopping server for rebuild...'
|
||||
if systemctl is-active --quiet pounce-frontend 2>/dev/null; then
|
||||
echo '$SERVER_PASS' | sudo -S systemctl stop pounce-frontend
|
||||
else
|
||||
pkill -f 'node .next/standalone/server.js' 2>/dev/null || true
|
||||
lsof -ti:3000 | xargs -r kill -9 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Install & build
|
||||
npm ci --prefer-offline --no-audit --no-fund
|
||||
NEXT_PUBLIC_API_URL=https://pounce.ch/api/v1 NODE_OPTIONS='--max-old-space-size=2048' npm run build
|
||||
|
||||
# Setup standalone
|
||||
mkdir -p .next/standalone/.next
|
||||
rm -rf .next/standalone/.next/static
|
||||
cp -r .next/static .next/standalone/.next/
|
||||
rm -rf .next/standalone/public
|
||||
cp -r public .next/standalone/public
|
||||
|
||||
# Start server
|
||||
if systemctl is-active --quiet pounce-frontend 2>/dev/null; then
|
||||
echo '$SERVER_PASS' | sudo -S systemctl start pounce-frontend
|
||||
else
|
||||
cd $SERVER_PATH/frontend/.next/standalone
|
||||
nohup env NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 BACKEND_URL=http://127.0.0.1:8000 node server.js > /tmp/frontend.log 2>&1 &
|
||||
fi
|
||||
sleep 3
|
||||
echo 'Frontend deployment complete'
|
||||
" 1
|
||||
|
||||
return $?
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# GIT FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
git_commit_push() {
|
||||
local msg="${1:-Deploy: $(date '+%Y-%m-%d %H:%M')}"
|
||||
|
||||
log_info "Git operations..."
|
||||
|
||||
# Check for changes
|
||||
if [ -z "$(git status --porcelain 2>/dev/null)" ]; then
|
||||
log_debug "No changes to commit"
|
||||
else
|
||||
git add -A
|
||||
git commit -m "$msg" 2>&1 | tee -a "$LOG_FILE" || true
|
||||
log_success "Committed: $msg"
|
||||
fi
|
||||
|
||||
# Push
|
||||
if git push origin main 2>&1 | tee -a "$LOG_FILE"; then
|
||||
log_success "Pushed to remote"
|
||||
else
|
||||
log_warn "Push failed or nothing to push"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# MAIN DEPLOY FUNCTION
|
||||
# ============================================================================
|
||||
|
||||
deploy() {
|
||||
local mode="${1:-full}"
|
||||
local commit_msg="${2:-}"
|
||||
|
||||
echo -e "\n${BOLD}${BLUE}╔════════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BOLD}${BLUE}║ POUNCE ZERO-DOWNTIME DEPLOY v3.0 ║${NC}"
|
||||
echo -e "${BOLD}${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}\n"
|
||||
|
||||
log_info "Mode: ${CYAN}$mode${NC}"
|
||||
log_info "Log: ${CYAN}$LOG_FILE${NC}"
|
||||
|
||||
local errors=0
|
||||
local start_time=$(date +%s)
|
||||
|
||||
# Phase 1: Connectivity
|
||||
echo -e "\n${BOLD}[1/5] Connectivity${NC}"
|
||||
find_server || { log_error "Cannot reach server"; exit 1; }
|
||||
find_ssh || log_warn "SSH unavailable - sync-only mode"
|
||||
|
||||
# Phase 2: Pre-deploy health check
|
||||
echo -e "\n${BOLD}[2/5] Pre-deploy Health Check${NC}"
|
||||
check_api_health || ((errors++))
|
||||
check_frontend_health || ((errors++))
|
||||
|
||||
# Phase 3: Git (skip in quick mode)
|
||||
echo -e "\n${BOLD}[3/5] Git${NC}"
|
||||
if [ "$mode" = "quick" ] || [ "$mode" = "sync" ]; then
|
||||
echo -e " ${GRAY}(skipped)${NC}"
|
||||
else
|
||||
git_commit_push "$commit_msg"
|
||||
fi
|
||||
|
||||
# Phase 4: Sync & Deploy
|
||||
echo -e "\n${BOLD}[4/5] Sync & Deploy${NC}"
|
||||
|
||||
case "$mode" in
|
||||
backend)
|
||||
sync_backend || ((errors++))
|
||||
deploy_backend || ((errors++))
|
||||
;;
|
||||
frontend)
|
||||
sync_frontend || ((errors++))
|
||||
deploy_frontend_zero_downtime || ((errors++))
|
||||
;;
|
||||
sync)
|
||||
sync_backend || ((errors++))
|
||||
sync_frontend || ((errors++))
|
||||
;;
|
||||
*)
|
||||
# Full or quick deploy
|
||||
sync_backend || ((errors++))
|
||||
sync_frontend || ((errors++))
|
||||
deploy_backend || ((errors++))
|
||||
deploy_frontend_zero_downtime || ((errors++))
|
||||
;;
|
||||
esac
|
||||
|
||||
# Phase 5: Post-deploy health check
|
||||
echo -e "\n${BOLD}[5/5] Post-deploy Health Check${NC}"
|
||||
sleep 3 # Give services time to start
|
||||
check_api_health || ((errors++))
|
||||
check_frontend_health || ((errors++))
|
||||
|
||||
# Summary
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
|
||||
echo -e "\n${BOLD}════════════════════════════════════════════════════════════════${NC}"
|
||||
if [ $errors -eq 0 ]; then
|
||||
echo -e "${GREEN}${BOLD}✅ ZERO-DOWNTIME DEPLOY SUCCESSFUL${NC} (${duration}s)"
|
||||
else
|
||||
echo -e "${RED}${BOLD}⚠️ DEPLOY COMPLETED WITH $errors ERROR(S)${NC} (${duration}s)"
|
||||
fi
|
||||
echo -e "${BOLD}════════════════════════════════════════════════════════════════${NC}\n"
|
||||
|
||||
echo -e " ${CYAN}Frontend:${NC} $FRONTEND_URL"
|
||||
echo -e " ${CYAN}API:${NC} $API_URL"
|
||||
echo -e " ${CYAN}Log:${NC} $LOG_FILE"
|
||||
echo ""
|
||||
|
||||
return $errors
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# CLI INTERFACE
|
||||
# ============================================================================
|
||||
|
||||
show_help() {
|
||||
echo "Usage: $0 [command] [options]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " full Full deploy (default) - git, sync, build, restart"
|
||||
echo " quick Skip git commit/push"
|
||||
echo " backend Deploy backend only"
|
||||
echo " frontend Deploy frontend only"
|
||||
echo " sync Sync files only (no build/restart)"
|
||||
echo " status Show server status"
|
||||
echo " health Run health checks only"
|
||||
echo " legacy Use legacy deploy (with downtime)"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -m MSG Commit message"
|
||||
echo " -h Show this help"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Full zero-downtime deploy"
|
||||
echo " $0 quick # Quick deploy (skip git)"
|
||||
echo " $0 frontend # Frontend only"
|
||||
echo " $0 -m 'feat: new' # Full with commit message"
|
||||
}
|
||||
|
||||
# Main
|
||||
main() {
|
||||
require_cmd sshpass
|
||||
require_cmd rsync
|
||||
require_cmd curl
|
||||
require_cmd git
|
||||
|
||||
local command="full"
|
||||
local commit_msg=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
full|quick|backend|frontend|sync)
|
||||
command="$1"
|
||||
shift
|
||||
;;
|
||||
legacy)
|
||||
# Override frontend deploy function
|
||||
deploy_frontend_zero_downtime() { deploy_frontend_legacy; }
|
||||
command="full"
|
||||
shift
|
||||
;;
|
||||
status)
|
||||
find_server && find_ssh
|
||||
if [ -n "$SSH_HOST" ]; then
|
||||
remote_exec "
|
||||
echo '=== Services ==='
|
||||
systemctl status pounce-backend --no-pager 2>/dev/null | head -5 || echo 'Backend: manual mode'
|
||||
systemctl status pounce-frontend --no-pager 2>/dev/null | head -5 || echo 'Frontend: manual mode'
|
||||
echo ''
|
||||
echo '=== Ports ==='
|
||||
ss -tlnp | grep -E ':(3000|8000)' || echo 'No services on expected ports'
|
||||
"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
health)
|
||||
find_server
|
||||
check_api_health
|
||||
check_frontend_health
|
||||
exit 0
|
||||
;;
|
||||
-m)
|
||||
shift
|
||||
commit_msg="$1"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown option: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
deploy "$command" "$commit_msg"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
66
docker-compose.prod.yml
Normal file
66
docker-compose.prod.yml
Normal file
@ -0,0 +1,66 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: pounce-backend
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- pounce-network
|
||||
- supabase-network
|
||||
environment:
|
||||
# NOTE: Do NOT hardcode credentials in git.
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- FRONTEND_URL=http://pounce.185-142-213-170.sslip.io
|
||||
- ENVIRONMENT=production
|
||||
- ENABLE_SCHEDULER=true
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.pounce-backend.rule=Host(`backend.185-142-213-170.sslip.io`)"
|
||||
- "traefik.http.routers.pounce-backend.entryPoints=http"
|
||||
- "traefik.http.services.pounce-backend.loadbalancer.server.port=8000"
|
||||
- "coolify.managed=true"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=http://backend.185-142-213-170.sslip.io
|
||||
container_name: pounce-frontend
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- pounce-network
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://backend.185-142-213-170.sslip.io
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.pounce-frontend.rule=Host(`pounce.185-142-213-170.sslip.io`)"
|
||||
- "traefik.http.routers.pounce-frontend.entryPoints=http"
|
||||
- "traefik.http.services.pounce-frontend.loadbalancer.server.port=3000"
|
||||
- "coolify.managed=true"
|
||||
depends_on:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
networks:
|
||||
pounce-network:
|
||||
name: coolify
|
||||
external: true
|
||||
supabase-network:
|
||||
name: n0488s44osgoow4wgo04ogg0
|
||||
external: true
|
||||
@ -1,37 +1,40 @@
|
||||
# pounce Frontend Dockerfile
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Multi-stage build for optimized production image
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
# Install dependencies
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --prefer-offline
|
||||
|
||||
# Rebuild source code only when needed
|
||||
FROM base AS builder
|
||||
# Builder stage
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
# Build arguments
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG BACKEND_URL
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
ENV BACKEND_URL=${BACKEND_URL}
|
||||
ENV NODE_OPTIONS="--max-old-space-size=2048"
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production image
|
||||
FROM base AS runner
|
||||
# Production stage
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built application
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
@ -40,8 +43,7 @@ USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
|
||||
@ -161,8 +161,12 @@ const nextConfig = {
|
||||
// Proxy API requests to backend
|
||||
// This ensures /api/v1/* works regardless of how the server is accessed
|
||||
async rewrites() {
|
||||
// Determine backend URL based on environment
|
||||
const backendUrl = process.env.BACKEND_URL || 'http://127.0.0.1:8000'
|
||||
// In production (Docker), use internal container hostname
|
||||
// In development, use localhost
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
const backendUrl = process.env.BACKEND_URL || (isProduction ? 'http://pounce-backend:8000' : 'http://127.0.0.1:8000')
|
||||
|
||||
console.log(`[Next.js Config] Backend URL: ${backendUrl}`)
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
BIN
frontend/public/noise.png
Normal file
BIN
frontend/public/noise.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { EarningsTab } from '@/components/admin/EarningsTab'
|
||||
import { ZonesTab } from '@/components/admin/ZonesTab'
|
||||
import { PremiumTable, Badge, TableActionButton, StatCard } from '@/components/PremiumTable'
|
||||
import {
|
||||
Users,
|
||||
@ -56,7 +57,7 @@ import Image from 'next/image'
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
type TabType = 'overview' | 'earnings' | 'telemetry' | 'users' | 'alerts' | 'newsletter' | 'tld' | 'auctions' | 'blog' | 'system' | 'activity'
|
||||
type TabType = 'overview' | 'earnings' | 'telemetry' | 'users' | 'alerts' | 'newsletter' | 'tld' | 'auctions' | 'blog' | 'system' | 'activity' | 'zones'
|
||||
|
||||
interface AdminStats {
|
||||
users: { total: number; active: number; verified: number; new_this_week: number }
|
||||
@ -89,6 +90,7 @@ const TABS: Array<{ id: TabType; label: string; icon: any; shortLabel?: string }
|
||||
{ id: 'overview', label: 'Overview', icon: Activity, shortLabel: 'Overview' },
|
||||
{ id: 'earnings', label: 'Earnings', icon: DollarSign, shortLabel: 'Earnings' },
|
||||
{ id: 'telemetry', label: 'Telemetry', icon: BarChart3, shortLabel: 'KPIs' },
|
||||
{ id: 'zones', label: 'Zone Sync', icon: RefreshCw, shortLabel: 'Zones' },
|
||||
{ id: 'users', label: 'Users', icon: Users, shortLabel: 'Users' },
|
||||
{ id: 'newsletter', label: 'Newsletter', icon: Mail, shortLabel: 'News' },
|
||||
{ id: 'tld', label: 'TLD Data', icon: Globe, shortLabel: 'TLD' },
|
||||
@ -162,7 +164,7 @@ export default function AdminPage() {
|
||||
// Load data when tab changes
|
||||
useEffect(() => {
|
||||
if (!user?.is_admin) return
|
||||
loadAdminData()
|
||||
loadAdminData()
|
||||
}, [activeTab, user?.is_admin])
|
||||
|
||||
const toggleSidebar = () => {
|
||||
@ -199,27 +201,27 @@ export default function AdminPage() {
|
||||
setNewsletter(nlData.subscribers)
|
||||
setNewsletterTotal(nlData.total)
|
||||
} else if (activeTab === 'system') {
|
||||
const [healthData, schedulerData] = await Promise.all([
|
||||
const [healthData, schedulerData] = await Promise.all([
|
||||
api.getSystemHealth().catch(() => null),
|
||||
api.getSchedulerStatus().catch(() => null),
|
||||
])
|
||||
setSystemHealth(healthData)
|
||||
setSchedulerStatus(schedulerData)
|
||||
])
|
||||
setSystemHealth(healthData)
|
||||
setSchedulerStatus(schedulerData)
|
||||
const backupData = await api.listDbBackups(20).catch(() => ({ backups: [] }))
|
||||
setBackups(backupData.backups || [])
|
||||
const opsHistory = await api.getOpsAlertsHistory(50).catch(() => ({ events: [] }))
|
||||
setOpsAlertsHistory(opsHistory.events || [])
|
||||
} else if (activeTab === 'activity') {
|
||||
const logData = await api.getActivityLog(50, 0).catch(() => ({ logs: [], total: 0 }))
|
||||
setActivityLog(logData.logs)
|
||||
setActivityLogTotal(logData.total)
|
||||
setActivityLog(logData.logs)
|
||||
setActivityLogTotal(logData.total)
|
||||
} else if (activeTab === 'blog') {
|
||||
const blogData = await api.getAdminBlogPosts(50, 0).catch(() => ({ posts: [], total: 0 }))
|
||||
setBlogPosts(blogData.posts)
|
||||
setBlogPostsTotal(blogData.total)
|
||||
setBlogPosts(blogData.posts)
|
||||
setBlogPostsTotal(blogData.total)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load admin data')
|
||||
setError(err instanceof Error ? err.message : 'Failed to load admin data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -569,9 +571,9 @@ export default function AdminPage() {
|
||||
{/* Page Content */}
|
||||
<main className="px-4 sm:px-6 lg:px-8 py-6">
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||||
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||||
<p className="text-sm text-red-400 flex-1 font-mono">{error}</p>
|
||||
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
|
||||
<X className="w-4 h-4" />
|
||||
@ -592,37 +594,37 @@ export default function AdminPage() {
|
||||
{/* Tab Content */}
|
||||
{loading && activeTab !== 'earnings' ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 text-red-400 animate-spin" />
|
||||
<Loader2 className="w-6 h-6 text-red-400 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && stats && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="Total Users" value={stats.users.total} subtitle={`${stats.users.new_this_week} new this week`} icon={Users} />
|
||||
<StatCard title="Domains" value={stats.domains.watched} subtitle={`${stats.domains.portfolio} in portfolios`} icon={Eye} />
|
||||
<StatCard title="TLDs" value={stats.tld_data.unique_tlds} subtitle={`${stats.tld_data.price_records.toLocaleString()} prices`} icon={Globe} />
|
||||
<StatCard title="Newsletter" value={stats.newsletter_subscribers} icon={Mail} accent />
|
||||
<StatCard title="Domains" value={stats.domains.watched} subtitle={`${stats.domains.portfolio} in portfolios`} icon={Eye} />
|
||||
<StatCard title="TLDs" value={stats.tld_data.unique_tlds} subtitle={`${stats.tld_data.price_records.toLocaleString()} prices`} icon={Globe} />
|
||||
<StatCard title="Newsletter" value={stats.newsletter_subscribers} icon={Mail} accent />
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-4">
|
||||
{[
|
||||
<div className="grid lg:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ tier: 'scout', icon: Zap, color: 'text-white/40', bg: 'bg-white/5', border: 'border-white/10' },
|
||||
{ tier: 'trader', icon: TrendingUp, color: 'text-accent', bg: 'bg-accent/10', border: 'border-accent/20' },
|
||||
{ tier: 'tycoon', icon: Crown, color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/20' },
|
||||
].map(({ tier, icon: Icon, color, bg, border }) => (
|
||||
<div key={tier} className={clsx("p-6 border", bg, border)}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Icon className={clsx("w-5 h-5", color)} />
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Icon className={clsx("w-5 h-5", color)} />
|
||||
<span className="text-sm font-medium text-white/60 capitalize">{tier}</span>
|
||||
</div>
|
||||
<p className="text-4xl font-bold text-white font-mono">{stats.subscriptions[tier] || 0}</p>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
<div className="p-6 border border-white/10 bg-white/[0.02]">
|
||||
<h3 className="text-lg font-bold text-white mb-2">Active Auctions</h3>
|
||||
<p className="text-4xl font-bold text-white font-mono">{stats.auctions.toLocaleString()}</p>
|
||||
@ -638,6 +640,9 @@ export default function AdminPage() {
|
||||
{/* Earnings Tab */}
|
||||
{activeTab === 'earnings' && <EarningsTab />}
|
||||
|
||||
{/* Zones Tab */}
|
||||
{activeTab === 'zones' && <ZonesTab />}
|
||||
|
||||
{/* Telemetry Tab */}
|
||||
{activeTab === 'telemetry' && telemetry && (
|
||||
<div className="space-y-6">
|
||||
@ -703,7 +708,7 @@ export default function AdminPage() {
|
||||
|
||||
{/* Users Tab */}
|
||||
{activeTab === 'users' && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" />
|
||||
@ -717,136 +722,136 @@ export default function AdminPage() {
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleExportUsers} className="flex items-center gap-2 px-5 py-3 bg-white/5 border border-white/10 text-white text-sm hover:bg-white/10">
|
||||
<Download className="w-4 h-4" /> Export CSV
|
||||
<Download className="w-4 h-4" /> Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PremiumTable
|
||||
data={users}
|
||||
keyExtractor={(u) => u.id}
|
||||
columns={[
|
||||
{
|
||||
key: 'user',
|
||||
header: 'User',
|
||||
render: (u) => (
|
||||
<div>
|
||||
<PremiumTable
|
||||
data={users}
|
||||
keyExtractor={(u) => u.id}
|
||||
columns={[
|
||||
{
|
||||
key: 'user',
|
||||
header: 'User',
|
||||
render: (u) => (
|
||||
<div>
|
||||
<p className="font-medium text-white">{u.email}</p>
|
||||
<p className="text-xs text-white/40">{u.name || 'No name'}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
hideOnMobile: true,
|
||||
render: (u) => (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{u.is_admin && <Badge variant="accent" size="xs">Admin</Badge>}
|
||||
{u.is_verified && <Badge variant="success" size="xs">Verified</Badge>}
|
||||
{!u.is_active && <Badge variant="error" size="xs">Inactive</Badge>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tier',
|
||||
header: 'Tier',
|
||||
render: (u) => (
|
||||
<Badge variant={u.subscription.tier === 'tycoon' ? 'warning' : u.subscription.tier === 'trader' ? 'accent' : 'default'} size="sm">
|
||||
{u.subscription.tier_name}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'domains',
|
||||
header: 'Domains',
|
||||
hideOnMobile: true,
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
hideOnMobile: true,
|
||||
render: (u) => (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{u.is_admin && <Badge variant="accent" size="xs">Admin</Badge>}
|
||||
{u.is_verified && <Badge variant="success" size="xs">Verified</Badge>}
|
||||
{!u.is_active && <Badge variant="error" size="xs">Inactive</Badge>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tier',
|
||||
header: 'Tier',
|
||||
render: (u) => (
|
||||
<Badge variant={u.subscription.tier === 'tycoon' ? 'warning' : u.subscription.tier === 'trader' ? 'accent' : 'default'} size="sm">
|
||||
{u.subscription.tier_name}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'domains',
|
||||
header: 'Domains',
|
||||
hideOnMobile: true,
|
||||
render: (u) => <span className="text-white/60 font-mono">{u.domain_count}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
render: (u) => (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<select
|
||||
value={u.subscription.tier}
|
||||
onChange={(e) => handleUpgradeUser(u.id, e.target.value)}
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
render: (u) => (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<select
|
||||
value={u.subscription.tier}
|
||||
onChange={(e) => handleUpgradeUser(u.id, e.target.value)}
|
||||
className="px-2 py-1.5 bg-white/5 border border-white/10 text-white text-xs font-mono"
|
||||
>
|
||||
<option value="scout">Scout</option>
|
||||
<option value="trader">Trader</option>
|
||||
<option value="tycoon">Tycoon</option>
|
||||
</select>
|
||||
<TableActionButton icon={Shield} onClick={() => handleToggleAdmin(u.id, u.is_admin)} variant={u.is_admin ? 'accent' : 'default'} title={u.is_admin ? 'Remove admin' : 'Make admin'} />
|
||||
<TableActionButton icon={Trash2} onClick={() => handleDeleteUser(u.id, u.email)} variant="danger" disabled={u.is_admin} title="Delete user" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<option value="scout">Scout</option>
|
||||
<option value="trader">Trader</option>
|
||||
<option value="tycoon">Tycoon</option>
|
||||
</select>
|
||||
<TableActionButton icon={Shield} onClick={() => handleToggleAdmin(u.id, u.is_admin)} variant={u.is_admin ? 'accent' : 'default'} title={u.is_admin ? 'Remove admin' : 'Make admin'} />
|
||||
<TableActionButton icon={Trash2} onClick={() => handleDeleteUser(u.id, u.email)} variant="danger" disabled={u.is_admin} title="Delete user" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyIcon={<Users className="w-12 h-12 text-white/10" />}
|
||||
emptyTitle="No users found"
|
||||
/>
|
||||
emptyTitle="No users found"
|
||||
/>
|
||||
<p className="text-sm text-white/40 font-mono">Showing {users.length} of {usersTotal} users</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newsletter Tab */}
|
||||
{activeTab === 'newsletter' && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-white/60 font-mono">{newsletterTotal} subscribers</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const data = await api.exportNewsletterEmails()
|
||||
const blob = new Blob([data.emails.join('\n')], { type: 'text/plain' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = 'newsletter-emails.txt'
|
||||
a.click()
|
||||
const data = await api.exportNewsletterEmails()
|
||||
const blob = new Blob([data.emails.join('\n')], { type: 'text/plain' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = 'newsletter-emails.txt'
|
||||
a.click()
|
||||
}}
|
||||
className="px-5 py-2.5 bg-red-500 text-white font-bold uppercase tracking-wider text-sm hover:bg-red-400"
|
||||
>
|
||||
Export Emails
|
||||
</button>
|
||||
</div>
|
||||
<PremiumTable
|
||||
data={newsletter}
|
||||
keyExtractor={(s) => s.id}
|
||||
columns={[
|
||||
<PremiumTable
|
||||
data={newsletter}
|
||||
keyExtractor={(s) => s.id}
|
||||
columns={[
|
||||
{ key: 'email', header: 'Email', render: (s) => <span className="text-white font-mono">{s.email}</span> },
|
||||
{ key: 'status', header: 'Status', render: (s) => <Badge variant={s.is_active ? 'success' : 'error'} dot>{s.is_active ? 'Active' : 'Unsubscribed'}</Badge> },
|
||||
{ key: 'status', header: 'Status', render: (s) => <Badge variant={s.is_active ? 'success' : 'error'} dot>{s.is_active ? 'Active' : 'Unsubscribed'}</Badge> },
|
||||
{ key: 'subscribed', header: 'Subscribed', render: (s) => <span className="text-white/60 font-mono text-sm">{new Date(s.subscribed_at).toLocaleDateString()}</span> },
|
||||
]}
|
||||
/>
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* System Tab */}
|
||||
{activeTab === 'system' && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div className="border border-white/10 bg-[#0a0a0a]">
|
||||
<div className="px-6 py-4 border-b border-white/10">
|
||||
<h3 className="text-sm font-bold text-white uppercase tracking-wider">System Status</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-white/[0.06]">
|
||||
{[
|
||||
{ label: 'Database', ok: systemHealth?.database === 'healthy', text: systemHealth?.database || 'Unknown' },
|
||||
{ label: 'Email (SMTP)', ok: systemHealth?.email_configured, text: systemHealth?.email_configured ? 'Configured' : 'Not configured' },
|
||||
{ label: 'Stripe', ok: systemHealth?.stripe_configured, text: systemHealth?.stripe_configured ? 'Configured' : 'Not configured' },
|
||||
{ label: 'Scheduler', ok: schedulerStatus?.scheduler_running, text: schedulerStatus?.scheduler_running ? 'Running' : 'Stopped' },
|
||||
].map((item) => (
|
||||
{[
|
||||
{ label: 'Database', ok: systemHealth?.database === 'healthy', text: systemHealth?.database || 'Unknown' },
|
||||
{ label: 'Email (SMTP)', ok: systemHealth?.email_configured, text: systemHealth?.email_configured ? 'Configured' : 'Not configured' },
|
||||
{ label: 'Stripe', ok: systemHealth?.stripe_configured, text: systemHealth?.stripe_configured ? 'Configured' : 'Not configured' },
|
||||
{ label: 'Scheduler', ok: schedulerStatus?.scheduler_running, text: schedulerStatus?.scheduler_running ? 'Running' : 'Stopped' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="px-6 py-4 flex items-center justify-between">
|
||||
<span className="text-white/60 font-mono">{item.label}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{item.ok ? <CheckCircle className="w-4 h-4 text-accent" /> : <XCircle className="w-4 h-4 text-amber-400" />}
|
||||
<span className="flex items-center gap-2">
|
||||
{item.ok ? <CheckCircle className="w-4 h-4 text-accent" /> : <XCircle className="w-4 h-4 text-amber-400" />}
|
||||
<span className={clsx("font-mono text-sm", item.ok ? 'text-accent' : 'text-amber-400')}>{item.text}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
<div className="border border-white/10 bg-[#0a0a0a]">
|
||||
<div className="px-6 py-4 border-b border-white/10">
|
||||
<h3 className="text-sm font-bold text-white uppercase tracking-wider">Manual Triggers</h3>
|
||||
@ -869,15 +874,15 @@ export default function AdminPage() {
|
||||
{runningOpsAlerts ? 'Running...' : 'Run Ops Alerts'}
|
||||
</button>
|
||||
<button onClick={handleTriggerScrape} disabled={scraping} className="w-full flex items-center justify-center gap-2 px-5 py-3 bg-white/5 border border-white/10 text-white font-medium disabled:opacity-50 hover:bg-white/10">
|
||||
{scraping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Globe className="w-4 h-4" />}
|
||||
{scraping ? 'Scraping...' : 'Scrape TLD Prices'}
|
||||
</button>
|
||||
{scraping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Globe className="w-4 h-4" />}
|
||||
{scraping ? 'Scraping...' : 'Scrape TLD Prices'}
|
||||
</button>
|
||||
<button onClick={handleTriggerAuctionScrape} disabled={auctionScraping} className="w-full flex items-center justify-center gap-2 px-5 py-3 bg-white/5 border border-white/10 text-white font-medium disabled:opacity-50 hover:bg-white/10">
|
||||
{auctionScraping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Gavel className="w-4 h-4" />}
|
||||
{auctionScraping ? 'Scraping...' : 'Scrape Auctions'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{auctionScraping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Gavel className="w-4 h-4" />}
|
||||
{auctionScraping ? 'Scraping...' : 'Scrape Auctions'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-white/10 bg-[#0a0a0a]">
|
||||
<div className="px-6 py-4 border-b border-white/10">
|
||||
@ -893,24 +898,24 @@ export default function AdminPage() {
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-white font-mono truncate">{b.name}</p>
|
||||
<p className="text-xs text-white/40 font-mono">{new Date(b.modified_at).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-white/40 font-mono">
|
||||
{Math.round((b.size_bytes || 0) / 1024 / 1024)} MB
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TLD Tab */}
|
||||
{activeTab === 'tld' && stats && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-6">
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard title="Unique TLDs" value={stats.tld_data?.unique_tlds || 0} icon={Globe} />
|
||||
<StatCard title="Price Records" value={stats.tld_data?.price_records?.toLocaleString() || '0'} icon={Database} accent />
|
||||
<StatCard title="Active Alerts" value={stats.price_alerts || 0} icon={Bell} />
|
||||
@ -919,18 +924,18 @@ export default function AdminPage() {
|
||||
|
||||
<div className="border border-white/10 bg-[#0a0a0a] p-6">
|
||||
<h3 className="text-sm font-bold text-white uppercase tracking-wider mb-4">TLD Price Management</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={handleTriggerScrape}
|
||||
disabled={scraping}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={handleTriggerScrape}
|
||||
disabled={scraping}
|
||||
className="flex items-center gap-2 px-5 py-3 bg-red-500 text-white font-bold uppercase tracking-wider disabled:opacity-50 hover:bg-red-400"
|
||||
>
|
||||
{scraping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Globe className="w-4 h-4" />}
|
||||
{scraping ? 'Scraping...' : 'Scrape All Registrars'}
|
||||
</button>
|
||||
>
|
||||
{scraping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Globe className="w-4 h-4" />}
|
||||
{scraping ? 'Scraping...' : 'Scrape All Registrars'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auctions Tab */}
|
||||
@ -940,7 +945,7 @@ export default function AdminPage() {
|
||||
<StatCard title="Total Auctions" value={stats.auctions?.toLocaleString() || '0'} icon={Gavel} />
|
||||
<StatCard title="Platforms" value="4" subtitle="GoDaddy, Sedo, NameJet, DropCatch" icon={Globe} accent />
|
||||
<StatCard title="Clean Domains" value={Math.round((stats.auctions || 0) * 0.4).toLocaleString()} subtitle="~40% pass filter" icon={CheckCircle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-white/10 bg-[#0a0a0a] p-6">
|
||||
<h3 className="text-sm font-bold text-white uppercase tracking-wider mb-4">Auction Management</h3>
|
||||
@ -952,9 +957,9 @@ export default function AdminPage() {
|
||||
{auctionScraping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Gavel className="w-4 h-4" />}
|
||||
{auctionScraping ? 'Scraping...' : 'Scrape All Platforms'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activity Tab */}
|
||||
{activeTab === 'activity' && (
|
||||
@ -970,9 +975,9 @@ export default function AdminPage() {
|
||||
{ key: 'time', header: 'Time', hideOnMobile: true, render: (l) => <span className="text-white/40 font-mono text-sm">{new Date(l.created_at).toLocaleString()}</span> },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -105,7 +105,7 @@ export default function ForgotPasswordPage() {
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="OPERATOR@POUNCE.IO"
|
||||
placeholder="OPERATOR@POUNCE.CH"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="w-full bg-[#0A0A0A] border border-white/10 px-4 py-3 text-white font-mono text-sm placeholder:text-white/20 focus:outline-none focus:border-accent transition-all rounded-none"
|
||||
|
||||
@ -187,7 +187,7 @@ function LoginForm() {
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="OPERATOR@POUNCE.IO"
|
||||
placeholder="OPERATOR@POUNCE.CH"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="w-full bg-[#0A0A0A] border border-white/10 px-4 py-3 text-white font-mono text-sm placeholder:text-white/20 focus:outline-none focus:border-accent transition-all rounded-none"
|
||||
|
||||
@ -175,8 +175,8 @@ export default function HomePage() {
|
||||
{/* Subline */}
|
||||
<div className="animate-slide-up opacity-0" style={{ animationDelay: '0.8s', animationFillMode: 'forwards' }}>
|
||||
<p className="text-sm sm:text-lg lg:text-xl text-white/60 max-w-xl font-light leading-relaxed mb-8 sm:mb-12 lg:mx-0 mx-auto">
|
||||
Transforming domains from static addresses into yield-bearing financial assets.
|
||||
<span className="text-white block mt-1 font-medium">Scan. Acquire. Route. Profit.</span>
|
||||
Domain intelligence for investors — scan live auctions, compare TLD pricing, and monitor portfolios in a clean, spam-filtered terminal.
|
||||
<span className="text-white block mt-1 font-medium">Scan. Track. Trade. Verify.</span>
|
||||
</p>
|
||||
|
||||
{/* Stats Grid - Mobile 2x2 */}
|
||||
@ -251,13 +251,13 @@ export default function HomePage() {
|
||||
<div className="max-w-[1200px] mx-auto">
|
||||
<div className="grid lg:grid-cols-2 gap-10 lg:gap-24 items-center">
|
||||
<div>
|
||||
<span className="text-accent font-mono text-[10px] sm:text-xs uppercase tracking-[0.2em] mb-3 sm:mb-4 block">The Broken Model</span>
|
||||
<span className="text-accent font-mono text-[10px] sm:text-xs uppercase tracking-[0.2em] mb-3 sm:mb-4 block">The Problem</span>
|
||||
<h2 className="font-display text-2xl sm:text-4xl lg:text-5xl text-white leading-tight mb-6 sm:mb-8">
|
||||
99% of portfolios are <br className="hidden sm:block"/><span className="text-white/30">bleeding cash.</span>
|
||||
The market is <br className="hidden sm:block"/><span className="text-white/30">loud & opaque.</span>
|
||||
</h2>
|
||||
<div className="space-y-4 sm:space-y-6 text-white/60 leading-relaxed text-sm sm:text-lg font-light">
|
||||
<p>Investors pay renewal fees for years, hoping for a "Unicorn" sale that never happens.</p>
|
||||
<p>Traditional parking pays pennies. Marketplaces charge 20% fees. The system drains your capital.</p>
|
||||
<p>Auctions are full of junk, pricing is fragmented across registrars, and the best signals are hidden behind spreadsheets.</p>
|
||||
<p>You need high-density intel, fast filtering, and operator-grade workflows — not more noise.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
@ -270,21 +270,21 @@ export default function HomePage() {
|
||||
<Radar className="w-4 h-4 sm:w-5 sm:h-5 text-accent mt-px shrink-0" />
|
||||
<div>
|
||||
<span className="text-white font-bold block mb-1">Deep Recon</span>
|
||||
<span className="text-white/50 text-[11px] sm:text-sm">Zone file analysis reveals what's truly valuable.</span>
|
||||
<span className="text-white/50 text-[11px] sm:text-sm">TLD pricing + trends to spot traps and opportunities.</span>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3 sm:gap-4">
|
||||
<Zap className="w-4 h-4 sm:w-5 sm:h-5 text-accent mt-px shrink-0" />
|
||||
<div>
|
||||
<span className="text-white font-bold block mb-1">Frictionless Liquidity</span>
|
||||
<span className="text-white/50 text-[11px] sm:text-sm">Instant settlement. 0% Commission.</span>
|
||||
<span className="text-white font-bold block mb-1">Verified Market</span>
|
||||
<span className="text-white/50 text-[11px] sm:text-sm">Pounce Direct listings with DNS-verified owners.</span>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3 sm:gap-4">
|
||||
<Coins className="w-4 h-4 sm:w-5 sm:h-5 text-accent mt-px shrink-0" />
|
||||
<div>
|
||||
<span className="text-white font-bold block mb-1">Automated Yield</span>
|
||||
<span className="text-white/50 text-[11px] sm:text-sm">Domains pay for their own renewals.</span>
|
||||
<span className="text-white font-bold block mb-1">Portfolio Ops</span>
|
||||
<span className="text-white/50 text-[11px] sm:text-sm">Watchlists, monitoring, and clean execution in one terminal.</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@ -311,7 +311,7 @@ export default function HomePage() {
|
||||
<p className="hidden lg:block text-white/50 max-w-md text-sm font-mono mt-8 lg:mt-0 leading-relaxed text-right">
|
||||
// INTELLIGENCE_LAYER_ACTIVE<br />
|
||||
// MARKET_PROTOCOL_READY<br />
|
||||
// YIELD_GENERATION_STANDBY
|
||||
// AUTOMATION_LAYER_OPTIONAL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -338,11 +338,11 @@ export default function HomePage() {
|
||||
},
|
||||
{
|
||||
module: '03',
|
||||
title: 'Yield',
|
||||
desc: '"Deploy the Asset." Transform idle domains into automated revenue generators.',
|
||||
title: 'Terminal',
|
||||
desc: '"Operate the Asset." High density, low noise workflows for tracking, filtering, and execution.',
|
||||
features: [
|
||||
{ icon: Layers, title: 'Intent Routing', desc: 'Traffic to partners' },
|
||||
{ icon: Coins, title: 'Passive Income', desc: 'Monthly payouts' },
|
||||
{ icon: Layers, title: 'Clean Feed', desc: 'Spam-filtered auctions + listings' },
|
||||
{ icon: Coins, title: 'Pricing Intel', desc: 'Trends + renewal risk signals' },
|
||||
],
|
||||
},
|
||||
].map((pillar, i) => (
|
||||
@ -381,24 +381,24 @@ export default function HomePage() {
|
||||
</section>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
{/* INTENT ROUTING */}
|
||||
{/* AUTOMATION (OPTIONAL) */}
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
<section className="relative py-16 sm:py-32 px-4 sm:px-6 border-b border-white/[0.05] bg-[#050505] overflow-hidden">
|
||||
<div className="absolute inset-0 bg-accent/[0.02]" />
|
||||
<div className="max-w-[1200px] mx-auto relative z-10">
|
||||
<div className="mb-12 sm:mb-20 text-center">
|
||||
<span className="text-accent font-mono text-[10px] sm:text-xs uppercase tracking-[0.2em] mb-3 sm:mb-4 block">The Endgame</span>
|
||||
<span className="text-accent font-mono text-[10px] sm:text-xs uppercase tracking-[0.2em] mb-3 sm:mb-4 block">Optional Automation</span>
|
||||
<h2 className="font-display text-2xl sm:text-4xl lg:text-5xl text-white mb-4 sm:mb-6">Intent Routing™</h2>
|
||||
<p className="text-white/50 max-w-2xl mx-auto text-sm sm:text-lg font-light leading-relaxed px-2">
|
||||
Our engine detects user intent and routes traffic directly to high-paying partners.
|
||||
For operators who want more: connect a domain, detect intent, and route traffic to the best destination. Your core product stays intelligence + market access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-8">
|
||||
{[
|
||||
{ icon: Network, step: '1', title: 'Connect', desc: 'Point nameservers to ns.pounce.io' },
|
||||
{ icon: Cpu, step: '2', title: 'Analyze', desc: 'We scan the semantic intent of your domain' },
|
||||
{ icon: Share2, step: '3', title: 'Route', desc: 'Traffic is routed to vertical partners' },
|
||||
{ icon: Network, step: '1', title: 'Connect', desc: 'Point nameservers to ns.pounce.ch (optional)' },
|
||||
{ icon: Cpu, step: '2', title: 'Analyze', desc: 'We detect intent and risk signals from real usage' },
|
||||
{ icon: Share2, step: '3', title: 'Route', desc: 'Send traffic to the best destination (partner, listing, or page)' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="bg-[#020202] border border-white/10 p-6 sm:p-10 relative group hover:border-accent/30 transition-colors">
|
||||
<div className="w-10 h-10 sm:w-14 sm:h-14 bg-white/5 flex items-center justify-center mb-5 sm:mb-8 text-white group-hover:text-accent group-hover:bg-accent/10 transition-all">
|
||||
|
||||
@ -17,16 +17,16 @@ const tiers = [
|
||||
icon: Zap,
|
||||
price: '0',
|
||||
period: '',
|
||||
description: 'Recon access. No commitment.',
|
||||
description: 'Taste the system. No commitment.',
|
||||
features: [
|
||||
{ text: 'Market Feed', highlight: false, available: true, sublabel: 'Raw' },
|
||||
{ text: 'Alert Speed', highlight: false, available: true, sublabel: 'Daily' },
|
||||
{ text: '5 Watchlist Domains', highlight: false, available: true },
|
||||
{ text: '2 Sniper Alerts', highlight: false, available: true },
|
||||
{ text: '5 Portfolio Domains', highlight: false, available: true },
|
||||
{ text: 'Listings', highlight: false, available: false },
|
||||
{ text: 'Sniper Alerts', highlight: false, available: false },
|
||||
{ text: 'Pounce Score', highlight: false, available: true, sublabel: 'Basic' },
|
||||
{ text: 'TLD Intel', highlight: false, available: true, sublabel: 'Public' },
|
||||
{ text: 'Pounce Score', highlight: false, available: false },
|
||||
{ text: 'Marketplace', highlight: false, available: true, sublabel: 'Buy Only' },
|
||||
{ text: 'Yield (Intent Routing)', highlight: false, available: false },
|
||||
],
|
||||
cta: 'Enter Terminal',
|
||||
highlighted: false,
|
||||
@ -44,12 +44,12 @@ const tiers = [
|
||||
{ text: 'Market Feed', highlight: true, available: true, sublabel: 'Curated' },
|
||||
{ text: 'Alert Speed', highlight: true, available: true, sublabel: 'Hourly' },
|
||||
{ text: '50 Watchlist Domains', highlight: true, available: true },
|
||||
{ text: '50 Portfolio Domains', highlight: true, available: true },
|
||||
{ text: '10 Listings', highlight: true, available: true, sublabel: '0% Fee' },
|
||||
{ text: '10 Sniper Alerts', highlight: true, available: true },
|
||||
{ text: 'Pounce Score', highlight: true, available: true, sublabel: 'Full' },
|
||||
{ text: 'TLD Intel', highlight: true, available: true, sublabel: 'Renewal Prices' },
|
||||
{ text: 'Pounce Score', highlight: true, available: true },
|
||||
{ text: '5 Listings', highlight: true, available: true, sublabel: '0% Fee' },
|
||||
{ text: 'Portfolio', highlight: true, available: true, sublabel: '25 Domains' },
|
||||
{ text: 'Yield (Intent Routing)', highlight: true, available: true, sublabel: '70% Rev Share' },
|
||||
{ text: 'Yield Preview', highlight: true, available: true, sublabel: 'Landing only' },
|
||||
],
|
||||
cta: 'Upgrade to Trader',
|
||||
highlighted: true,
|
||||
@ -62,17 +62,17 @@ const tiers = [
|
||||
icon: Crown,
|
||||
price: '29',
|
||||
period: '/mo',
|
||||
description: 'Full firepower. Priority routes.',
|
||||
description: 'Full firepower. No limits.',
|
||||
features: [
|
||||
{ text: 'Market Feed', highlight: true, available: true, sublabel: 'Priority' },
|
||||
{ text: 'Alert Speed', highlight: true, available: true, sublabel: '10 min' },
|
||||
{ text: '500 Watchlist Domains', highlight: true, available: true },
|
||||
{ text: '50 Sniper Alerts', highlight: true, available: true },
|
||||
{ text: 'TLD Intel', highlight: true, available: true, sublabel: 'Full History' },
|
||||
{ text: 'Score + SEO Data', highlight: true, available: true },
|
||||
{ text: '50 Listings', highlight: true, available: true, sublabel: 'Featured' },
|
||||
{ text: 'Alert Speed', highlight: true, available: true, sublabel: '5 min' },
|
||||
{ text: 'Unlimited Watchlist', highlight: true, available: true },
|
||||
{ text: 'Unlimited Portfolio', highlight: true, available: true },
|
||||
{ text: 'Yield (Intent Routing)', highlight: true, available: true, sublabel: 'Priority Routes' },
|
||||
{ text: 'Unlimited Listings', highlight: true, available: true, sublabel: 'Featured' },
|
||||
{ text: '50 Sniper Alerts', highlight: true, available: true },
|
||||
{ text: 'Yield Active', highlight: true, available: true, sublabel: 'Full routing' },
|
||||
{ text: 'Score + SEO Data', highlight: true, available: true },
|
||||
{ text: 'API + Webhooks', highlight: true, available: true },
|
||||
],
|
||||
cta: 'Go Tycoon',
|
||||
highlighted: false,
|
||||
@ -82,21 +82,22 @@ const tiers = [
|
||||
]
|
||||
|
||||
const comparisonFeatures = [
|
||||
{ name: 'Market Feed', scout: 'Raw (Unfiltered)', trader: 'Curated (Spam-Free)', tycoon: 'Curated + Priority' },
|
||||
{ name: 'Alert Speed', scout: 'Daily', trader: 'Hourly', tycoon: 'Every 10 minutes' },
|
||||
{ name: 'Watchlist', scout: '5 Domains', trader: '50 Domains', tycoon: '500 Domains' },
|
||||
{ name: 'Sniper Alerts', scout: '2', trader: '10', tycoon: '50' },
|
||||
{ name: 'TLD Intel', scout: 'Public Trends', trader: 'Renewal Prices', tycoon: 'Full History' },
|
||||
{ name: 'Valuation', scout: 'Locked', trader: 'Pounce Score', tycoon: 'Score + SEO' },
|
||||
{ name: 'Marketplace', scout: 'Buy Only', trader: '5 Listings (0% Fee)', tycoon: '50 Featured' },
|
||||
{ name: 'Portfolio', scout: '—', trader: '25 Domains', tycoon: 'Unlimited' },
|
||||
{ name: 'Yield (Intent Routing)', scout: '—', trader: '70% Rev Share', tycoon: 'Priority Routes' },
|
||||
{ name: 'Market Feed', scout: 'Raw', trader: 'Curated', tycoon: 'Priority + Early' },
|
||||
{ name: 'Alert Speed', scout: 'Daily', trader: 'Hourly', tycoon: 'Every 5 min' },
|
||||
{ name: 'Watchlist', scout: '5 Domains', trader: '50 Domains', tycoon: 'Unlimited' },
|
||||
{ name: 'Portfolio', scout: '5 Domains', trader: '50 Domains', tycoon: 'Unlimited' },
|
||||
{ name: 'Listings', scout: '—', trader: '10 (0% Fee)', tycoon: 'Unlimited + Featured' },
|
||||
{ name: 'Sniper Alerts', scout: '—', trader: '10', tycoon: '50' },
|
||||
{ name: 'Valuation', scout: 'Basic Score', trader: 'Pounce Score', tycoon: 'Score + SEO' },
|
||||
{ name: 'TLD Intel', scout: 'Public', trader: 'Renewal Prices', tycoon: 'Full History' },
|
||||
{ name: 'Yield', scout: '—', trader: 'Preview', tycoon: 'Active Routing' },
|
||||
{ name: 'API Access', scout: '—', trader: '—', tycoon: '✓' },
|
||||
]
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: 'How fast will I know when a domain drops?',
|
||||
a: 'Depends on your plan. Scout: daily. Trader: hourly. Tycoon: every 10 minutes. When it drops, you\'ll know.',
|
||||
a: 'Depends on your plan. Scout: daily. Trader: hourly. Tycoon: every 5 minutes. When it drops, you\'ll know.',
|
||||
},
|
||||
{
|
||||
q: 'What\'s domain valuation?',
|
||||
|
||||
@ -217,7 +217,7 @@ function RegisterForm() {
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="OPERATOR@POUNCE.IO"
|
||||
placeholder="OPERATOR@POUNCE.CH"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="w-full bg-[#0A0A0A] border border-white/10 px-4 py-3 text-white font-mono text-sm placeholder:text-white/20 focus:outline-none focus:border-accent transition-all rounded-none"
|
||||
|
||||
@ -46,10 +46,10 @@ type HuntTab = 'auctions' | 'drops' | 'search' | 'trends' | 'forge'
|
||||
// ============================================================================
|
||||
|
||||
const TABS: Array<{ key: HuntTab; label: string; shortLabel: string; icon: any; color: string }> = [
|
||||
{ key: 'auctions', label: 'Auctions', shortLabel: 'Auctions', icon: Gavel, color: 'accent' },
|
||||
{ key: 'search', label: 'Search', shortLabel: 'Search', icon: Search, color: 'accent' },
|
||||
{ key: 'drops', label: 'Drops', shortLabel: 'Drops', icon: Download, color: 'blue' },
|
||||
{ key: 'search', label: 'Search', shortLabel: 'Search', icon: Search, color: 'white' },
|
||||
{ key: 'trends', label: 'Trends', shortLabel: 'Trends', icon: Flame, color: 'orange' },
|
||||
{ key: 'auctions', label: 'Auctions', shortLabel: 'Auctions', icon: Gavel, color: 'orange' },
|
||||
{ key: 'trends', label: 'Trends', shortLabel: 'Trends', icon: Flame, color: 'rose' },
|
||||
{ key: 'forge', label: 'Forge', shortLabel: 'Forge', icon: Wand2, color: 'purple' },
|
||||
]
|
||||
|
||||
@ -60,7 +60,7 @@ const TABS: Array<{ key: HuntTab; label: string; shortLabel: string; icon: any;
|
||||
export default function HuntPage() {
|
||||
const { user, subscription, logout, checkAuth } = useStore()
|
||||
const { toast, showToast, hideToast } = useToast()
|
||||
const [tab, setTab] = useState<HuntTab>('auctions')
|
||||
const [tab, setTab] = useState<HuntTab>('search')
|
||||
|
||||
// Mobile Menu State
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
@ -143,17 +143,19 @@ export default function HuntPage() {
|
||||
onClick={() => setTab(t.key)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-2 border transition-all shrink-0',
|
||||
isActive
|
||||
? t.color === 'accent'
|
||||
? 'border-accent/40 bg-accent/10 text-accent'
|
||||
: t.color === 'blue'
|
||||
? 'border-blue-500/40 bg-blue-500/10 text-blue-400'
|
||||
: t.color === 'orange'
|
||||
? 'border-orange-500/40 bg-orange-500/10 text-orange-400'
|
||||
: t.color === 'purple'
|
||||
? 'border-purple-500/40 bg-purple-500/10 text-purple-400'
|
||||
: 'border-white/40 bg-white/10 text-white'
|
||||
: 'border-transparent text-white/40 active:bg-white/5'
|
||||
isActive
|
||||
? t.color === 'accent'
|
||||
? 'border-accent/40 bg-accent/10 text-accent'
|
||||
: t.color === 'blue'
|
||||
? 'border-blue-500/40 bg-blue-500/10 text-blue-400'
|
||||
: t.color === 'orange'
|
||||
? 'border-orange-500/40 bg-orange-500/10 text-orange-400'
|
||||
: t.color === 'rose'
|
||||
? 'border-rose-500/40 bg-rose-500/10 text-rose-400'
|
||||
: t.color === 'purple'
|
||||
? 'border-purple-500/40 bg-purple-500/10 text-purple-400'
|
||||
: 'border-white/40 bg-white/10 text-white'
|
||||
: 'border-transparent text-white/40 active:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<t.icon className="w-3.5 h-3.5" />
|
||||
@ -192,6 +194,7 @@ export default function HuntPage() {
|
||||
blue: { active: 'border-blue-500 bg-blue-500/10 text-blue-400', inactive: 'border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]' },
|
||||
white: { active: 'border-white/40 bg-white/10 text-white', inactive: 'border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]' },
|
||||
orange: { active: 'border-orange-500 bg-orange-500/10 text-orange-400', inactive: 'border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]' },
|
||||
rose: { active: 'border-rose-500 bg-rose-500/10 text-rose-400', inactive: 'border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]' },
|
||||
purple: { active: 'border-purple-500 bg-purple-500/10 text-purple-400', inactive: 'border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.02]' },
|
||||
}
|
||||
const classes = colorClasses[t.color] || colorClasses.white
|
||||
|
||||
@ -20,12 +20,20 @@ import {
|
||||
LogOut,
|
||||
Crown,
|
||||
Zap,
|
||||
Tag,
|
||||
ShoppingCart,
|
||||
DollarSign,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Mail,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type Thread = {
|
||||
// Types
|
||||
type BuyerThread = {
|
||||
id: number
|
||||
listing_id: number
|
||||
domain: string
|
||||
@ -36,6 +44,26 @@ type Thread = {
|
||||
closed_reason: string | null
|
||||
}
|
||||
|
||||
type SellerInquiry = {
|
||||
id: number
|
||||
listing_id: number
|
||||
domain: string
|
||||
slug: string
|
||||
buyer_name: string
|
||||
buyer_email: string
|
||||
offer_amount: number | null
|
||||
status: string
|
||||
created_at: string
|
||||
read_at: string | null
|
||||
replied_at: string | null
|
||||
closed_at: string | null
|
||||
closed_reason: string | null
|
||||
has_unread_reply: boolean
|
||||
last_message_preview: string
|
||||
last_message_at: string
|
||||
last_message_is_buyer: boolean
|
||||
}
|
||||
|
||||
type Message = {
|
||||
id: number
|
||||
inquiry_id: number
|
||||
@ -45,16 +73,34 @@ type Message = {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type InboxTab = 'buying' | 'selling'
|
||||
|
||||
// Tab config like Hunt page
|
||||
const TABS: Array<{ key: InboxTab; label: string; shortLabel: string; icon: any; color: string }> = [
|
||||
{ key: 'buying', label: 'Buying', shortLabel: 'Buying', icon: ShoppingCart, color: 'accent' },
|
||||
{ key: 'selling', label: 'Selling', shortLabel: 'Selling', icon: Tag, color: 'blue' },
|
||||
]
|
||||
|
||||
export default function InboxPage() {
|
||||
const { user, subscription, logout, checkAuth } = useStore()
|
||||
const searchParams = useSearchParams()
|
||||
const openInquiryId = searchParams.get('inquiry')
|
||||
const initialTab = searchParams.get('tab') as InboxTab | null
|
||||
|
||||
const [threads, setThreads] = useState<Thread[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<InboxTab>(initialTab || 'buying')
|
||||
|
||||
// Buyer state
|
||||
const [buyerThreads, setBuyerThreads] = useState<BuyerThread[]>([])
|
||||
const [loadingBuyer, setLoadingBuyer] = useState(true)
|
||||
|
||||
// Seller state
|
||||
const [sellerInquiries, setSellerInquiries] = useState<SellerInquiry[]>([])
|
||||
const [loadingSeller, setLoadingSeller] = useState(true)
|
||||
const [sellerUnread, setSellerUnread] = useState(0)
|
||||
|
||||
// Shared state
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const [activeThread, setActiveThread] = useState<Thread | null>(null)
|
||||
const [activeThread, setActiveThread] = useState<BuyerThread | SellerInquiry | null>(null)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [loadingMessages, setLoadingMessages] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
@ -65,54 +111,92 @@ export default function InboxPage() {
|
||||
|
||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||
const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap
|
||||
// All tiers can now list domains (Scout=1, Trader=10, Tycoon=unlimited)
|
||||
const isSeller = true
|
||||
|
||||
const drawerNavSections = [
|
||||
{ title: 'Discover', items: [
|
||||
{ href: '/terminal/hunt', label: 'Radar', icon: Target },
|
||||
{ href: '/terminal/market', label: 'Market', icon: Gavel },
|
||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
||||
]},
|
||||
{ title: 'Manage', items: [
|
||||
{ href: '/terminal/watchlist', label: 'Watchlist', icon: Eye },
|
||||
{ href: '/terminal/inbox', label: 'Inbox', icon: MessageSquare, active: true },
|
||||
]},
|
||||
]
|
||||
|
||||
const loadThreads = useCallback(async () => {
|
||||
setLoading(true)
|
||||
// Load buyer threads
|
||||
const loadBuyerThreads = useCallback(async () => {
|
||||
setLoadingBuyer(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await api.getMyInquiryThreads()
|
||||
setThreads(data)
|
||||
setBuyerThreads(data)
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to load inbox')
|
||||
// Silently fail - might not have any threads
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingBuyer(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadThreads() }, [loadThreads])
|
||||
// Load seller inquiries
|
||||
const loadSellerInquiries = useCallback(async () => {
|
||||
if (!isSeller) {
|
||||
setLoadingSeller(false)
|
||||
return
|
||||
}
|
||||
setLoadingSeller(true)
|
||||
try {
|
||||
const data = await api.getSellerInbox()
|
||||
setSellerInquiries(data.inquiries)
|
||||
setSellerUnread(data.unread)
|
||||
} catch (err: any) {
|
||||
// Silently fail
|
||||
} finally {
|
||||
setLoadingSeller(false)
|
||||
}
|
||||
}, [isSeller])
|
||||
|
||||
useEffect(() => {
|
||||
loadBuyerThreads()
|
||||
loadSellerInquiries()
|
||||
|
||||
// Poll inbox counts every 30 seconds for badge updates
|
||||
const pollInterval = setInterval(() => {
|
||||
loadBuyerThreads()
|
||||
loadSellerInquiries()
|
||||
}, 30000)
|
||||
|
||||
return () => clearInterval(pollInterval)
|
||||
}, [loadBuyerThreads, loadSellerInquiries])
|
||||
|
||||
// Handle URL parameter for opening specific inquiry
|
||||
const threadsById = useMemo(() => {
|
||||
const map = new Map<number, Thread>()
|
||||
threads.forEach(t => map.set(t.id, t))
|
||||
const map = new Map<number, BuyerThread | SellerInquiry>()
|
||||
buyerThreads.forEach(t => map.set(t.id, t))
|
||||
sellerInquiries.forEach(t => map.set(t.id, t))
|
||||
return map
|
||||
}, [threads])
|
||||
}, [buyerThreads, sellerInquiries])
|
||||
|
||||
useEffect(() => {
|
||||
if (!openInquiryId) return
|
||||
const id = Number(openInquiryId)
|
||||
if (!Number.isFinite(id)) return
|
||||
const t = threadsById.get(id)
|
||||
if (t) setActiveThread(t)
|
||||
if (t) {
|
||||
setActiveThread(t)
|
||||
// Determine which tab
|
||||
if ('buyer_name' in t) {
|
||||
setActiveTab('selling')
|
||||
} else {
|
||||
setActiveTab('buying')
|
||||
}
|
||||
}
|
||||
}, [openInquiryId, threadsById])
|
||||
|
||||
const loadMessages = useCallback(async (thread: Thread) => {
|
||||
// Load messages for thread
|
||||
const loadMessages = useCallback(async (thread: BuyerThread | SellerInquiry) => {
|
||||
setLoadingMessages(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await api.getInquiryMessagesAsBuyer(thread.id)
|
||||
setMessages(data)
|
||||
if ('buyer_name' in thread) {
|
||||
// Seller loading buyer's messages
|
||||
const data = await api.getInquiryMessagesAsSeller(thread.listing_id, thread.id)
|
||||
setMessages(data)
|
||||
} else {
|
||||
// Buyer loading their own messages
|
||||
const data = await api.getInquiryMessagesAsBuyer(thread.id)
|
||||
setMessages(data)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to load messages')
|
||||
} finally {
|
||||
@ -123,8 +207,16 @@ export default function InboxPage() {
|
||||
useEffect(() => {
|
||||
if (!activeThread) return
|
||||
loadMessages(activeThread)
|
||||
|
||||
// Poll for new messages every 15 seconds when thread is open
|
||||
const pollInterval = setInterval(() => {
|
||||
loadMessages(activeThread)
|
||||
}, 15000)
|
||||
|
||||
return () => clearInterval(pollInterval)
|
||||
}, [activeThread, loadMessages])
|
||||
|
||||
// Send message
|
||||
const sendMessage = useCallback(async () => {
|
||||
if (!activeThread) return
|
||||
const body = draft.trim()
|
||||
@ -133,7 +225,14 @@ export default function InboxPage() {
|
||||
setSending(true)
|
||||
setError(null)
|
||||
try {
|
||||
const created = await api.sendInquiryMessageAsBuyer(activeThread.id, body)
|
||||
let created: Message
|
||||
if ('buyer_name' in activeThread) {
|
||||
// Seller replying
|
||||
created = await api.sendInquiryMessageAsSeller(activeThread.listing_id, activeThread.id, body)
|
||||
} else {
|
||||
// Buyer replying
|
||||
created = await api.sendInquiryMessageAsBuyer(activeThread.id, body)
|
||||
}
|
||||
setDraft('')
|
||||
setMessages(prev => [...prev, created])
|
||||
} catch (err: any) {
|
||||
@ -143,6 +242,27 @@ export default function InboxPage() {
|
||||
}
|
||||
}, [activeThread, draft])
|
||||
|
||||
// Refresh on tab change
|
||||
useEffect(() => {
|
||||
setActiveThread(null)
|
||||
setMessages([])
|
||||
setDraft('')
|
||||
if (activeTab === 'buying') {
|
||||
loadBuyerThreads()
|
||||
} else {
|
||||
loadSellerInquiries()
|
||||
}
|
||||
}, [activeTab, loadBuyerThreads, loadSellerInquiries])
|
||||
|
||||
const isLoading = activeTab === 'buying' ? loadingBuyer : loadingSeller
|
||||
const currentItems = activeTab === 'buying' ? buyerThreads : sellerInquiries
|
||||
const emptyMessage = activeTab === 'buying'
|
||||
? 'No inquiries yet. Browse Pounce Direct deals and send an inquiry.'
|
||||
: 'No buyer inquiries yet. List a domain for sale to receive offers.'
|
||||
const emptyAction = activeTab === 'buying'
|
||||
? { href: '/acquire', label: 'Browse Deals' }
|
||||
: { href: '/terminal/listing', label: 'List Domain' }
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020202]">
|
||||
<div className="hidden lg:block"><Sidebar /></div>
|
||||
@ -175,141 +295,306 @@ export default function InboxPage() {
|
||||
<span className="text-white">Inbox</span>
|
||||
</h1>
|
||||
<p className="text-sm text-white/40 font-mono max-w-lg">
|
||||
Your inquiry threads with verified sellers.
|
||||
Manage your domain inquiries and conversations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* TABS - Hunt page style */}
|
||||
<section className="px-4 lg:px-10 pb-4">
|
||||
{/* Desktop Tabs */}
|
||||
<div className="hidden lg:flex gap-2">
|
||||
{TABS.filter(t => t.key === 'buying' || isSeller).map((t) => {
|
||||
const isActive = activeTab === t.key
|
||||
const count = t.key === 'buying' ? buyerThreads.length : sellerUnread
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setActiveTab(t.key)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-4 py-2.5 border transition-all',
|
||||
isActive
|
||||
? t.color === 'accent'
|
||||
? 'border-accent/40 bg-accent/10 text-accent'
|
||||
: 'border-blue-500/40 bg-blue-500/10 text-blue-400'
|
||||
: 'border-white/[0.08] bg-white/[0.02] text-white/50 hover:text-white hover:border-white/20'
|
||||
)}
|
||||
>
|
||||
<t.icon className="w-4 h-4" />
|
||||
<span className="text-xs font-mono uppercase tracking-wider">{t.label}</span>
|
||||
{count > 0 && (
|
||||
<span className={clsx(
|
||||
'min-w-[18px] h-[18px] flex items-center justify-center text-[10px] font-bold rounded-full',
|
||||
isActive
|
||||
? t.color === 'accent' ? 'bg-accent text-black' : 'bg-blue-500 text-white'
|
||||
: 'bg-white/10 text-white/60'
|
||||
)}>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Mobile Tabs - Scrollable */}
|
||||
<div className="lg:hidden -mx-4 px-4 overflow-x-auto">
|
||||
<div className="flex gap-1 min-w-max pb-1">
|
||||
{TABS.filter(t => t.key === 'buying' || isSeller).map((t) => {
|
||||
const isActive = activeTab === t.key
|
||||
const count = t.key === 'buying' ? buyerThreads.length : sellerUnread
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setActiveTab(t.key)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-2 border transition-all shrink-0',
|
||||
isActive
|
||||
? t.color === 'accent'
|
||||
? 'border-accent/40 bg-accent/10 text-accent'
|
||||
: 'border-blue-500/40 bg-blue-500/10 text-blue-400'
|
||||
: 'border-white/[0.08] bg-white/[0.02] text-white/40'
|
||||
)}
|
||||
>
|
||||
<t.icon className="w-4 h-4" />
|
||||
<span className="text-[11px] font-mono uppercase tracking-wide">{t.shortLabel}</span>
|
||||
{count > 0 && (
|
||||
<span className={clsx(
|
||||
'min-w-[16px] h-[16px] flex items-center justify-center text-[9px] font-bold rounded-full',
|
||||
isActive
|
||||
? t.color === 'accent' ? 'bg-accent text-black' : 'bg-blue-500 text-white'
|
||||
: 'bg-white/10 text-white/50'
|
||||
)}>
|
||||
{count > 9 ? '9+' : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CONTENT */}
|
||||
<section className="px-4 lg:px-10 pb-28 lg:pb-10">
|
||||
{loading ? (
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
) : error && !activeThread ? (
|
||||
<div className="p-4 border border-rose-400/20 bg-rose-400/5 text-rose-300 text-sm font-mono">{error}</div>
|
||||
) : threads.length === 0 ? (
|
||||
) : currentItems.length === 0 ? (
|
||||
<div className="text-center py-16 border border-dashed border-white/[0.08]">
|
||||
<MessageSquare className="w-8 h-8 text-white/10 mx-auto mb-3" />
|
||||
<p className="text-white/40 text-sm font-mono">No threads yet</p>
|
||||
<p className="text-white/25 text-xs font-mono mt-1">Browse Pounce Direct deals and send an inquiry.</p>
|
||||
<Link href="/acquire" className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-accent text-black text-xs font-bold uppercase">
|
||||
View Deals
|
||||
<p className="text-white/40 text-sm font-mono">{emptyMessage}</p>
|
||||
<Link href={emptyAction.href} className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-accent text-black text-xs font-bold uppercase">
|
||||
{emptyAction.label}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid lg:grid-cols-[360px_1fr] gap-4">
|
||||
{/* Thread list */}
|
||||
<div className="border border-white/[0.08] bg-white/[0.02]">
|
||||
<div className="px-3 py-2 border-b border-white/[0.08] text-[10px] font-mono text-white/40 uppercase tracking-wider">
|
||||
Threads
|
||||
<div className="grid lg:grid-cols-[400px_1fr] gap-4">
|
||||
{/* Thread/Inquiry list */}
|
||||
<div className="border border-white/[0.08] bg-white/[0.02] max-h-[600px] overflow-auto">
|
||||
<div className="px-3 py-2 border-b border-white/[0.08] text-[10px] font-mono text-white/40 uppercase tracking-wider sticky top-0 bg-[#0A0A0A]">
|
||||
{activeTab === 'buying' ? 'Your Inquiries' : 'Buyer Inquiries'}
|
||||
</div>
|
||||
<div className="divide-y divide-white/[0.06]">
|
||||
{threads.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setActiveThread(t)}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-3 hover:bg-white/[0.03] transition-colors',
|
||||
activeThread?.id === t.id && 'bg-white/[0.03]'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-bold text-white font-mono truncate">{t.domain}</div>
|
||||
<div className="text-[10px] font-mono text-white/30 mt-0.5">
|
||||
{new Date(t.created_at).toLocaleDateString('en-US')}
|
||||
{t.status === 'closed' && t.closed_reason ? ` • closed: ${t.closed_reason}` : ''}
|
||||
{activeTab === 'buying' ? (
|
||||
// Buyer view
|
||||
buyerThreads.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setActiveThread(t)}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-3 hover:bg-white/[0.03] transition-colors',
|
||||
activeThread?.id === t.id && 'bg-white/[0.03]'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-bold text-white font-mono truncate">{t.domain}</div>
|
||||
<div className="text-[10px] font-mono text-white/30 mt-0.5">
|
||||
{new Date(t.created_at).toLocaleDateString('en-US')}
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'px-2 py-1 text-[9px] font-mono uppercase border shrink-0',
|
||||
t.status === 'closed' ? 'bg-white/[0.03] text-white/40 border-white/[0.10]' :
|
||||
t.status === 'spam' ? 'bg-rose-500/10 text-rose-300 border-rose-500/20' :
|
||||
'bg-accent/10 text-accent border-accent/20'
|
||||
)}>
|
||||
{t.status}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
// Seller view
|
||||
sellerInquiries.map(inq => (
|
||||
<button
|
||||
key={inq.id}
|
||||
type="button"
|
||||
onClick={() => setActiveThread(inq)}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-3 hover:bg-white/[0.03] transition-colors',
|
||||
activeThread?.id === inq.id && 'bg-white/[0.03]',
|
||||
inq.has_unread_reply && 'border-l-2 border-accent'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-white font-mono truncate">{inq.domain}</span>
|
||||
{inq.has_unread_reply && (
|
||||
<span className="w-2 h-2 bg-accent rounded-full animate-pulse shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-white/50 mt-0.5 truncate">
|
||||
{inq.buyer_name} • {inq.buyer_email}
|
||||
</div>
|
||||
{inq.offer_amount && (
|
||||
<div className="text-[10px] font-mono text-accent mt-0.5">
|
||||
${inq.offer_amount.toLocaleString()} offer
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] font-mono text-white/30 mt-1 truncate">
|
||||
{inq.last_message_preview}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className={clsx(
|
||||
'px-2 py-1 text-[9px] font-mono uppercase border',
|
||||
inq.status === 'new' ? 'bg-amber-400/10 text-amber-400 border-amber-400/20' :
|
||||
inq.status === 'closed' ? 'bg-white/[0.03] text-white/40 border-white/[0.10]' :
|
||||
inq.status === 'spam' ? 'bg-rose-500/10 text-rose-300 border-rose-500/20' :
|
||||
'bg-accent/10 text-accent border-accent/20'
|
||||
)}>
|
||||
{inq.status}
|
||||
</span>
|
||||
<div className="text-[9px] font-mono text-white/20 mt-1">
|
||||
{new Date(inq.last_message_at).toLocaleDateString('en-US')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'px-2 py-1 text-[9px] font-mono uppercase border',
|
||||
t.status === 'closed' ? 'bg-white/[0.03] text-white/40 border-white/[0.10]' :
|
||||
t.status === 'spam' ? 'bg-rose-500/10 text-rose-300 border-rose-500/20' :
|
||||
'bg-accent/10 text-accent border-accent/20'
|
||||
)}>
|
||||
{t.status}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thread detail */}
|
||||
<div className="border border-white/[0.08] bg-[#020202]">
|
||||
{!activeThread ? (
|
||||
<div className="p-10 text-center text-white/40 font-mono">Select a thread</div>
|
||||
<div className="p-10 text-center text-white/40 font-mono">
|
||||
<MessageSquare className="w-8 h-8 mx-auto mb-3 text-white/10" />
|
||||
Select a conversation
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Thread header */}
|
||||
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Thread</div>
|
||||
<div className="text-sm font-bold text-white font-mono">{activeThread.domain}</div>
|
||||
<div className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">
|
||||
{activeTab === 'buying' ? 'Thread' : 'Inquiry'}
|
||||
</div>
|
||||
<div className="text-sm font-bold text-white font-mono">
|
||||
{'buyer_name' in activeThread ? activeThread.domain : activeThread.domain}
|
||||
</div>
|
||||
{'buyer_name' in activeThread && (
|
||||
<div className="text-[10px] font-mono text-white/40 mt-0.5">
|
||||
From: {activeThread.buyer_name} ({activeThread.buyer_email})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{'buyer_name' in activeThread && (
|
||||
<a
|
||||
href={`mailto:${activeThread.buyer_email}?subject=Re: ${activeThread.domain}`}
|
||||
className="p-2 border border-white/[0.10] bg-white/[0.03] text-white/50 hover:text-white"
|
||||
title="Reply via email"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
<Link
|
||||
href={`/buy/${activeThread.slug}`}
|
||||
className="p-2 border border-white/[0.10] bg-white/[0.03] text-white/50 hover:text-white"
|
||||
title="View listing"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href={`/buy/${activeThread.slug}`}
|
||||
className="px-3 py-2 border border-white/[0.10] bg-white/[0.03] text-white/70 hover:text-white text-[10px] font-mono uppercase"
|
||||
title="View listing"
|
||||
>
|
||||
View Deal
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3 min-h-[280px] max-h-[520px] overflow-auto">
|
||||
{/* Messages */}
|
||||
<div className="p-4 space-y-3 min-h-[280px] max-h-[450px] overflow-auto">
|
||||
{loadingMessages ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-sm font-mono text-white/40">No messages</div>
|
||||
<div className="text-sm font-mono text-white/40">No messages yet</div>
|
||||
) : (
|
||||
messages.map(m => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={clsx(
|
||||
'p-3 border',
|
||||
m.sender_user_id === user?.id
|
||||
? 'bg-accent/10 border-accent/20'
|
||||
: 'bg-white/[0.02] border-white/[0.08]'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-white/40 mb-1">
|
||||
<span>{m.sender_user_id === user?.id ? 'You' : 'Seller'}</span>
|
||||
<span>{new Date(m.created_at).toLocaleString('en-US')}</span>
|
||||
messages.map(m => {
|
||||
const isMe = m.sender_user_id === user?.id
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={clsx(
|
||||
'p-3 border max-w-[85%]',
|
||||
isMe
|
||||
? 'bg-accent/10 border-accent/20 ml-auto'
|
||||
: 'bg-white/[0.02] border-white/[0.08]'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-white/40 mb-1">
|
||||
<span>{isMe ? 'You' : (activeTab === 'buying' ? 'Seller' : 'Buyer')}</span>
|
||||
<span>{new Date(m.created_at).toLocaleString('en-US')}</span>
|
||||
</div>
|
||||
<div className="text-sm text-white/80 whitespace-pre-line">{m.body}</div>
|
||||
</div>
|
||||
<div className="text-sm text-white/80 whitespace-pre-line">{m.body}</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply form */}
|
||||
<div className="p-4 border-t border-white/[0.08]">
|
||||
{activeThread.status === 'closed' || activeThread.status === 'spam' ? (
|
||||
<div className="flex items-center gap-2 text-[11px] font-mono text-white/40">
|
||||
<Lock className="w-4 h-4" /> Thread is closed.
|
||||
<Lock className="w-4 h-4" /> This conversation is closed.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Write a message..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
sendMessage()
|
||||
}
|
||||
}}
|
||||
placeholder="Write a message... (Cmd+Enter to send)"
|
||||
rows={2}
|
||||
className="flex-1 px-3 py-2 bg-[#0A0A0A] border border-white/10 text-white font-mono text-sm placeholder:text-white/20 focus:outline-none focus:border-accent"
|
||||
className="flex-1 px-3 py-2 bg-[#0A0A0A] border border-white/10 text-white font-mono text-sm placeholder:text-white/20 focus:outline-none focus:border-accent resize-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={sendMessage}
|
||||
disabled={sending || !draft.trim()}
|
||||
className="px-4 py-2 bg-accent text-black text-xs font-bold uppercase disabled:opacity-50 flex items-center gap-2"
|
||||
className="px-4 py-2 bg-accent text-black text-xs font-bold uppercase disabled:opacity-50 flex items-center gap-2 self-end"
|
||||
>
|
||||
{sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-2 text-[11px] font-mono text-rose-400">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@ -326,7 +611,7 @@ export default function InboxPage() {
|
||||
<div className="p-4 border-b border-white/[0.08]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Image src="/pounce-icon.png" alt="Pounce" width={24} height={24} />
|
||||
<Image src="/pounce-puma.png" alt="Pounce" width={24} height={24} />
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white">Terminal</div>
|
||||
<div className="text-[10px] font-mono text-white/40">{tierName}</div>
|
||||
@ -337,44 +622,19 @@ export default function InboxPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 space-y-6">
|
||||
{drawerNavSections.map(section => (
|
||||
<div key={section.title}>
|
||||
<div className="text-[10px] font-mono text-white/30 uppercase tracking-wider mb-2">{section.title}</div>
|
||||
<div className="space-y-1">
|
||||
{section.items.map(item => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 px-3 py-2.5 border border-white/[0.08] bg-white/[0.02] text-white/70 hover:text-white',
|
||||
(item as any).active && 'border-accent/20 bg-accent/5 text-accent'
|
||||
)}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<item.icon className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="space-y-2 pt-4 border-t border-white/[0.08]">
|
||||
<Link
|
||||
href="/terminal/settings"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-white/70 hover:text-white transition-colors"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Settings</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => { logout(); setMenuOpen(false) }}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-rose-400/60 hover:text-rose-400 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Logout</span>
|
||||
<div className="p-4 space-y-4">
|
||||
<Link href="/terminal/hunt" className="flex items-center gap-3 px-3 py-2.5 border border-white/[0.08] text-white/70" onClick={() => setMenuOpen(false)}>
|
||||
<Target className="w-4 h-4" /> Hunt
|
||||
</Link>
|
||||
<Link href="/terminal/watchlist" className="flex items-center gap-3 px-3 py-2.5 border border-white/[0.08] text-white/70" onClick={() => setMenuOpen(false)}>
|
||||
<Eye className="w-4 h-4" /> Watchlist
|
||||
</Link>
|
||||
<Link href="/terminal/listing" className="flex items-center gap-3 px-3 py-2.5 border border-white/[0.08] text-white/70" onClick={() => setMenuOpen(false)}>
|
||||
<Tag className="w-4 h-4" /> For Sale
|
||||
</Link>
|
||||
<div className="pt-4 border-t border-white/[0.08]">
|
||||
<button onClick={() => { logout(); setMenuOpen(false) }} className="flex items-center gap-3 px-3 py-2.5 text-rose-400/60">
|
||||
<LogOut className="w-4 h-4" /> Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -75,11 +75,12 @@ export default function MyListingsPage() {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const tier = subscription?.tier || 'scout'
|
||||
const isScout = tier === 'scout'
|
||||
const listingLimits: Record<string, number> = { scout: 0, trader: 5, tycoon: 50 }
|
||||
const listingLimits: Record<string, number> = { scout: 0, trader: 10, tycoon: Infinity }
|
||||
const maxListings = listingLimits[tier] || 0
|
||||
const canAddMore = listings.length < maxListings && !isScout
|
||||
const canAddMore = listings.length < maxListings
|
||||
const isTycoon = tier === 'tycoon'
|
||||
const isUnlimited = tier === 'tycoon'
|
||||
const formatLimit = (limit: number) => limit === Infinity ? '∞' : String(limit)
|
||||
|
||||
const activeListings = listings.filter(l => l.status === 'active').length
|
||||
const draftListings = listings.filter(l => l.status === 'draft').length
|
||||
@ -87,20 +88,16 @@ export default function MyListingsPage() {
|
||||
const totalInquiries = listings.reduce((sum, l) => sum + l.inquiry_count, 0)
|
||||
|
||||
useEffect(() => { checkAuth() }, [checkAuth])
|
||||
useEffect(() => { if (prefillDomain && !isScout) setShowCreateWizard(true) }, [prefillDomain, isScout])
|
||||
useEffect(() => { if (prefillDomain) setShowCreateWizard(true) }, [prefillDomain])
|
||||
|
||||
const loadListings = useCallback(async () => {
|
||||
if (isScout) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.getMyListings()
|
||||
setListings(data)
|
||||
} catch (err) { console.error(err) }
|
||||
finally { setLoading(false) }
|
||||
}, [isScout])
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadListings() }, [loadListings])
|
||||
|
||||
@ -153,68 +150,7 @@ export default function MyListingsPage() {
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// SCOUT UPGRADE PROMPT (Feature not available for free tier)
|
||||
// ============================================================================
|
||||
if (isScout) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020202]">
|
||||
<div className="hidden lg:block"><Sidebar /></div>
|
||||
<main className="lg:pl-[240px]">
|
||||
<div className="min-h-screen flex items-center justify-center p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="w-20 h-20 bg-amber-400/10 border border-amber-400/20 flex items-center justify-center mx-auto mb-6">
|
||||
<Lock className="w-10 h-10 text-amber-400" />
|
||||
</div>
|
||||
<h1 className="font-display text-3xl text-white mb-4">For Sale</h1>
|
||||
<p className="text-white/50 text-sm font-mono mb-2">
|
||||
List domains on Pounce Direct.
|
||||
</p>
|
||||
<p className="text-white/30 text-xs font-mono mb-8">
|
||||
0% commission. DNS-verified ownership. Direct buyer contact.
|
||||
</p>
|
||||
|
||||
<div className="bg-white/[0.02] border border-white/[0.08] p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4 pb-4 border-b border-white/[0.08]">
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="w-5 h-5 text-accent" />
|
||||
<span className="text-sm font-bold text-white">Trader</span>
|
||||
</div>
|
||||
<span className="text-accent font-mono text-sm">$9/mo</span>
|
||||
</div>
|
||||
<ul className="text-left space-y-2 text-sm text-white/60 mb-4">
|
||||
<li className="flex items-center gap-2"><CheckCircle className="w-4 h-4 text-accent" />5 Active Listings</li>
|
||||
<li className="flex items-center gap-2"><CheckCircle className="w-4 h-4 text-accent" />DNS Ownership Verification</li>
|
||||
<li className="flex items-center gap-2"><CheckCircle className="w-4 h-4 text-accent" />Direct Buyer Contact</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.02] border border-amber-400/20 p-6 mb-8">
|
||||
<div className="flex items-center justify-between mb-4 pb-4 border-b border-white/[0.08]">
|
||||
<div className="flex items-center gap-3">
|
||||
<Crown className="w-5 h-5 text-amber-400" />
|
||||
<span className="text-sm font-bold text-white">Tycoon</span>
|
||||
</div>
|
||||
<span className="text-amber-400 font-mono text-sm">$29/mo</span>
|
||||
</div>
|
||||
<ul className="text-left space-y-2 text-sm text-white/60">
|
||||
<li className="flex items-center gap-2"><CheckCircle className="w-4 h-4 text-amber-400" />50 Active Listings</li>
|
||||
<li className="flex items-center gap-2"><CheckCircle className="w-4 h-4 text-amber-400" />Featured Placement</li>
|
||||
<li className="flex items-center gap-2"><CheckCircle className="w-4 h-4 text-amber-400" />Priority in Market Feed</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Link href="/pricing" className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white transition-colors">
|
||||
<Sparkles className="w-4 h-4" />Upgrade
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN LISTING VIEW (For Trader & Tycoon)
|
||||
// MAIN LISTING VIEW (All tiers - Scout has 1 listing, Trader 10, Tycoon unlimited)
|
||||
// ============================================================================
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020202]">
|
||||
@ -229,7 +165,7 @@ export default function MyListingsPage() {
|
||||
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
||||
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">For Sale</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-white/40">{listings.length}/{maxListings}</span>
|
||||
<span className="text-[10px] font-mono text-white/40">{listings.length}/{formatLimit(maxListings)}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="bg-accent/[0.05] border border-accent/20 p-2">
|
||||
@ -262,7 +198,7 @@ export default function MyListingsPage() {
|
||||
</div>
|
||||
<h1 className="font-display text-[2.5rem] leading-[1] tracking-[-0.02em]">
|
||||
<span className="text-white">For Sale</span>
|
||||
<span className="text-white/30 ml-3 font-mono text-[2rem]">{listings.length}/{maxListings}</span>
|
||||
<span className="text-white/30 ml-3 font-mono text-[2rem]">{listings.length}/{formatLimit(maxListings)}</span>
|
||||
</h1>
|
||||
<p className="text-sm text-white/40 font-mono mt-2 max-w-lg">
|
||||
List your domains for sale. 0% commission, verified ownership, direct buyer contact.
|
||||
@ -312,26 +248,30 @@ export default function MyListingsPage() {
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
) : listings.length === 0 ? (
|
||||
<div className="text-center py-16 border border-dashed border-white/[0.08]">
|
||||
<Tag className="w-8 h-8 text-white/10 mx-auto mb-3" />
|
||||
<p className="text-white/40 text-sm font-mono mb-2">No listings yet</p>
|
||||
<p className="text-white/25 text-xs font-mono mb-4">Create your first listing to start selling</p>
|
||||
<button onClick={() => setShowCreateWizard(true)} className="inline-flex items-center gap-2 px-4 py-2 bg-accent text-black text-xs font-bold uppercase">
|
||||
<div className="text-center py-24 border border-dashed border-white/[0.08] bg-white/[0.01]">
|
||||
<Tag className="w-16 h-16 text-white/5 mx-auto mb-6" />
|
||||
<p className="text-white/50 text-sm font-mono uppercase tracking-widest font-bold">No listings yet</p>
|
||||
<p className="text-white/20 text-xs font-mono mt-3 uppercase tracking-wider max-w-sm mx-auto leading-relaxed">
|
||||
Create your first listing to start selling domains
|
||||
</p>
|
||||
<button onClick={() => setShowCreateWizard(true)} className="mt-6 inline-flex items-center gap-2 px-5 py-3 bg-accent text-black text-xs font-black uppercase tracking-widest hover:bg-white transition-colors">
|
||||
<Plus className="w-4 h-4" />Create Listing
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
|
||||
<div className="border border-white/[0.08] bg-[#020202] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_100px_100px_60px_60px_120px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
|
||||
<div className="hidden lg:grid grid-cols-[1fr_100px_90px_70px_70px_140px] gap-4 px-5 py-3 text-[10px] font-mono text-white/40 uppercase tracking-[0.12em] border-b border-white/[0.08] bg-white/[0.02]">
|
||||
<div>Domain</div>
|
||||
<div className="text-right">Price</div>
|
||||
<div className="text-center">Status</div>
|
||||
<div className="text-right">Views</div>
|
||||
<div className="text-right">Leads</div>
|
||||
<div className="text-center">Views</div>
|
||||
<div className="text-center">Leads</div>
|
||||
<div className="text-right">Actions</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body */}
|
||||
<div className="divide-y divide-white/[0.04]">
|
||||
{listings.map((listing) => (
|
||||
<ListingRow
|
||||
key={listing.id}
|
||||
@ -345,6 +285,7 @@ export default function MyListingsPage() {
|
||||
isDeleting={deletingId === listing.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -458,31 +399,26 @@ function ListingRow({
|
||||
const needsVerification = !listing.is_verified
|
||||
|
||||
return (
|
||||
<div className="bg-[#020202] hover:bg-white/[0.02] transition-all group">
|
||||
{/* Mobile */}
|
||||
<div className="lg:hidden p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx("w-8 h-8 border flex items-center justify-center",
|
||||
listing.is_verified ? "bg-accent/10 border-accent/20" : "bg-white/[0.02] border-white/[0.06]"
|
||||
)}>
|
||||
{listing.is_verified ? <Shield className="w-4 h-4 text-accent" /> : <AlertCircle className="w-4 h-4 text-amber-400" />}
|
||||
</div>
|
||||
<div className="group transition-all">
|
||||
{/* Mobile */}
|
||||
<div className="lg:hidden p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<span className="text-sm font-bold text-white font-mono">{listing.domain}</span>
|
||||
<span className="text-sm font-bold text-white font-mono">{listing.domain}</span>
|
||||
{isTycoon && <span className="ml-2 px-1 py-0.5 text-[8px] font-mono bg-amber-400/10 text-amber-400 border border-amber-400/20">Featured</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx("px-1.5 py-0.5 text-[9px] font-mono border",
|
||||
</div>
|
||||
<span className={clsx("px-1.5 py-0.5 text-[9px] font-mono border",
|
||||
isActive ? "bg-accent/10 text-accent border-accent/20" :
|
||||
isDraft ? "bg-amber-400/10 text-amber-400 border-amber-400/20" :
|
||||
"bg-white/5 text-white/40 border-white/10"
|
||||
)}>{listing.status}</span>
|
||||
</div>
|
||||
)}>{listing.status}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono text-white/40 mb-2">
|
||||
<span>${listing.asking_price?.toLocaleString() || 'Make Offer'}</span>
|
||||
<span>{listing.view_count} views · {listing.inquiry_count} leads</span>
|
||||
</div>
|
||||
<span>{listing.view_count} views · {listing.inquiry_count} leads</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isDraft && needsVerification && (
|
||||
<button onClick={onVerify} className="flex-1 py-2 bg-amber-400/10 border border-amber-400/20 text-amber-400 text-[10px] font-mono uppercase flex items-center justify-center gap-1">
|
||||
@ -495,9 +431,9 @@ function ListingRow({
|
||||
</button>
|
||||
)}
|
||||
{isActive && (
|
||||
<a href={listing.public_url} target="_blank" className="flex-1 py-2 border border-white/[0.08] text-[10px] font-mono text-white/40 flex items-center justify-center gap-1">
|
||||
<ExternalLink className="w-3 h-3" />View
|
||||
</a>
|
||||
<a href={listing.public_url} target="_blank" className="flex-1 py-2 border border-white/[0.08] text-[10px] font-mono text-white/40 flex items-center justify-center gap-1">
|
||||
<ExternalLink className="w-3 h-3" />View
|
||||
</a>
|
||||
)}
|
||||
{isActive && (
|
||||
<button
|
||||
@ -520,57 +456,52 @@ function ListingRow({
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onDelete} disabled={isDeleting}
|
||||
className="px-4 py-2 border border-white/[0.08] text-white/40 hover:text-rose-400">
|
||||
className="px-4 py-2 border border-white/[0.08] text-white/40 hover:text-rose-400">
|
||||
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_100px_100px_60px_60px_120px] gap-4 items-center px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx("w-8 h-8 border flex items-center justify-center",
|
||||
listing.is_verified ? "bg-accent/10 border-accent/20" : "bg-white/[0.02] border-white/[0.06]"
|
||||
)}>
|
||||
{listing.is_verified ? <Shield className="w-4 h-4 text-accent" /> : <AlertCircle className="w-4 h-4 text-amber-400" />}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-bold text-white font-mono group-hover:text-accent transition-colors">{listing.domain}</span>
|
||||
{isTycoon && <span className="ml-2 px-1 py-0.5 text-[8px] font-mono bg-amber-400/10 text-amber-400 border border-amber-400/20">Featured</span>}
|
||||
{!listing.is_verified && <span className="ml-2 text-[9px] text-amber-400/60 font-mono">Unverified</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm font-bold font-mono text-accent">${listing.asking_price?.toLocaleString() || '—'}</div>
|
||||
<div className="flex justify-center">
|
||||
<span className={clsx("px-2 py-1 text-[9px] font-mono border",
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_100px_90px_70px_70px_140px] gap-4 items-center px-5 py-3 group-hover:bg-white/[0.02]">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors">{listing.domain}</span>
|
||||
<div className="flex items-center gap-2 text-[9px] font-mono text-white/20 uppercase tracking-wider opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{isTycoon && <span className="px-1 py-0.5 bg-amber-400/10 text-amber-400 border border-amber-400/20">Featured</span>}
|
||||
{!listing.is_verified && <span className="text-amber-400/60">Unverified</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm font-bold font-mono text-accent">${listing.asking_price?.toLocaleString() || '—'}</div>
|
||||
<div className="flex justify-center">
|
||||
<span className={clsx("px-2.5 py-1.5 text-[10px] font-mono font-bold uppercase border",
|
||||
isActive ? "bg-accent/10 text-accent border-accent/20" :
|
||||
isDraft ? "bg-amber-400/10 text-amber-400 border-amber-400/20" :
|
||||
"bg-white/5 text-white/40 border-white/10"
|
||||
)}>{listing.status}</span>
|
||||
</div>
|
||||
<div className="text-right text-xs font-mono text-white/60">{listing.view_count}</div>
|
||||
<div className="text-right text-xs font-mono text-white/60">{listing.inquiry_count}</div>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
)}>{listing.status}</span>
|
||||
</div>
|
||||
<div className="text-center text-sm font-mono text-white/50">{listing.view_count}</div>
|
||||
<div className="text-center text-sm font-mono text-white/50">{listing.inquiry_count}</div>
|
||||
<div className="flex items-center justify-end gap-1 opacity-40 group-hover:opacity-100 transition-all">
|
||||
{isDraft && needsVerification && (
|
||||
<button onClick={onVerify} className="px-2 py-1 bg-amber-400/10 border border-amber-400/20 text-amber-400 text-[9px] font-mono uppercase hover:bg-amber-400/20 transition-colors">
|
||||
<button onClick={onVerify} className="h-8 px-3 bg-amber-400/10 border border-amber-400/20 text-amber-400 text-[9px] font-mono uppercase hover:bg-amber-400/20 transition-colors">
|
||||
Verify
|
||||
</button>
|
||||
)}
|
||||
{isDraft && !needsVerification && (
|
||||
<button onClick={onPublish} className="px-2 py-1 bg-accent text-black text-[9px] font-bold uppercase hover:bg-white transition-colors">
|
||||
<button onClick={onPublish} className="h-8 px-3 bg-accent text-black text-[9px] font-bold uppercase hover:bg-white transition-colors">
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
{isActive && (
|
||||
<a href={listing.public_url} target="_blank" className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent">
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<a href={listing.public_url} target="_blank" className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-accent hover:border-accent/20 hover:bg-accent/10 transition-all">
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
{isActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMarkSold}
|
||||
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent"
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-accent hover:border-accent/20 hover:bg-accent/10 transition-all"
|
||||
title="Mark as sold"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
@ -580,19 +511,19 @@ function ListingRow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLeads}
|
||||
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent"
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-accent hover:border-accent/20 hover:bg-accent/10 transition-all"
|
||||
title="View buyer inquiries"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onDelete} disabled={isDeleting}
|
||||
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-rose-400">
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-rose-400 hover:border-rose-400/20 hover:bg-rose-500/10 transition-all">
|
||||
{isDeleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -637,7 +568,7 @@ function MarkSoldModal({ listing, onClose, onDone }: { listing: Listing; onClose
|
||||
<div className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Mark Sold</div>
|
||||
<h3 className="mt-1 text-lg font-display text-white">{listing.domain}</h3>
|
||||
<p className="mt-1 text-xs font-mono text-white/40">Close the deal and capture GMV (optional).</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="p-1 text-white/40 hover:text-white" aria-label="Close">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@ -658,7 +589,7 @@ function MarkSoldModal({ listing, onClose, onDone }: { listing: Listing; onClose
|
||||
<option value="removed">Removed</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-mono text-white/40 uppercase mb-2">Deal Value (optional)</label>
|
||||
@ -877,7 +808,7 @@ function LeadsModal({ listing, onClose }: { listing: Listing; onClose: () => voi
|
||||
>
|
||||
{updatingId === inq.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
|
||||
Read
|
||||
</button>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
@ -918,7 +849,7 @@ function LeadsModal({ listing, onClose }: { listing: Listing; onClose: () => voi
|
||||
Email
|
||||
<Mail className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{closingId === inq.id && (
|
||||
<div className="mt-3 p-3 border border-white/[0.10] bg-white/[0.02]">
|
||||
@ -955,14 +886,14 @@ function LeadsModal({ listing, onClose }: { listing: Listing; onClose: () => voi
|
||||
>
|
||||
{updatingId === inq.id ? 'Closing…' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* THREAD MODAL (nested) */}
|
||||
@ -989,7 +920,7 @@ function LeadsModal({ listing, onClose }: { listing: Listing; onClose: () => voi
|
||||
{loadingThread ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
) : threadMessages.length === 0 ? (
|
||||
<div className="text-sm font-mono text-white/40">No messages yet.</div>
|
||||
) : (
|
||||
@ -1004,7 +935,7 @@ function LeadsModal({ listing, onClose }: { listing: Listing; onClose: () => voi
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-white/40 mb-1">
|
||||
<span>{m.sender_user_id === user?.id ? 'You' : 'Buyer'}</span>
|
||||
<span>{new Date(m.created_at).toLocaleString('en-US')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-white/80 whitespace-pre-line">{m.body}</div>
|
||||
</div>
|
||||
))
|
||||
@ -1162,7 +1093,7 @@ function CreateListingWizard({ onClose, onSuccess, prefillDomain }: {
|
||||
<div className="flex items-center gap-3">
|
||||
<Tag className="w-4 h-4 text-accent" />
|
||||
<span className="text-xs font-mono text-accent uppercase tracking-wider">New Listing</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Step Indicators */}
|
||||
<div className="flex items-center gap-2">
|
||||
@ -1199,12 +1130,12 @@ function CreateListingWizard({ onClose, onSuccess, prefillDomain }: {
|
||||
<p className="text-xs font-mono text-white/40">Step 1 of 3: Set your domain and price</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-mono text-white/40 uppercase mb-2">Select Domain from Portfolio *</label>
|
||||
{loadingDomains ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="w-5 h-5 text-accent animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
) : portfolioDomains.length === 0 ? (
|
||||
<div className="p-4 bg-amber-400/5 border border-amber-400/20 text-center">
|
||||
<AlertCircle className="w-6 h-6 text-amber-400 mx-auto mb-2" />
|
||||
@ -1230,7 +1161,7 @@ function CreateListingWizard({ onClose, onSuccess, prefillDomain }: {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-mono text-white/40 uppercase mb-2">Price Type</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
@ -1249,7 +1180,7 @@ function CreateListingWizard({ onClose, onSuccess, prefillDomain }: {
|
||||
)}>
|
||||
Make Offer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{priceType === 'fixed' && (
|
||||
@ -1272,8 +1203,8 @@ function CreateListingWizard({ onClose, onSuccess, prefillDomain }: {
|
||||
className="w-full py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-2 disabled:opacity-50 hover:bg-white transition-colors"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <>Next: Verify Ownership <ArrowRight className="w-4 h-4" /></>}
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 2: DNS Verification */}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -82,8 +82,9 @@ export default function SniperAlertsPage() {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const tier = subscription?.tier || 'scout'
|
||||
const alertLimits: Record<string, number> = { scout: 2, trader: 10, tycoon: 50 }
|
||||
const maxAlerts = alertLimits[tier] || 2
|
||||
const alertLimits: Record<string, number> = { scout: 0, trader: 10, tycoon: 50 }
|
||||
const maxAlerts = alertLimits[tier] || 0
|
||||
// Limits match backend TIER_CONFIG.sniper_limit
|
||||
const canAddMore = alerts.length < maxAlerts
|
||||
|
||||
const activeAlerts = alerts.filter(a => a.is_active).length
|
||||
|
||||
@ -42,6 +42,7 @@ import {
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { daysUntil, formatCountdown } from '@/lib/time'
|
||||
|
||||
// ============================================================================
|
||||
// ADD MODAL COMPONENT (like Portfolio)
|
||||
@ -119,14 +120,6 @@ function AddModal({
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
function getDaysUntilExpiry(expirationDate: string | null): number | null {
|
||||
if (!expirationDate) return null
|
||||
const expDate = new Date(expirationDate)
|
||||
const now = new Date()
|
||||
const diffTime = expDate.getTime() - now.getTime()
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
function formatExpiryDate(expirationDate: string | null): string {
|
||||
if (!expirationDate) return '—'
|
||||
return new Date(expirationDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
@ -147,17 +140,36 @@ const healthConfig: Record<HealthStatus, { label: string; color: string; bg: str
|
||||
export default function WatchlistPage() {
|
||||
const { domains, addDomain, deleteDomain, refreshDomain, updateDomain, subscription, user, logout, checkAuth } = useStore()
|
||||
const { toast, showToast, hideToast } = useToast()
|
||||
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
||||
const openAnalyzePanel = useAnalyzePanelStore((s) => s.open)
|
||||
|
||||
// Wrapper to open analyze panel with domain status
|
||||
const openAnalyze = useCallback((domainData: { name: string; status: string; is_available: boolean; expiration_date: string | null; deletion_date?: string | null }) => {
|
||||
// Map domain status to drop status format
|
||||
const statusMap: Record<string, 'available' | 'dropping_soon' | 'taken' | 'unknown'> = {
|
||||
'available': 'available',
|
||||
'dropping_soon': 'dropping_soon',
|
||||
'taken': 'taken',
|
||||
'error': 'unknown',
|
||||
'unknown': 'unknown',
|
||||
}
|
||||
openAnalyzePanel(domainData.name, {
|
||||
status: statusMap[domainData.status] || (domainData.is_available ? 'available' : 'taken'),
|
||||
deletion_date: domainData.deletion_date || null,
|
||||
is_drop: false,
|
||||
})
|
||||
}, [openAnalyzePanel])
|
||||
|
||||
// Modal state
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
||||
const [refreshingAll, setRefreshingAll] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [togglingNotifyId, setTogglingNotifyId] = useState<number | null>(null)
|
||||
const [healthReports, setHealthReports] = useState<Record<number, DomainHealthReport>>({})
|
||||
const [loadingHealth, setLoadingHealth] = useState<Record<number, boolean>>({})
|
||||
const [selectedDomain, setSelectedDomain] = useState<number | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | 'available' | 'expiring'>('all')
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null)
|
||||
|
||||
// Sorting
|
||||
const [sortField, setSortField] = useState<'domain' | 'status' | 'health' | 'expiry'>('domain')
|
||||
@ -172,11 +184,17 @@ export default function WatchlistPage() {
|
||||
}, [checkAuth])
|
||||
|
||||
// Stats
|
||||
// Tier limits
|
||||
const tier = subscription?.tier || 'scout'
|
||||
const watchlistLimits: Record<string, number> = { scout: 5, trader: 50, tycoon: Infinity }
|
||||
const maxWatchlist = watchlistLimits[tier] || 5
|
||||
const formatLimit = (limit: number) => limit === Infinity ? '∞' : String(limit)
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const available = domains?.filter(d => d.is_available) || []
|
||||
const expiringSoon = domains?.filter(d => {
|
||||
if (d.is_available || !d.expiration_date) return false
|
||||
const days = getDaysUntilExpiry(d.expiration_date)
|
||||
const days = daysUntil(d.expiration_date)
|
||||
return days !== null && days <= 30 && days > 0
|
||||
}) || []
|
||||
return { total: domains?.length || 0, available: available.length, expiring: expiringSoon.length }
|
||||
@ -188,7 +206,7 @@ export default function WatchlistPage() {
|
||||
let filtered = domains.filter(d => {
|
||||
if (filter === 'available') return d.is_available
|
||||
if (filter === 'expiring') {
|
||||
const days = getDaysUntilExpiry(d.expiration_date)
|
||||
const days = daysUntil(d.expiration_date)
|
||||
return days !== null && days <= 30 && days > 0
|
||||
}
|
||||
return true
|
||||
@ -262,10 +280,67 @@ export default function WatchlistPage() {
|
||||
try {
|
||||
await refreshDomain(id)
|
||||
showToast('Intel updated', 'success')
|
||||
setLastRefreshTime(new Date())
|
||||
} catch { showToast('Update failed', 'error') }
|
||||
finally { setRefreshingId(null) }
|
||||
}, [refreshDomain, showToast])
|
||||
|
||||
// Refresh All Domains
|
||||
const handleRefreshAll = useCallback(async () => {
|
||||
if (refreshingAll) return
|
||||
setRefreshingAll(true)
|
||||
try {
|
||||
const result = await api.refreshAllDomains()
|
||||
|
||||
// Refresh the domain list to get updated data
|
||||
await checkAuth()
|
||||
|
||||
// Show appropriate message based on changes
|
||||
if (result.changes.length > 0) {
|
||||
const takenCount = result.changes.filter(c => c.change === 'became_taken').length
|
||||
const availableCount = result.changes.filter(c => c.change === 'became_available').length
|
||||
|
||||
if (takenCount > 0 && availableCount > 0) {
|
||||
showToast(`${result.checked} domains checked. ${availableCount} became available, ${takenCount} were taken!`, 'success')
|
||||
} else if (takenCount > 0) {
|
||||
showToast(`${result.checked} domains checked. ${takenCount} domain(s) were registered!`, 'warning')
|
||||
} else if (availableCount > 0) {
|
||||
showToast(`${result.checked} domains checked. ${availableCount} domain(s) are now available!`, 'success')
|
||||
}
|
||||
} else {
|
||||
showToast(`All ${result.checked} domains checked. No status changes.`, 'success')
|
||||
}
|
||||
|
||||
setLastRefreshTime(new Date())
|
||||
} catch (err: any) {
|
||||
showToast(err.message || 'Refresh failed', 'error')
|
||||
} finally {
|
||||
setRefreshingAll(false)
|
||||
}
|
||||
}, [refreshingAll, checkAuth, showToast])
|
||||
|
||||
// Auto-refresh available domains every 2 minutes for Tycoon users
|
||||
useEffect(() => {
|
||||
const hasAvailableDomains = domains?.some(d => d.is_available)
|
||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||
const isTycoon = tierName.toLowerCase() === 'tycoon'
|
||||
|
||||
// Only auto-refresh if user is Tycoon and has available domains
|
||||
if (!isTycoon || !hasAvailableDomains) return
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
// Silently refresh all available domains to catch status changes
|
||||
try {
|
||||
await api.refreshAllDomains()
|
||||
await checkAuth() // Refresh domain list
|
||||
} catch {
|
||||
// Silent fail - don't show error for background refresh
|
||||
}
|
||||
}, 2 * 60 * 1000) // 2 minutes
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [domains, subscription, checkAuth])
|
||||
|
||||
const handleDelete = useCallback(async (id: number, name: string) => {
|
||||
if (!confirm(`Drop target: ${name}?`)) return
|
||||
setDeletingId(id)
|
||||
@ -372,19 +447,28 @@ export default function WatchlistPage() {
|
||||
<Eye className="w-4 h-4 text-accent" />
|
||||
<span className="text-sm font-mono text-white font-bold">Watchlist</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent text-black text-[10px] font-bold uppercase"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRefreshAll}
|
||||
disabled={refreshingAll || !stats.total}
|
||||
className="flex items-center gap-1 px-2 py-1.5 border border-white/10 text-white/60 text-[10px] font-bold uppercase disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={clsx("w-3 h-3", refreshingAll && "animate-spin")} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent text-black text-[10px] font-bold uppercase"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="bg-white/[0.02] border border-white/[0.08] p-2 text-center">
|
||||
<div className="text-lg font-bold text-white tabular-nums">{stats.total}</div>
|
||||
<div className="text-lg font-bold text-white tabular-nums">{stats.total}<span className="text-white/30">/{formatLimit(maxWatchlist)}</span></div>
|
||||
<div className="text-[8px] font-mono text-white/30 uppercase">Tracked</div>
|
||||
</div>
|
||||
<div className="bg-accent/[0.05] border border-accent/20 p-2 text-center">
|
||||
@ -420,7 +504,7 @@ export default function WatchlistPage() {
|
||||
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-white font-mono">{stats.total}</div>
|
||||
<div className="text-2xl font-bold text-white font-mono">{stats.total}<span className="text-white/30">/{formatLimit(maxWatchlist)}</span></div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Tracked</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@ -431,7 +515,16 @@ export default function WatchlistPage() {
|
||||
<div className="text-2xl font-bold text-orange-400 font-mono">{stats.expiring}</div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Expiring</div>
|
||||
</div>
|
||||
<div className="pl-6 border-l border-white/10">
|
||||
<div className="pl-6 border-l border-white/10 flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleRefreshAll}
|
||||
disabled={refreshingAll || !stats.total}
|
||||
className="flex items-center gap-2 px-4 py-3 border border-white/10 text-white/60 text-xs font-bold uppercase tracking-wider hover:bg-white/5 hover:text-white transition-colors disabled:opacity-50"
|
||||
title={lastRefreshTime ? `Last refresh: ${lastRefreshTime.toLocaleTimeString()}` : 'Refresh all domains'}
|
||||
>
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshingAll && "animate-spin")} />
|
||||
{refreshingAll ? 'Checking...' : 'Refresh All'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="flex items-center gap-2 px-5 py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white transition-colors"
|
||||
@ -475,40 +568,56 @@ export default function WatchlistPage() {
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
<section className="px-4 lg:px-10 py-4 pb-28 lg:pb-10">
|
||||
{!filteredDomains.length ? (
|
||||
<div className="text-center py-16 border border-dashed border-white/[0.08]">
|
||||
<Eye className="w-8 h-8 text-white/10 mx-auto mb-3" />
|
||||
<p className="text-white/40 text-sm font-mono">No domains in your watchlist</p>
|
||||
<p className="text-white/25 text-xs font-mono mt-1">Add a domain above to start monitoring</p>
|
||||
<div className="text-center py-24 border border-dashed border-white/[0.08] bg-white/[0.01]">
|
||||
<Eye className="w-16 h-16 text-white/5 mx-auto mb-6" />
|
||||
<p className="text-white/50 text-sm font-mono uppercase tracking-widest font-bold">No domains in watchlist</p>
|
||||
<p className="text-white/20 text-xs font-mono mt-3 uppercase tracking-wider max-w-sm mx-auto leading-relaxed">
|
||||
Add a domain to start monitoring availability and expiry
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-px">
|
||||
<div className="border border-white/[0.08] bg-[#020202] overflow-hidden">
|
||||
{/* Desktop Table Header */}
|
||||
<div className="hidden lg:grid grid-cols-[1.5fr_100px_100px_100px_80px_160px] gap-4 px-4 py-2.5 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08] bg-white/[0.02]">
|
||||
<button onClick={() => handleSortWatch('domain')} className="flex items-center gap-1 hover:text-white/60 text-left">
|
||||
Domain
|
||||
{sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_100px_100px_100px_80px_180px] gap-6 px-6 py-4 text-[10px] font-mono text-white/40 uppercase tracking-[0.15em] border-b border-white/[0.08] bg-white/[0.02]">
|
||||
<button onClick={() => handleSortWatch('domain')} className="flex items-center gap-2 hover:text-white transition-colors text-left">
|
||||
<span className={clsx(sortField === 'domain' && "text-accent font-bold")}>Domain</span>
|
||||
{sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3 text-accent" /> : <ChevronDown className="w-3 h-3 text-accent" />)}
|
||||
</button>
|
||||
<button onClick={() => handleSortWatch('status')} className="flex items-center gap-1 justify-center hover:text-white/60">
|
||||
Status
|
||||
{sortField === 'status' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
<button onClick={() => handleSortWatch('status')} className="flex items-center gap-2 justify-center hover:text-white transition-colors">
|
||||
<span className={clsx(sortField === 'status' && "text-accent font-bold")}>Status</span>
|
||||
{sortField === 'status' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3 text-accent" /> : <ChevronDown className="w-3 h-3 text-accent" />)}
|
||||
</button>
|
||||
<button onClick={() => handleSortWatch('health')} className="flex items-center gap-1 justify-center hover:text-white/60">
|
||||
Health
|
||||
{sortField === 'health' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
<button onClick={() => handleSortWatch('health')} className="flex items-center gap-2 justify-center hover:text-white transition-colors">
|
||||
<span className={clsx(sortField === 'health' && "text-accent font-bold")}>Health</span>
|
||||
{sortField === 'health' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3 text-accent" /> : <ChevronDown className="w-3 h-3 text-accent" />)}
|
||||
</button>
|
||||
<button onClick={() => handleSortWatch('expiry')} className="flex items-center gap-1 justify-center hover:text-white/60">
|
||||
Expiry
|
||||
{sortField === 'expiry' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
<button onClick={() => handleSortWatch('expiry')} className="flex items-center gap-2 justify-center hover:text-white transition-colors">
|
||||
<span className={clsx(sortField === 'expiry' && "text-accent font-bold")}>Expiry</span>
|
||||
{sortField === 'expiry' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3 text-accent" /> : <ChevronDown className="w-3 h-3 text-accent" />)}
|
||||
</button>
|
||||
<div className="text-center">Alert</div>
|
||||
<div className="text-right">Actions</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body */}
|
||||
<div className="divide-y divide-white/[0.04]">
|
||||
|
||||
{filteredDomains.map((domain) => {
|
||||
const health = healthReports[domain.id]
|
||||
const healthStatus = health?.status || 'unknown'
|
||||
const config = healthConfig[healthStatus]
|
||||
const days = getDaysUntilExpiry(domain.expiration_date)
|
||||
const days = daysUntil(domain.expiration_date)
|
||||
|
||||
// Domain status display config (consistent with DropsTab)
|
||||
const domainStatus = domain.status || (domain.is_available ? 'available' : 'taken')
|
||||
const transitionCountdown = domainStatus === 'dropping_soon' ? formatCountdown(domain.deletion_date ?? null) : null
|
||||
const statusConfig = {
|
||||
available: { label: 'AVAIL', color: 'text-accent', bg: 'bg-accent/5 border-accent/20' },
|
||||
dropping_soon: { label: transitionCountdown ? `TRANSITION • ${transitionCountdown}` : 'TRANSITION', color: 'text-amber-400', bg: 'bg-amber-400/5 border-amber-400/20' },
|
||||
taken: { label: 'TAKEN', color: 'text-white/40', bg: 'bg-white/5 border-white/10' },
|
||||
error: { label: 'ERROR', color: 'text-rose-400', bg: 'bg-rose-400/5 border-rose-400/20' },
|
||||
unknown: { label: 'CHECK', color: 'text-white/30', bg: 'bg-white/5 border-white/5' },
|
||||
}[domainStatus] || { label: 'UNKNOWN', color: 'text-white/30', bg: 'bg-white/5 border-white/5' }
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -516,73 +625,35 @@ export default function WatchlistPage() {
|
||||
className="bg-[#020202] hover:bg-white/[0.02] transition-all"
|
||||
>
|
||||
{/* Mobile Row */}
|
||||
<div className={clsx(
|
||||
"lg:hidden p-3 border border-white/[0.06]",
|
||||
domain.is_available
|
||||
? "bg-accent/[0.02] border-accent/20"
|
||||
: "bg-[#020202]"
|
||||
)}>
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<div className={clsx(
|
||||
"w-9 h-9 flex items-center justify-center border shrink-0",
|
||||
domain.is_available
|
||||
? "bg-accent/10 border-accent/30"
|
||||
: "bg-white/[0.02] border-white/[0.06]"
|
||||
)}>
|
||||
{domain.is_available ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-accent" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 text-white/30" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
onClick={() => openAnalyze(domain.name)}
|
||||
className="text-sm font-bold text-white font-mono truncate text-left"
|
||||
title="Analyze"
|
||||
>
|
||||
{domain.name}
|
||||
</button>
|
||||
<div className="text-[10px] font-mono text-white/30">
|
||||
{domain.registrar || 'Unknown registrar'}
|
||||
</div>
|
||||
<div className={clsx("lg:hidden p-5", domain.is_available && "bg-accent/[0.02]")}>
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
onClick={() => openAnalyze(domain)}
|
||||
className="text-lg font-bold text-white font-mono truncate block text-left hover:text-accent transition-colors"
|
||||
>
|
||||
{domain.name}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 mt-2 text-[10px] font-mono text-white/30 uppercase tracking-wider">
|
||||
<span className="bg-white/5 px-2 py-0.5 border border-white/5">{domain.registrar || 'Unknown'}</span>
|
||||
{domainStatus === 'dropping_soon' && transitionCountdown ? (
|
||||
<span className="text-amber-400 font-bold">drops in {transitionCountdown}</span>
|
||||
) : days !== null && days <= 30 && days > 0 ? (
|
||||
<span className="text-orange-400 font-bold">{days}d left</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
<div className={clsx(
|
||||
"text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 mb-1 border",
|
||||
domain.is_available
|
||||
? "text-accent bg-accent/10 border-accent/30"
|
||||
: "text-white/40 bg-white/5 border-white/10"
|
||||
"text-[10px] font-mono px-2 py-0.5 mt-1 inline-block border",
|
||||
statusConfig.color, statusConfig.bg
|
||||
)}>
|
||||
{domain.is_available ? '✓ AVAIL' : 'TAKEN'}
|
||||
{statusConfig.label}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setSelectedDomain(domain.id); handleHealthCheck(domain.id) }}
|
||||
className="flex items-center gap-1 justify-end"
|
||||
>
|
||||
{loadingHealth[domain.id] ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin text-white/30" />
|
||||
) : (
|
||||
<>
|
||||
<Activity className={clsx("w-3 h-3", config.color)} />
|
||||
<span className={clsx("text-[9px] font-mono", config.color)}>{config.label}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expiry Info */}
|
||||
{days !== null && days <= 30 && days > 0 && !domain.is_available && (
|
||||
<div className="mb-3 text-[10px] font-mono text-orange-400 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Expires in {days} days
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
{domain.is_available ? (
|
||||
@ -590,58 +661,48 @@ export default function WatchlistPage() {
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${domain.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 py-3 bg-accent text-black text-[11px] font-bold uppercase tracking-wider flex items-center justify-center gap-2"
|
||||
className="flex-1 h-12 text-xs font-black uppercase tracking-widest flex items-center justify-center gap-2 bg-accent text-black hover:bg-white transition-all"
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
Buy Now
|
||||
Buy
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
|
||||
disabled={togglingNotifyId === domain.id}
|
||||
className={clsx(
|
||||
"flex-1 py-2.5 text-[10px] font-bold uppercase tracking-wider border flex items-center justify-center gap-1.5 transition-all",
|
||||
"flex-1 h-12 text-xs font-bold uppercase tracking-widest border flex items-center justify-center gap-2 transition-all",
|
||||
domain.notify_on_available
|
||||
? "border-accent/30 bg-accent/10 text-accent"
|
||||
: "border-white/10 bg-white/[0.02] text-white/40"
|
||||
? "border-accent bg-accent/5 text-accent"
|
||||
: "border-white/10 text-white/50 hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
{togglingNotifyId === domain.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : domain.notify_on_available ? (
|
||||
<Bell className="w-3.5 h-3.5" />
|
||||
<Bell className="w-4 h-4" />
|
||||
) : (
|
||||
<BellOff className="w-3.5 h-3.5" />
|
||||
<BellOff className="w-4 h-4" />
|
||||
)}
|
||||
{domain.notify_on_available ? 'Alert ON' : 'Set Alert'}
|
||||
{domain.notify_on_available ? 'Alert ON' : 'Alert'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleRefresh(domain.id)}
|
||||
disabled={refreshingId === domain.id}
|
||||
className="px-3 py-2 border border-white/10 text-white/40 hover:bg-white/5"
|
||||
onClick={() => openAnalyze(domain)}
|
||||
className="w-14 h-12 border border-white/10 text-white/50 flex items-center justify-center hover:text-accent hover:border-accent/30 hover:bg-accent/5 transition-all"
|
||||
>
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshingId === domain.id && "animate-spin")} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => openAnalyze(domain.name)}
|
||||
className="px-3 py-2 border border-white/10 text-white/40 hover:text-accent hover:border-accent/20 hover:bg-accent/10"
|
||||
title="Analyze"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
<Shield className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(domain.id, domain.name)}
|
||||
disabled={deletingId === domain.id}
|
||||
className="px-3 py-2 border border-white/10 text-white/40 hover:text-rose-400 hover:border-rose-400/20 hover:bg-rose-400/5"
|
||||
className="w-14 h-12 border border-white/10 text-white/50 flex items-center justify-center hover:text-rose-400 hover:border-rose-400/30 hover:bg-rose-400/5 transition-all"
|
||||
>
|
||||
{deletingId === domain.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<Trash2 className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@ -649,51 +710,30 @@ export default function WatchlistPage() {
|
||||
|
||||
{/* Desktop Row */}
|
||||
<div className={clsx(
|
||||
"hidden lg:grid grid-cols-[1.5fr_100px_100px_100px_80px_160px] gap-4 items-center p-4 group border border-white/[0.06] transition-all",
|
||||
domain.is_available
|
||||
? "bg-accent/[0.02] hover:bg-accent/[0.05] border-accent/20"
|
||||
: "bg-[#020202] hover:bg-white/[0.02]"
|
||||
"hidden lg:grid grid-cols-[1fr_100px_100px_100px_80px_180px] gap-6 items-center px-6 py-4 group transition-all",
|
||||
domain.is_available ? "bg-accent/[0.02]" : ""
|
||||
)}>
|
||||
{/* Domain */}
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className={clsx(
|
||||
"w-10 h-10 flex items-center justify-center border shrink-0",
|
||||
domain.is_available
|
||||
? "bg-accent/10 border-accent/30"
|
||||
: "bg-white/[0.02] border-white/[0.06]"
|
||||
)}>
|
||||
{domain.is_available ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-accent" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 text-white/30" />
|
||||
)}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
onClick={() => openAnalyze(domain)}
|
||||
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left"
|
||||
title="Analyze"
|
||||
>
|
||||
{domain.name}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-[9px] font-mono text-white/20 uppercase tracking-wider opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span>{domain.registrar || 'Unknown'}</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
onClick={() => openAnalyze(domain.name)}
|
||||
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left"
|
||||
title="Analyze"
|
||||
>
|
||||
{domain.name}
|
||||
</button>
|
||||
<div className="text-[10px] font-mono text-white/30">
|
||||
{domain.registrar || 'Unknown registrar'}
|
||||
</div>
|
||||
</div>
|
||||
<a href={`https://${domain.name}`} target="_blank" className="opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity">
|
||||
<ExternalLink className="w-3.5 h-3.5 text-white/40" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex justify-center">
|
||||
<span className={clsx(
|
||||
"text-[10px] font-mono font-bold uppercase px-2.5 py-1 border",
|
||||
domain.is_available
|
||||
? "text-accent bg-accent/10 border-accent/30"
|
||||
: "text-white/40 bg-white/5 border-white/10"
|
||||
"text-[10px] font-mono font-bold uppercase px-2.5 py-1.5 border",
|
||||
statusConfig.color, statusConfig.bg
|
||||
)}>
|
||||
{domain.is_available ? '✓ AVAIL' : 'TAKEN'}
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -702,10 +742,8 @@ export default function WatchlistPage() {
|
||||
<button
|
||||
onClick={() => { setSelectedDomain(domain.id); handleHealthCheck(domain.id) }}
|
||||
className={clsx(
|
||||
"flex items-center gap-1.5 px-2 py-1 text-[10px] font-mono uppercase border transition-colors hover:opacity-80",
|
||||
config.color,
|
||||
config.bg.replace('bg-', 'bg-'),
|
||||
"border-white/10"
|
||||
"flex items-center gap-1.5 px-2.5 py-1.5 text-[10px] font-mono uppercase border transition-colors hover:opacity-80",
|
||||
config.color, "border-white/10"
|
||||
)}
|
||||
>
|
||||
{loadingHealth[domain.id] ? (
|
||||
@ -720,9 +758,11 @@ export default function WatchlistPage() {
|
||||
</div>
|
||||
|
||||
{/* Expires */}
|
||||
<div className="text-center text-xs font-mono">
|
||||
{days !== null && days <= 30 && days > 0 ? (
|
||||
<span className="text-orange-400 font-bold">{days}d left</span>
|
||||
<div className="text-center text-sm font-mono">
|
||||
{domainStatus === 'dropping_soon' && transitionCountdown ? (
|
||||
<span className="text-amber-400 font-bold">{transitionCountdown}</span>
|
||||
) : days !== null && days <= 30 && days > 0 ? (
|
||||
<span className="text-orange-400 font-bold">{days}d</span>
|
||||
) : (
|
||||
<span className="text-white/50">{formatExpiryDate(domain.expiration_date)}</span>
|
||||
)}
|
||||
@ -734,7 +774,7 @@ export default function WatchlistPage() {
|
||||
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
|
||||
disabled={togglingNotifyId === domain.id}
|
||||
className={clsx(
|
||||
"w-9 h-9 flex items-center justify-center border transition-colors",
|
||||
"w-10 h-10 flex items-center justify-center border transition-all",
|
||||
domain.notify_on_available
|
||||
? "text-accent border-accent/30 bg-accent/10"
|
||||
: "text-white/20 border-white/10 hover:text-white/40 hover:bg-white/5"
|
||||
@ -751,16 +791,16 @@ export default function WatchlistPage() {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="flex items-center justify-end gap-2 opacity-40 group-hover:opacity-100 transition-all">
|
||||
{domain.is_available ? (
|
||||
<a
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${domain.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-9 px-4 bg-accent text-black text-[10px] font-bold uppercase tracking-wider flex items-center gap-2 hover:bg-white transition-colors"
|
||||
className="h-10 px-5 bg-accent text-black text-[10px] font-black uppercase tracking-widest flex items-center gap-2 hover:bg-white transition-colors"
|
||||
>
|
||||
<ShoppingCart className="w-3.5 h-3.5" />
|
||||
Buy Now
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
Buy
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
@ -768,16 +808,16 @@ export default function WatchlistPage() {
|
||||
onClick={() => handleRefresh(domain.id)}
|
||||
disabled={refreshingId === domain.id}
|
||||
title="Refresh"
|
||||
className="w-8 h-8 flex items-center justify-center text-white/30 hover:text-white border border-white/10 hover:bg-white/5 transition-all"
|
||||
className="w-10 h-10 flex items-center justify-center text-white/40 hover:text-white border border-white/10 hover:bg-white/5 transition-all"
|
||||
>
|
||||
<RefreshCw className={clsx("w-3.5 h-3.5", refreshingId === domain.id && "animate-spin")} />
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshingId === domain.id && "animate-spin")} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openAnalyze(domain.name)}
|
||||
onClick={() => openAnalyze(domain)}
|
||||
title="Analyze"
|
||||
className="w-8 h-8 flex items-center justify-center text-white/30 hover:text-accent border border-white/10 hover:bg-accent/10 hover:border-accent/20 transition-all"
|
||||
className="w-10 h-10 flex items-center justify-center text-white/40 hover:text-accent border border-white/10 hover:bg-accent/10 hover:border-accent/20 transition-all"
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
<Shield className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@ -785,12 +825,12 @@ export default function WatchlistPage() {
|
||||
onClick={() => handleDelete(domain.id, domain.name)}
|
||||
disabled={deletingId === domain.id}
|
||||
title="Remove"
|
||||
className="w-8 h-8 flex items-center justify-center text-white/30 hover:text-rose-400 border border-white/10 hover:border-rose-400/20 hover:bg-rose-500/10 transition-all"
|
||||
className="w-10 h-10 flex items-center justify-center text-white/40 hover:text-rose-400 border border-white/10 hover:border-rose-400/20 hover:bg-rose-500/10 transition-all"
|
||||
>
|
||||
{deletingId === domain.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@ -798,6 +838,7 @@ export default function WatchlistPage() {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@ -36,9 +36,11 @@ function StatusBadge({ status }: { status: string }) {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ACTIVATE MODAL - Only verified portfolio domains
|
||||
// ACTIVATE MODAL - Simple 3-step wizard
|
||||
// ============================================================================
|
||||
|
||||
const YIELD_SERVER_IP = '46.235.147.194'
|
||||
|
||||
function ActivateModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@ -50,41 +52,39 @@ function ActivateModal({
|
||||
onSuccess: () => void
|
||||
prefillDomain?: string | null
|
||||
}) {
|
||||
const subscription = useStore((s) => s.subscription)
|
||||
const tier = (subscription?.tier || 'scout').toLowerCase()
|
||||
const isTycoon = tier === 'tycoon'
|
||||
const canPreview = tier === 'trader' || tier === 'tycoon'
|
||||
|
||||
const [selectedDomain, setSelectedDomain] = useState('')
|
||||
const [verifiedDomains, setVerifiedDomains] = useState<{ id: number; domain: string }[]>([])
|
||||
const [loadingDomains, setLoadingDomains] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [step, setStep] = useState<1 | 2>(1)
|
||||
const [activation, setActivation] = useState<null | {
|
||||
domain_id: number
|
||||
domain: string
|
||||
status: string
|
||||
dns_instructions: {
|
||||
domain: string
|
||||
nameservers: string[]
|
||||
cname_host: string
|
||||
cname_target: string
|
||||
verification_url: string
|
||||
}
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1)
|
||||
const [activatedDomainId, setActivatedDomainId] = useState<number | null>(null)
|
||||
const [landing, setLanding] = useState<null | {
|
||||
headline: string
|
||||
seo_intro: string
|
||||
cta_label: string
|
||||
}>(null)
|
||||
const [dnsChecking, setDnsChecking] = useState(false)
|
||||
const [dnsResult, setDnsResult] = useState<null | {
|
||||
verified: boolean
|
||||
expected_ns: string[]
|
||||
actual_ns: string[]
|
||||
cname_ok: boolean
|
||||
error: string | null
|
||||
const [dnsVerified, setDnsVerified] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||
const [preview, setPreview] = useState<null | {
|
||||
headline: string
|
||||
seo_intro: string
|
||||
cta_label: string
|
||||
}>(null)
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (prefillDomain) {
|
||||
setSelectedDomain(prefillDomain)
|
||||
setStep(1)
|
||||
setActivation(null)
|
||||
setDnsResult(null)
|
||||
}
|
||||
const fetchVerifiedDomains = async () => {
|
||||
setLoadingDomains(true)
|
||||
@ -98,23 +98,28 @@ function ActivateModal({
|
||||
}
|
||||
}
|
||||
fetchVerifiedDomains()
|
||||
}, [isOpen])
|
||||
}, [isOpen, prefillDomain])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setStep(1)
|
||||
setActivation(null)
|
||||
setDnsResult(null)
|
||||
setActivatedDomainId(null)
|
||||
setLanding(null)
|
||||
setDnsChecking(false)
|
||||
setDnsVerified(false)
|
||||
setError(null)
|
||||
setSelectedDomain('')
|
||||
}, [isOpen])
|
||||
setSelectedDomain(prefillDomain || '')
|
||||
setPreview(null)
|
||||
setPreviewError(null)
|
||||
setPreviewLoading(false)
|
||||
setCopied(false)
|
||||
}, [isOpen, prefillDomain])
|
||||
|
||||
const copyToClipboard = async (value: string, key: string) => {
|
||||
const copyIP = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
setCopied(key)
|
||||
setTimeout(() => setCopied(null), 1200)
|
||||
await navigator.clipboard.writeText(YIELD_SERVER_IP)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@ -126,182 +131,357 @@ function ActivateModal({
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.activateYieldDomain(selectedDomain, true)
|
||||
setActivation({
|
||||
domain_id: res.domain_id,
|
||||
domain: res.domain,
|
||||
status: res.status,
|
||||
dns_instructions: res.dns_instructions,
|
||||
})
|
||||
setActivatedDomainId(res.domain_id)
|
||||
if (res.landing) {
|
||||
setLanding({
|
||||
headline: res.landing.headline,
|
||||
seo_intro: res.landing.seo_intro,
|
||||
cta_label: res.landing.cta_label,
|
||||
})
|
||||
}
|
||||
setStep(2)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed')
|
||||
setError(err.message || 'Failed to activate')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkDNS = useCallback(async (domainId: number) => {
|
||||
const handlePreview = async () => {
|
||||
if (!selectedDomain || !canPreview) return
|
||||
setPreviewLoading(true)
|
||||
setPreviewError(null)
|
||||
setPreview(null)
|
||||
try {
|
||||
const res = await api.getYieldLandingPreview(selectedDomain, false)
|
||||
setPreview({
|
||||
headline: res.result.headline,
|
||||
seo_intro: res.result.seo_intro,
|
||||
cta_label: res.result.cta_label,
|
||||
})
|
||||
} catch (err: any) {
|
||||
setPreviewError(err.message || 'Preview failed')
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const checkDNS = async () => {
|
||||
if (!activatedDomainId) return
|
||||
setDnsChecking(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.verifyYieldDomainDNS(domainId)
|
||||
setDnsResult({
|
||||
verified: res.verified,
|
||||
expected_ns: res.expected_ns,
|
||||
actual_ns: res.actual_ns,
|
||||
cname_ok: res.cname_ok,
|
||||
error: res.error,
|
||||
})
|
||||
const res = await api.verifyYieldDomainDNS(activatedDomainId)
|
||||
if (res.verified) {
|
||||
setDnsVerified(true)
|
||||
setStep(3)
|
||||
onSuccess()
|
||||
} else {
|
||||
setError('DNS not yet propagated. This can take up to 15 minutes. Try again shortly.')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'DNS check failed')
|
||||
} finally {
|
||||
setDnsChecking(false)
|
||||
}
|
||||
}, [onSuccess])
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] bg-black/80 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="w-full max-w-md bg-[#0A0A0A] border border-white/[0.08]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-accent" />
|
||||
<span className="text-xs font-mono text-accent uppercase tracking-wider">Activate Yield</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
{step === 1 && loadingDomains ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="w-5 h-5 text-accent animate-spin" />
|
||||
<div className="w-full max-w-lg bg-[#0A0A0A] border border-white/[0.08]" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-white/[0.08]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
) : step === 1 && verifiedDomains.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<AlertCircle className="w-8 h-8 text-amber-400 mx-auto mb-3" />
|
||||
<h3 className="text-sm font-bold text-white mb-2">No Verified Domains</h3>
|
||||
<p className="text-xs text-white/50 mb-4">
|
||||
You need to add domains to your portfolio and verify DNS ownership before activating Yield.
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-white">
|
||||
{isTycoon ? 'Activate Yield' : 'Preview Yield Landing'}
|
||||
</h2>
|
||||
<p className="text-[10px] font-mono text-white/40 uppercase tracking-wider">
|
||||
Step {step} of {isTycoon ? 3 : 1}
|
||||
</p>
|
||||
<a href="/terminal/portfolio" className="inline-flex items-center gap-2 px-4 py-2 bg-accent text-black text-xs font-bold uppercase">
|
||||
Go to Portfolio
|
||||
</a>
|
||||
</div>
|
||||
) : step === 1 ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-[9px] font-mono text-white/40 uppercase mb-1.5">Select Domain (DNS Verified)</label>
|
||||
<select
|
||||
value={selectedDomain}
|
||||
onChange={(e) => setSelectedDomain(e.target.value)}
|
||||
className="w-full px-3 py-2.5 bg-white/5 border border-white/10 text-white text-sm font-mono outline-none focus:border-accent/50"
|
||||
>
|
||||
<option value="">— Select a domain —</option>
|
||||
{verifiedDomains.map(d => (
|
||||
<option key={d.id} value={d.domain}>{d.domain}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="p-3 bg-accent/5 border border-accent/20 text-xs text-accent/80 font-mono">
|
||||
<p>Only DNS-verified domains from your portfolio can be activated for Yield.</p>
|
||||
</div>
|
||||
{error && <div className="p-2 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs">{error}</div>}
|
||||
<button onClick={handleActivate} disabled={loading || !selectedDomain}
|
||||
className="w-full py-2.5 bg-accent text-black text-xs font-bold uppercase flex items-center justify-center gap-2 disabled:opacity-50">
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
Activate Yield
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-3 bg-white/[0.02] border border-white/[0.08]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[9px] font-mono text-white/40 uppercase tracking-wider">Domain</div>
|
||||
<div className="text-sm font-bold text-white font-mono">{activation?.domain}</div>
|
||||
</div>
|
||||
<StatusBadge status={activation?.status || 'pending'} />
|
||||
</div>
|
||||
<button onClick={onClose} className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/40 hover:text-white hover:border-white/20 transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5">
|
||||
{/* STEP 1: Select Domain */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-5">
|
||||
{loadingDomains ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Option A (Recommended): Nameservers</div>
|
||||
<div className="bg-[#020202] border border-white/[0.08]">
|
||||
{(activation?.dns_instructions.nameservers || []).map((ns, idx) => (
|
||||
<div key={ns} className={clsx("flex items-center justify-between px-3 py-2", idx > 0 && "border-t border-white/[0.06]")}>
|
||||
<span className="text-xs font-mono text-white/80">{ns}</span>
|
||||
<button onClick={() => copyToClipboard(ns, `ns-${idx}`)} className="p-1.5 border border-white/10 text-white/40 hover:text-white">
|
||||
{copied === `ns-${idx}` ? <Check className="w-4 h-4 text-accent" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Option B: CNAME / ALIAS</div>
|
||||
<div className="bg-[#020202] border border-white/[0.08] p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-mono text-white/70">
|
||||
<span className="text-white/40">Host:</span> {activation?.dns_instructions.cname_host} <span className="text-white/40">→ Target:</span> {activation?.dns_instructions.cname_target}
|
||||
</div>
|
||||
<button onClick={() => copyToClipboard(activation?.dns_instructions.cname_target || '', `cname-target`)} className="p-1.5 border border-white/10 text-white/40 hover:text-white">
|
||||
{copied === `cname-target` ? <Check className="w-4 h-4 text-accent" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] font-mono text-white/35">
|
||||
Some DNS providers use ALIAS/ANAME for apex. We accept both CNAME and ALIAS-style flattening.
|
||||
) : verifiedDomains.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="w-12 h-12 text-amber-400/50 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold text-white mb-2">No Verified Domains</h3>
|
||||
<p className="text-sm text-white/50 mb-6 max-w-xs mx-auto">
|
||||
First, add domains to your portfolio and verify DNS ownership.
|
||||
</p>
|
||||
<a href="/terminal/portfolio" className="inline-flex items-center gap-2 px-5 py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider">
|
||||
Go to Portfolio
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dnsResult && (
|
||||
<div className={clsx("p-3 border text-xs font-mono", dnsResult.verified ? "bg-accent/5 border-accent/20 text-accent/80" : "bg-amber-400/5 border-amber-400/20 text-amber-400/80")}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span>{dnsResult.verified ? 'Connected. Domain is active.' : 'Not connected yet. Waiting for DNS propagation.'}</span>
|
||||
{dnsResult.verified ? <CheckCircle2 className="w-4 h-4 text-accent" /> : <Clock className="w-4 h-4 text-amber-400" />}
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white mb-2">
|
||||
Which domain do you want to monetize?
|
||||
</label>
|
||||
<select
|
||||
value={selectedDomain}
|
||||
onChange={(e) => { setSelectedDomain(e.target.value); setPreview(null); setPreviewError(null) }}
|
||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 text-white text-sm font-mono outline-none focus:border-accent/50 transition-colors"
|
||||
>
|
||||
<option value="">Select a domain...</option>
|
||||
{verifiedDomains.map(d => (
|
||||
<option key={d.id} value={d.domain}>{d.domain}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] text-white/30 mt-2">
|
||||
Only DNS-verified domains from your portfolio are shown.
|
||||
</p>
|
||||
</div>
|
||||
{dnsResult.error && <div className="mt-2 text-rose-400/80">Error: {dnsResult.error}</div>}
|
||||
{!dnsResult.verified && (
|
||||
<div className="mt-2 text-white/40">
|
||||
<div>Expected NS: {dnsResult.expected_ns?.join(', ') || '—'}</div>
|
||||
<div>Actual NS: {dnsResult.actual_ns?.join(', ') || '—'}</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className={clsx(
|
||||
"p-4 border",
|
||||
isTycoon ? "bg-accent/5 border-accent/20" : "bg-white/[0.02] border-white/[0.08]"
|
||||
)}>
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0 w-8 h-8 bg-white/5 flex items-center justify-center">
|
||||
<Zap className={clsx("w-4 h-4", isTycoon ? "text-accent" : "text-white/40")} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-white mb-1">
|
||||
{isTycoon ? 'How Yield Works' : 'Yield is Tycoon-Only'}
|
||||
</h4>
|
||||
<p className="text-xs text-white/50 leading-relaxed">
|
||||
{isTycoon
|
||||
? 'We generate an AI-powered landing page for your domain. When visitors click, you earn revenue through our affiliate network.'
|
||||
: 'You can preview the AI-generated landing page, but activation requires a Tycoon subscription.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs font-mono">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview for non-Tycoon */}
|
||||
{!isTycoon && (
|
||||
<>
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={previewLoading || !selectedDomain}
|
||||
className="w-full py-3 bg-white/10 text-white text-sm font-bold uppercase flex items-center justify-center gap-2 disabled:opacity-50 hover:bg-white/15 transition-colors"
|
||||
>
|
||||
{previewLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
|
||||
Preview Landing Page
|
||||
</button>
|
||||
|
||||
{previewError && (
|
||||
<div className="p-3 bg-rose-500/10 border border-rose-500/20 text-rose-300 text-xs">
|
||||
{previewError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="p-4 bg-[#050505] border border-white/[0.08] space-y-3">
|
||||
<div className="text-[9px] font-mono text-accent uppercase tracking-wider">Generated Landing Page</div>
|
||||
<h3 className="text-base font-bold text-white">{preview.headline}</h3>
|
||||
<p className="text-sm text-white/50 leading-relaxed">{preview.seo_intro}</p>
|
||||
<div className="pt-3 border-t border-white/10">
|
||||
<span className="inline-flex items-center gap-2 px-4 py-2 bg-accent/20 text-accent text-xs font-bold">
|
||||
{preview.cta_label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="w-full py-3 bg-accent text-black text-sm font-bold uppercase flex items-center justify-center gap-2 hover:bg-white transition-colors"
|
||||
>
|
||||
<Crown className="w-4 h-4" />
|
||||
Upgrade to Tycoon to Activate
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Activate for Tycoon */}
|
||||
{isTycoon && (
|
||||
<button
|
||||
onClick={handleActivate}
|
||||
disabled={loading || !selectedDomain}
|
||||
className="w-full py-3.5 bg-accent text-black text-sm font-bold uppercase flex items-center justify-center gap-2 disabled:opacity-50 hover:bg-white transition-colors"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
Generate Landing & Continue
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 2: DNS Setup Instructions */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-5">
|
||||
{/* Success Message */}
|
||||
<div className="p-4 bg-accent/5 border border-accent/20">
|
||||
<div className="flex gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-accent shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-white mb-1">Landing Page Generated!</h4>
|
||||
<p className="text-xs text-white/50">
|
||||
Now point your domain to our server to start earning.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Landing Preview */}
|
||||
{landing && (
|
||||
<div className="p-4 bg-[#050505] border border-white/[0.08] space-y-2">
|
||||
<div className="text-[9px] font-mono text-white/40 uppercase tracking-wider">Your Landing Page</div>
|
||||
<h3 className="text-sm font-bold text-white">{landing.headline}</h3>
|
||||
<p className="text-xs text-white/50">{landing.seo_intro}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="p-2 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs">{error}</div>}
|
||||
{/* DNS Instructions */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<span className="w-6 h-6 bg-accent text-black text-xs font-bold flex items-center justify-center">1</span>
|
||||
Set up DNS at your registrar
|
||||
</h3>
|
||||
|
||||
<div className="p-4 bg-[#020202] border border-white/[0.08] space-y-4">
|
||||
<p className="text-sm text-white/70">
|
||||
Go to your domain registrar (where you bought <span className="text-white font-bold">{selectedDomain}</span>) and add this DNS record:
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="p-3 bg-white/5 border border-white/10">
|
||||
<div className="text-[9px] font-mono text-white/40 uppercase mb-1">Type</div>
|
||||
<div className="text-lg font-bold text-white font-mono">A</div>
|
||||
</div>
|
||||
<div className="p-3 bg-white/5 border border-white/10">
|
||||
<div className="text-[9px] font-mono text-white/40 uppercase mb-1">Name</div>
|
||||
<div className="text-lg font-bold text-white font-mono">@</div>
|
||||
</div>
|
||||
<div className="p-3 bg-accent/10 border border-accent/20">
|
||||
<div className="text-[9px] font-mono text-accent/60 uppercase mb-1">Value</div>
|
||||
<div className="text-sm font-bold text-accent font-mono">{YIELD_SERVER_IP}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setStep(1); setActivation(null); setDnsResult(null) }}
|
||||
className="flex-1 py-2.5 border border-white/10 text-white/60 text-xs font-bold uppercase"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => activation?.domain_id && checkDNS(activation.domain_id)}
|
||||
disabled={dnsChecking || !activation?.domain_id}
|
||||
className="flex-[1.4] py-2.5 bg-accent text-black text-xs font-bold uppercase flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{dnsChecking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
Verify DNS
|
||||
</button>
|
||||
<button
|
||||
onClick={copyIP}
|
||||
className="w-full py-2.5 border border-white/10 text-white/70 text-xs font-mono flex items-center justify-center gap-2 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-accent" /> : <Copy className="w-4 h-4" />}
|
||||
{copied ? 'Copied!' : `Copy IP: ${YIELD_SERVER_IP}`}
|
||||
</button>
|
||||
|
||||
<div className="text-[10px] text-white/30 leading-relaxed">
|
||||
<strong className="text-white/50">Tip:</strong> The "@" symbol means the root domain. Some registrars use "empty" or the domain name itself instead.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dnsResult?.verified && (
|
||||
<button
|
||||
onClick={() => { onClose(); onSuccess() }}
|
||||
className="w-full py-2.5 bg-white text-black text-xs font-bold uppercase flex items-center justify-center gap-2"
|
||||
>
|
||||
View Yield Dashboard <ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
{/* Verify Button */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<span className="w-6 h-6 bg-white/10 text-white/60 text-xs font-bold flex items-center justify-center">2</span>
|
||||
Verify connection
|
||||
</h3>
|
||||
|
||||
<p className="text-xs text-white/50">
|
||||
After saving your DNS settings, click verify. DNS changes can take 5-15 minutes to propagate.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-amber-500/10 border border-amber-500/20 text-amber-400 text-xs flex items-start gap-2">
|
||||
<Clock className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setStep(1)}
|
||||
className="flex-1 py-3 border border-white/10 text-white/60 text-xs font-bold uppercase hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={checkDNS}
|
||||
disabled={dnsChecking}
|
||||
className="flex-[2] py-3 bg-accent text-black text-sm font-bold uppercase flex items-center justify-center gap-2 disabled:opacity-50 hover:bg-white transition-colors"
|
||||
>
|
||||
{dnsChecking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
Verify DNS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STEP 3: Success */}
|
||||
{step === 3 && (
|
||||
<div className="text-center py-6 space-y-5">
|
||||
<div className="w-16 h-16 bg-accent/10 border border-accent/20 flex items-center justify-center mx-auto">
|
||||
<CheckCircle2 className="w-8 h-8 text-accent" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">Yield Activated!</h3>
|
||||
<p className="text-sm text-white/50">
|
||||
<span className="text-white font-bold">{selectedDomain}</span> is now earning passive income.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[#050505] border border-white/[0.08] text-left space-y-2">
|
||||
<div className="text-[9px] font-mono text-white/40 uppercase tracking-wider">What happens now?</div>
|
||||
<ul className="text-xs text-white/60 space-y-1.5">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-accent">•</span>
|
||||
Visitors to {selectedDomain} see your AI-generated landing page
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-accent">•</span>
|
||||
When they click the CTA, you earn revenue
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-accent">•</span>
|
||||
Track clicks and earnings in your Yield dashboard
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full py-3.5 bg-accent text-black text-sm font-bold uppercase flex items-center justify-center gap-2 hover:bg-white transition-colors"
|
||||
>
|
||||
View Dashboard
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -323,6 +503,11 @@ export default function YieldPage() {
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [verifyingId, setVerifyingId] = useState<number | null>(null)
|
||||
|
||||
const tier = (subscription?.tier || 'scout').toLowerCase()
|
||||
const tierName = subscription?.tier_name || (tier.charAt(0).toUpperCase() + tier.slice(1))
|
||||
const isTycoon = tier === 'tycoon'
|
||||
|
||||
useEffect(() => { checkAuth() }, [checkAuth])
|
||||
|
||||
@ -347,6 +532,23 @@ export default function YieldPage() {
|
||||
}
|
||||
}, [fetchDashboard])
|
||||
|
||||
const handleVerifyDNS = useCallback(async (domainId: number, domainName: string) => {
|
||||
setVerifyingId(domainId)
|
||||
try {
|
||||
const res = await api.verifyYieldDomainDNS(domainId)
|
||||
if (res.verified) {
|
||||
alert(`✅ ${domainName} is now active! Your landing page is live.`)
|
||||
fetchDashboard()
|
||||
} else {
|
||||
alert(`⏳ DNS not yet propagated for ${domainName}. Please wait 5-15 minutes and try again.`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(`❌ DNS verification failed: ${err.message || 'Unknown error'}`)
|
||||
} finally {
|
||||
setVerifyingId(null)
|
||||
}
|
||||
}, [fetchDashboard])
|
||||
|
||||
useEffect(() => { fetchDashboard() }, [fetchDashboard])
|
||||
|
||||
useEffect(() => {
|
||||
@ -366,8 +568,8 @@ export default function YieldPage() {
|
||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
||||
]
|
||||
|
||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||
const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap
|
||||
const tierLabelForDrawer = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||
const TierIcon = tierLabelForDrawer === 'Tycoon' ? Crown : tierLabelForDrawer === 'Trader' ? TrendingUp : Zap
|
||||
|
||||
const drawerNavSections = [
|
||||
{ title: 'Discover', items: [
|
||||
@ -430,6 +632,12 @@ export default function YieldPage() {
|
||||
<p className="text-sm text-white/40 font-mono mt-2 max-w-lg">
|
||||
Monetize your parked domains. Route visitor intent to earn passive income.
|
||||
</p>
|
||||
{!isTycoon && (
|
||||
<div className="inline-flex items-center gap-2 px-3 py-2 border border-white/10 bg-white/[0.02] text-xs text-white/50 font-mono">
|
||||
<Sparkles className="w-4 h-4 text-accent" />
|
||||
Yield activation is <span className="text-white/70 font-bold">Tycoon-only</span>. You can preview the landing page on Trader.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
{stats && (
|
||||
@ -451,7 +659,7 @@ export default function YieldPage() {
|
||||
</button>
|
||||
<button onClick={() => setShowActivateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white">
|
||||
<Plus className="w-4 h-4" />Activate Domain
|
||||
<Plus className="w-4 h-4" />{isTycoon ? 'Activate Domain' : 'Preview Landing'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -462,7 +670,7 @@ export default function YieldPage() {
|
||||
<section className="lg:hidden px-4 py-3 border-b border-white/[0.08]">
|
||||
<button onClick={() => setShowActivateModal(true)}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider">
|
||||
<Plus className="w-4 h-4" />Activate Domain
|
||||
<Plus className="w-4 h-4" />{isTycoon ? 'Activate Domain' : 'Preview Landing'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@ -473,86 +681,187 @@ export default function YieldPage() {
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
) : !dashboard?.domains?.length ? (
|
||||
<div className="text-center py-16 border border-dashed border-white/[0.08]">
|
||||
<TrendingUp className="w-8 h-8 text-white/10 mx-auto mb-3" />
|
||||
<p className="text-white/40 text-sm font-mono mb-2">No yield domains yet</p>
|
||||
<p className="text-white/25 text-xs font-mono">Activate domains to earn passive income</p>
|
||||
<div className="text-center py-24 border border-dashed border-white/[0.08] bg-white/[0.01]">
|
||||
<TrendingUp className="w-16 h-16 text-white/5 mx-auto mb-6" />
|
||||
<p className="text-white/50 text-sm font-mono uppercase tracking-widest font-bold">No yield domains yet</p>
|
||||
<p className="text-white/20 text-xs font-mono mt-3 uppercase tracking-wider max-w-sm mx-auto leading-relaxed">
|
||||
Activate domains to earn passive income from parked traffic
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
|
||||
<div className="border border-white/[0.08] bg-[#020202] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_80px_120px_80px_80px_80px_60px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
|
||||
<div className="hidden lg:grid grid-cols-[1fr_80px_140px_120px_70px_60px_80px_90px] gap-4 px-5 py-3 text-[10px] font-mono text-white/40 uppercase tracking-[0.12em] border-b border-white/[0.08] bg-white/[0.02]">
|
||||
<div>Domain</div>
|
||||
<div className="text-center">Status</div>
|
||||
<div>Intent</div>
|
||||
<div>Landing</div>
|
||||
<div className="text-right">Clicks</div>
|
||||
<div className="text-right">Conv.</div>
|
||||
<div className="text-right">Revenue</div>
|
||||
<div className="text-right">Action</div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
{/* Table Body */}
|
||||
<div className="divide-y divide-white/[0.04]">
|
||||
|
||||
{dashboard.domains.map((domain: YieldDomain) => (
|
||||
<div key={domain.id} className="bg-[#020202] hover:bg-white/[0.02] transition-all">
|
||||
<div key={domain.id} className="group transition-all">
|
||||
{/* Mobile */}
|
||||
<div className="lg:hidden p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-white/[0.02] border border-white/[0.06] flex items-center justify-center text-accent text-xs font-bold font-mono">
|
||||
{domain.domain.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-sm font-bold text-white font-mono">{domain.domain}</span>
|
||||
</div>
|
||||
<StatusBadge status={domain.status} />
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-[10px] font-mono text-white/50 hover:text-white/70 flex items-center justify-between">
|
||||
<span>
|
||||
Landing:{' '}
|
||||
{domain.landing_headline ? (
|
||||
<span className="text-accent font-bold">Ready</span>
|
||||
) : (
|
||||
<span className="text-amber-400 font-bold">Missing</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-white/30 group-open:text-white/50">expand</span>
|
||||
</summary>
|
||||
<div className="mt-2 p-2 bg-[#050505] border border-white/[0.08]">
|
||||
{domain.landing_headline ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-bold text-white">{domain.landing_headline}</div>
|
||||
{domain.landing_intro && <div className="text-[10px] text-white/50">{domain.landing_intro}</div>}
|
||||
<div className="text-[10px] font-mono text-white/40">
|
||||
CTA: <span className="text-white/70">{domain.landing_cta_label || '—'}</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-white/30">
|
||||
{domain.landing_generated_at ? new Date(domain.landing_generated_at).toLocaleString() : '—'}
|
||||
{domain.landing_model ? <span className="text-white/20"> • {domain.landing_model}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-white/40">
|
||||
No landing config stored yet. (Older activation) Remove + re-activate on Tycoon to regenerate.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-4 text-[10px] font-mono text-white/40">
|
||||
<span>{domain.total_clicks} clicks</span>
|
||||
<span className="text-accent font-bold">${domain.total_revenue}</span>
|
||||
<span>{domain.total_clicks} clicks</span>
|
||||
<span className="text-accent font-bold">${domain.total_revenue}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteYield(domain.id, domain.domain)}
|
||||
disabled={deletingId === domain.id}
|
||||
className="p-1.5 text-white/30 hover:text-rose-400 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{deletingId === domain.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Verify DNS Button - only for pending domains (mobile) */}
|
||||
{domain.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleVerifyDNS(domain.id, domain.domain)}
|
||||
disabled={verifyingId === domain.id}
|
||||
className="px-2 py-1 flex items-center gap-1 text-amber-400 text-[10px] font-mono border border-amber-400/20 hover:border-accent/30 hover:bg-accent/10 transition-all"
|
||||
>
|
||||
{verifyingId === domain.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-3 h-3" />
|
||||
<span>Verify</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteYield(domain.id, domain.domain)}
|
||||
disabled={deletingId === domain.id}
|
||||
className="p-1.5 text-white/30 hover:text-rose-400 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{deletingId === domain.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_80px_120px_80px_80px_80px_60px] gap-4 items-center px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-white/[0.02] border border-white/[0.06] flex items-center justify-center text-accent text-xs font-bold font-mono">
|
||||
{domain.domain.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-sm font-bold text-white font-mono">{domain.domain}</span>
|
||||
<div className="hidden lg:grid grid-cols-[1fr_80px_140px_120px_70px_60px_80px_90px] gap-4 items-center px-5 py-3 group-hover:bg-white/[0.02]">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors">{domain.domain}</span>
|
||||
</div>
|
||||
<div className="flex justify-center"><StatusBadge status={domain.status} /></div>
|
||||
<span className="text-xs text-white/60 capitalize font-mono">{domain.detected_intent?.replace('_', ' ') || '—'}</span>
|
||||
<div className="text-right text-xs font-mono text-white/60">{domain.total_clicks}</div>
|
||||
<div className="text-right text-xs font-mono text-white/60">{domain.total_conversions}</div>
|
||||
<span className="text-sm text-white/50 capitalize font-mono truncate">{domain.detected_intent?.replace('_', ' ') || '—'}</span>
|
||||
<div>
|
||||
<details className="group/details">
|
||||
<summary
|
||||
className="cursor-pointer text-xs font-mono text-white/50 hover:text-white/70 flex items-center gap-2"
|
||||
title="View landing page details"
|
||||
>
|
||||
{domain.landing_headline ? (
|
||||
<span className="text-accent">Ready</span>
|
||||
) : (
|
||||
<span className="text-amber-400">Missing</span>
|
||||
)}
|
||||
</summary>
|
||||
<div className="absolute mt-2 p-3 bg-[#050505] border border-white/[0.08] space-y-2 z-10 w-80">
|
||||
{domain.landing_headline ? (
|
||||
<>
|
||||
<div className="text-xs font-bold text-white">{domain.landing_headline}</div>
|
||||
{domain.landing_intro && <div className="text-[10px] text-white/50">{domain.landing_intro}</div>}
|
||||
<div className="text-[10px] font-mono text-white/40">
|
||||
CTA: <span className="text-white/70">{domain.landing_cta_label || '—'}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-[10px] text-white/40">
|
||||
No landing config. Re-activate on Tycoon to generate.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div className="text-right text-sm font-mono text-white/50">{domain.total_clicks}</div>
|
||||
<div className="text-right text-sm font-mono text-white/50">{domain.total_conversions}</div>
|
||||
<div className="text-right text-sm font-bold font-mono text-accent">${domain.total_revenue}</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-1 opacity-40 group-hover:opacity-100 transition-all">
|
||||
{/* Verify DNS Button - only for pending domains */}
|
||||
{domain.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleVerifyDNS(domain.id, domain.domain)}
|
||||
disabled={verifyingId === domain.id}
|
||||
className="h-8 px-2 flex items-center justify-center gap-1 text-amber-400 hover:text-accent text-[10px] font-mono border border-amber-400/20 hover:border-accent/30 hover:bg-accent/10 transition-all"
|
||||
title="Verify DNS to activate"
|
||||
>
|
||||
{verifyingId === domain.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-3 h-3" />
|
||||
<span>Verify</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteYield(domain.id, domain.domain)}
|
||||
disabled={deletingId === domain.id}
|
||||
className="p-1.5 text-white/30 hover:text-rose-400 disabled:opacity-50 transition-colors"
|
||||
className="w-8 h-8 flex items-center justify-center text-white/40 hover:text-rose-400 border border-white/10 hover:border-rose-400/20 hover:bg-rose-500/10 transition-all"
|
||||
title="Remove from Yield"
|
||||
>
|
||||
{deletingId === domain.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@ -604,9 +913,9 @@ export default function YieldPage() {
|
||||
<div className="p-4 bg-white/[0.02] border-t border-white/[0.08]">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center"><TierIcon className="w-4 h-4 text-accent" /></div>
|
||||
<div className="flex-1 min-w-0"><p className="text-sm font-bold text-white truncate">{user?.name || user?.email?.split('@')[0] || 'User'}</p><p className="text-[9px] font-mono text-white/40 uppercase">{tierName}</p></div>
|
||||
<div className="flex-1 min-w-0"><p className="text-sm font-bold text-white truncate">{user?.name || user?.email?.split('@')[0] || 'User'}</p><p className="text-[9px] font-mono text-white/40 uppercase">{tierLabelForDrawer}</p></div>
|
||||
</div>
|
||||
{tierName === 'Scout' && <Link href="/pricing" onClick={() => setMenuOpen(false)} className="flex items-center justify-center gap-2 w-full py-2.5 bg-accent text-black text-xs font-bold uppercase mb-2"><Sparkles className="w-3 h-3" />Upgrade</Link>}
|
||||
{tierLabelForDrawer === 'Scout' && <Link href="/pricing" onClick={() => setMenuOpen(false)} className="flex items-center justify-center gap-2 w-full py-2.5 bg-accent text-black text-xs font-bold uppercase mb-2"><Sparkles className="w-3 h-3" />Upgrade</Link>}
|
||||
<button onClick={() => { logout(); setMenuOpen(false) }} className="flex items-center justify-center gap-2 w-full py-2 border border-white/10 text-white/40 text-[10px] font-mono uppercase"><LogOut className="w-3 h-3" />Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -66,7 +66,7 @@ export function Footer() {
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm font-mono text-white/50 mb-6 sm:mb-8 max-w-sm leading-relaxed">
|
||||
Global domain intelligence for serious investors. Scan. Acquire. Route. Yield.
|
||||
High-density domain intelligence for serious investors. Scan. Track. Trade.
|
||||
</p>
|
||||
|
||||
{/* Newsletter - Hidden on Mobile */}
|
||||
@ -144,6 +144,7 @@ export function Footer() {
|
||||
{[
|
||||
{ href: '/acquire', label: 'Acquire' },
|
||||
{ href: '/discover', label: 'Discover' },
|
||||
{ href: '/intelligence', label: 'Intel' },
|
||||
{ href: '/yield', label: 'Yield' },
|
||||
{ href: '/pricing', label: 'Pricing' },
|
||||
].map((link) => (
|
||||
|
||||
@ -4,6 +4,7 @@ import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
Eye,
|
||||
TrendingUp,
|
||||
@ -23,7 +24,7 @@ import {
|
||||
Briefcase,
|
||||
MessageSquare,
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface SidebarProps {
|
||||
@ -37,10 +38,28 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [inboxCounts, setInboxCounts] = useState<{ buyer_unread: number; seller_unread: number; total_unread: number } | null>(null)
|
||||
|
||||
const collapsed = controlledCollapsed ?? internalCollapsed
|
||||
const setCollapsed = onCollapsedChange ?? setInternalCollapsed
|
||||
|
||||
// Fetch inbox counts for badge
|
||||
const fetchInboxCounts = useCallback(async () => {
|
||||
try {
|
||||
const counts = await api.getInboxCounts()
|
||||
setInboxCounts(counts)
|
||||
} catch {
|
||||
// Silently fail - user might not be authenticated yet
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchInboxCounts()
|
||||
// Poll every 60 seconds for updates
|
||||
const interval = setInterval(fetchInboxCounts, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchInboxCounts])
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('sidebar-collapsed')
|
||||
if (saved) {
|
||||
@ -104,7 +123,7 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
href: '/terminal/inbox',
|
||||
label: 'INBOX',
|
||||
icon: MessageSquare,
|
||||
badge: null,
|
||||
badge: inboxCounts?.total_unread || null,
|
||||
},
|
||||
{
|
||||
href: '/terminal/sniper',
|
||||
@ -263,8 +282,10 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
"w-4 h-4 transition-all duration-300",
|
||||
isDisabled ? "text-white/20" : isActive(item.href) ? "text-accent" : "text-white/40 group-hover:text-white"
|
||||
)} />
|
||||
{item.badge && typeof item.badge === 'number' && !isDisabled && (
|
||||
<span className="absolute -top-1 -right-1 w-1.5 h-1.5 bg-accent rounded-full animate-pulse" />
|
||||
{item.badge && typeof item.badge === 'number' && !isDisabled && collapsed && (
|
||||
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] bg-accent rounded-full flex items-center justify-center text-[9px] font-bold text-black">
|
||||
{item.badge > 9 ? '9+' : item.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
@ -275,6 +296,11 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{item.badge && typeof item.badge === 'number' && !isDisabled && !collapsed && (
|
||||
<span className="min-w-[18px] h-[18px] bg-accent rounded-full flex items-center justify-center text-[10px] font-bold text-black">
|
||||
{item.badge > 99 ? '99+' : item.badge}
|
||||
</span>
|
||||
)}
|
||||
{isDisabled && !collapsed && <Crown className="w-3 h-3 text-amber-500/40" />}
|
||||
</ItemWrapper>
|
||||
)
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, X, AlertCircle, Info } from 'lucide-react'
|
||||
import { Check, X, AlertCircle, Info, AlertTriangle } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info'
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
interface ToastProps {
|
||||
message: string
|
||||
@ -31,7 +31,7 @@ export function Toast({ message, type = 'success', duration = 4000, onClose }: T
|
||||
setTimeout(onClose, 300)
|
||||
}
|
||||
|
||||
const Icon = type === 'success' ? Check : type === 'error' ? AlertCircle : Info
|
||||
const Icon = type === 'success' ? Check : type === 'error' ? AlertCircle : type === 'warning' ? AlertTriangle : Info
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -40,6 +40,7 @@ export function Toast({ message, type = 'success', duration = 4000, onClose }: T
|
||||
isLeaving ? "translate-y-2 opacity-0" : "translate-y-0 opacity-100",
|
||||
type === 'success' && "bg-accent/10 border-accent/20",
|
||||
type === 'error' && "bg-danger/10 border-danger/20",
|
||||
type === 'warning' && "bg-amber-500/10 border-amber-500/20",
|
||||
type === 'info' && "bg-foreground/5 border-border"
|
||||
)}
|
||||
>
|
||||
@ -47,12 +48,14 @@ export function Toast({ message, type = 'success', duration = 4000, onClose }: T
|
||||
"w-7 h-7 rounded-lg flex items-center justify-center",
|
||||
type === 'success' && "bg-accent/20",
|
||||
type === 'error' && "bg-danger/20",
|
||||
type === 'warning' && "bg-amber-500/20",
|
||||
type === 'info' && "bg-foreground/10"
|
||||
)}>
|
||||
<Icon className={clsx(
|
||||
"w-4 h-4",
|
||||
type === 'success' && "text-accent",
|
||||
type === 'error' && "text-danger",
|
||||
type === 'warning' && "text-amber-500",
|
||||
type === 'info' && "text-foreground-muted"
|
||||
)} />
|
||||
</div>
|
||||
@ -60,6 +63,7 @@ export function Toast({ message, type = 'success', duration = 4000, onClose }: T
|
||||
"text-body-sm",
|
||||
type === 'success' && "text-accent",
|
||||
type === 'error' && "text-danger",
|
||||
type === 'warning' && "text-amber-500",
|
||||
type === 'info' && "text-foreground"
|
||||
)}>{message}</p>
|
||||
<button
|
||||
@ -68,6 +72,7 @@ export function Toast({ message, type = 'success', duration = 4000, onClose }: T
|
||||
"ml-2 p-1 rounded hover:bg-foreground/5 transition-colors",
|
||||
type === 'success' && "text-accent/70 hover:text-accent",
|
||||
type === 'error' && "text-danger/70 hover:text-danger",
|
||||
type === 'warning' && "text-amber-500/70 hover:text-amber-500",
|
||||
type === 'info' && "text-foreground-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
|
||||
276
frontend/src/components/admin/ZonesTab.tsx
Normal file
276
frontend/src/components/admin/ZonesTab.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
RefreshCw,
|
||||
Globe,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Play,
|
||||
Clock,
|
||||
Database,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ZoneStatus {
|
||||
tld: string
|
||||
last_sync: string | null
|
||||
domain_count: number
|
||||
drops_today: number
|
||||
total_drops: number
|
||||
status: 'healthy' | 'stale' | 'never'
|
||||
}
|
||||
|
||||
interface ZoneSyncStatus {
|
||||
zones: ZoneStatus[]
|
||||
summary: {
|
||||
total_zones: number
|
||||
healthy: number
|
||||
stale: number
|
||||
never_synced: number
|
||||
total_drops_today: number
|
||||
total_drops_all: number
|
||||
}
|
||||
}
|
||||
|
||||
export function ZonesTab() {
|
||||
const [status, setStatus] = useState<ZoneSyncStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [syncingSwitch, setSyncingSwitch] = useState(false)
|
||||
const [syncingCzds, setSyncingCzds] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.request<ZoneSyncStatus>('/admin/zone-sync/status')
|
||||
setStatus(data)
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch zone status:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
// Auto-refresh every 30 seconds
|
||||
const interval = setInterval(fetchStatus, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStatus])
|
||||
|
||||
const triggerSwitchSync = async () => {
|
||||
if (syncingSwitch) return
|
||||
setSyncingSwitch(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
await api.request('/admin/zone-sync/switch', { method: 'POST' })
|
||||
setMessage({ type: 'success', text: 'Switch.ch sync started! Check logs for progress.' })
|
||||
// Refresh status after a delay
|
||||
setTimeout(fetchStatus, 5000)
|
||||
} catch (e) {
|
||||
setMessage({ type: 'error', text: e instanceof Error ? e.message : 'Sync failed' })
|
||||
} finally {
|
||||
setSyncingSwitch(false)
|
||||
}
|
||||
}
|
||||
|
||||
const triggerCzdsSync = async () => {
|
||||
if (syncingCzds) return
|
||||
setSyncingCzds(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
await api.request('/admin/zone-sync/czds', { method: 'POST' })
|
||||
setMessage({ type: 'success', text: 'ICANN CZDS sync started (parallel mode)! Check logs for progress.' })
|
||||
// Refresh status after a delay
|
||||
setTimeout(fetchStatus, 5000)
|
||||
} catch (e) {
|
||||
setMessage({ type: 'error', text: e instanceof Error ? e.message : 'Sync failed' })
|
||||
} finally {
|
||||
setSyncingCzds(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return 'Never'
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
|
||||
if (hours < 1) return 'Just now'
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const getStatusIcon = (s: string) => {
|
||||
switch (s) {
|
||||
case 'healthy': return <CheckCircle2 className="w-4 h-4 text-accent" />
|
||||
case 'stale': return <AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||
default: return <XCircle className="w-4 h-4 text-rose-400" />
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-accent animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white/[0.02] border border-white/[0.08] p-4">
|
||||
<div className="flex items-center gap-2 text-white/40 text-xs font-mono uppercase mb-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
Zones
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{status?.summary.total_zones || 0}</div>
|
||||
<div className="text-xs text-white/30">
|
||||
{status?.summary.healthy || 0} healthy
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.02] border border-white/[0.08] p-4">
|
||||
<div className="flex items-center gap-2 text-white/40 text-xs font-mono uppercase mb-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Today
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-accent">{status?.summary.total_drops_today?.toLocaleString() || 0}</div>
|
||||
<div className="text-xs text-white/30">drops detected</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.02] border border-white/[0.08] p-4">
|
||||
<div className="flex items-center gap-2 text-white/40 text-xs font-mono uppercase mb-2">
|
||||
<Database className="w-4 h-4" />
|
||||
Total
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{status?.summary.total_drops_all?.toLocaleString() || 0}</div>
|
||||
<div className="text-xs text-white/30">drops in database</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.02] border border-white/[0.08] p-4">
|
||||
<div className="flex items-center gap-2 text-white/40 text-xs font-mono uppercase mb-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
Status
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{status?.summary.stale || status?.summary.never_synced ? (
|
||||
<>
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400" />
|
||||
<span className="text-amber-400 font-bold">Needs Attention</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-5 h-5 text-accent" />
|
||||
<span className="text-accent font-bold">All Healthy</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<button
|
||||
onClick={triggerSwitchSync}
|
||||
disabled={syncingSwitch}
|
||||
className="flex items-center gap-2 px-4 py-3 bg-white/[0.05] border border-white/[0.08] text-white hover:bg-white/[0.08] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{syncingSwitch ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
Sync Switch.ch (.ch, .li)
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={triggerCzdsSync}
|
||||
disabled={syncingCzds}
|
||||
className="flex items-center gap-2 px-4 py-3 bg-accent/10 border border-accent/30 text-accent hover:bg-accent/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{syncingCzds ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
Sync ICANN CZDS (gTLDs)
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
className="flex items-center gap-2 px-4 py-3 border border-white/[0.08] text-white/60 hover:text-white hover:bg-white/[0.05] transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh Status
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<div className={clsx(
|
||||
"p-4 border",
|
||||
message.type === 'success' ? "bg-accent/10 border-accent/30 text-accent" : "bg-rose-500/10 border-rose-500/30 text-rose-400"
|
||||
)}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zone Table */}
|
||||
<div className="border border-white/[0.08] overflow-hidden">
|
||||
<div className="grid grid-cols-[80px_1fr_120px_120px_120px_100px] gap-4 px-4 py-3 bg-white/[0.02] text-xs font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
|
||||
<div>TLD</div>
|
||||
<div>Last Sync</div>
|
||||
<div className="text-right">Domains</div>
|
||||
<div className="text-right">Today</div>
|
||||
<div className="text-right">Total Drops</div>
|
||||
<div className="text-center">Status</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-white/[0.04]">
|
||||
{status?.zones.map((zone) => (
|
||||
<div
|
||||
key={zone.tld}
|
||||
className="grid grid-cols-[80px_1fr_120px_120px_120px_100px] gap-4 px-4 py-3 items-center hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<div className="font-mono font-bold text-white">.{zone.tld}</div>
|
||||
<div className="text-sm text-white/60">{formatDate(zone.last_sync)}</div>
|
||||
<div className="text-right font-mono text-white/60">{zone.domain_count?.toLocaleString() || '-'}</div>
|
||||
<div className="text-right font-mono text-accent font-bold">{zone.drops_today?.toLocaleString() || '0'}</div>
|
||||
<div className="text-right font-mono text-white/40">{zone.total_drops?.toLocaleString() || '0'}</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{getStatusIcon(zone.status)}
|
||||
<span className={clsx(
|
||||
"text-xs font-mono uppercase",
|
||||
zone.status === 'healthy' ? "text-accent" : zone.status === 'stale' ? "text-amber-400" : "text-rose-400"
|
||||
)}>
|
||||
{zone.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule Info */}
|
||||
<div className="bg-white/[0.02] border border-white/[0.08] p-4">
|
||||
<h3 className="text-sm font-bold text-white mb-3">Automatic Sync Schedule</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="w-4 h-4 text-white/40 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-white font-medium">Switch.ch (.ch, .li)</div>
|
||||
<div className="text-white/40">Daily at 05:00 UTC (06:00 CH)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="w-4 h-4 text-white/40 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-white font-medium">ICANN CZDS (gTLDs)</div>
|
||||
<div className="text-white/40">Daily at 06:00 UTC (07:00 CH)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -14,18 +14,17 @@ import {
|
||||
Check,
|
||||
Zap,
|
||||
Globe,
|
||||
Calendar,
|
||||
Link2,
|
||||
Radio,
|
||||
Eye,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Sparkles,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||
import { formatCountdown, parseIsoAsUtc } from '@/lib/time'
|
||||
import type { AnalyzeResponse, AnalyzeSection, AnalyzeItem } from '@/components/analyze/types'
|
||||
import { VisionSection } from '@/components/analyze/VisionSection'
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
@ -34,43 +33,68 @@ import type { AnalyzeResponse, AnalyzeSection, AnalyzeItem } from '@/components/
|
||||
function getStatusColor(status: string) {
|
||||
switch (status) {
|
||||
case 'pass':
|
||||
return { bg: 'bg-accent/20', text: 'text-accent', border: 'border-accent/40', icon: CheckCircle2 }
|
||||
return { bg: 'bg-accent/10', text: 'text-accent', border: 'border-accent/30', icon: CheckCircle2 }
|
||||
case 'warn':
|
||||
return { bg: 'bg-amber-400/20', text: 'text-amber-300', border: 'border-amber-400/40', icon: AlertTriangle }
|
||||
return { bg: 'bg-amber-400/10', text: 'text-amber-400', border: 'border-amber-400/30', icon: AlertTriangle }
|
||||
case 'fail':
|
||||
return { bg: 'bg-red-500/20', text: 'text-red-400', border: 'border-red-500/40', icon: XCircle }
|
||||
return { bg: 'bg-rose-500/10', text: 'text-rose-400', border: 'border-rose-500/30', icon: XCircle }
|
||||
default:
|
||||
return { bg: 'bg-white/10', text: 'text-white/50', border: 'border-white/20', icon: null }
|
||||
return { bg: 'bg-white/5', text: 'text-white/40', border: 'border-white/10', icon: null }
|
||||
}
|
||||
}
|
||||
|
||||
function getSectionIcon(key: string) {
|
||||
switch (key) {
|
||||
case 'authority':
|
||||
return Shield
|
||||
case 'market':
|
||||
return TrendingUp
|
||||
case 'risk':
|
||||
return AlertTriangle
|
||||
case 'value':
|
||||
return DollarSign
|
||||
default:
|
||||
return Globe
|
||||
function getSectionConfig(key: string) {
|
||||
// Minimalist monochrome style matching Hunt pages
|
||||
const base = {
|
||||
bg: 'bg-white/[0.02]',
|
||||
border: 'border-white/[0.08]',
|
||||
color: 'text-white/60'
|
||||
}
|
||||
}
|
||||
|
||||
function getSectionColor(key: string) {
|
||||
|
||||
switch (key) {
|
||||
case 'authority':
|
||||
return { text: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/30' }
|
||||
return {
|
||||
...base,
|
||||
icon: Shield,
|
||||
description: 'Age, backlinks, trust signals',
|
||||
tooltip: 'Authority measures how established and trusted the domain is.'
|
||||
}
|
||||
case 'market':
|
||||
return { text: 'text-emerald-400', bg: 'bg-emerald-500/10', border: 'border-emerald-500/30' }
|
||||
return {
|
||||
...base,
|
||||
icon: TrendingUp,
|
||||
description: 'Search demand, CPC, TLD availability',
|
||||
tooltip: 'Market data shows commercial potential.'
|
||||
}
|
||||
case 'risk':
|
||||
return { text: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30' }
|
||||
return {
|
||||
...base,
|
||||
icon: AlertTriangle,
|
||||
description: 'Trademarks, blacklists, history',
|
||||
tooltip: 'Risk checks help avoid legal issues.'
|
||||
}
|
||||
case 'value':
|
||||
return { text: 'text-violet-400', bg: 'bg-violet-500/10', border: 'border-violet-500/30' }
|
||||
return {
|
||||
...base,
|
||||
icon: DollarSign,
|
||||
description: 'Estimated worth, comparable sales',
|
||||
tooltip: 'Value estimation based on market data.'
|
||||
}
|
||||
case 'vision':
|
||||
return {
|
||||
...base,
|
||||
icon: Sparkles,
|
||||
color: 'text-accent',
|
||||
description: 'AI business insights',
|
||||
tooltip: 'AI-powered analysis for this domain.'
|
||||
}
|
||||
default:
|
||||
return { text: 'text-white/60', bg: 'bg-white/5', border: 'border-white/20' }
|
||||
return {
|
||||
...base,
|
||||
icon: Globe,
|
||||
description: '',
|
||||
tooltip: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,10 +107,17 @@ async function copyToClipboard(text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
function formatValue(value: unknown, key?: string): string {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value === 'number') {
|
||||
// Format USD values with currency symbol
|
||||
const usdKeys = ['cheapest_registration', 'cheapest_renewal', 'cheapest_transfer', 'renewal_burn', 'estimated_value', 'cpc']
|
||||
if (key && usdKeys.some(k => key.toLowerCase().includes(k.replace('_', '')))) {
|
||||
return `$${value.toFixed(2)}`
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||
if (Array.isArray(value)) return `${value.length} items`
|
||||
return 'Details'
|
||||
@ -96,6 +127,46 @@ function isMatrix(item: AnalyzeItem) {
|
||||
return item.key === 'tld_matrix' && Array.isArray(item.value)
|
||||
}
|
||||
|
||||
function getItemTooltip(key: string): string {
|
||||
const tooltips: Record<string, string> = {
|
||||
// Authority
|
||||
domain_age: 'Registration age of the domain. Older domains typically have more authority and trust in search engines. 5+ years is excellent.',
|
||||
age: 'Registration age of the domain. Older domains typically have more authority and trust in search engines. 5+ years is excellent.',
|
||||
backlinks: 'Number of external websites linking to this domain. More backlinks = higher authority. Quality matters more than quantity.',
|
||||
trust_flow: 'Majestic Trust Flow score (0-100). Measures the quality of backlinks. Higher = more trusted by search engines.',
|
||||
citation_flow: 'Majestic Citation Flow score (0-100). Measures the quantity of backlinks regardless of quality.',
|
||||
radio_test: 'Pronounceability test. Can someone spell the domain correctly after hearing it once? Important for word-of-mouth.',
|
||||
syllables: 'Number of syllables. Fewer is better - 2-3 syllables is ideal for brandability.',
|
||||
|
||||
// Market
|
||||
search_volume: 'Monthly Google searches for the main keyword. Higher = more organic traffic potential.',
|
||||
cpc: 'Google Ads Cost-Per-Click. Higher CPC = more commercial intent. $5+ indicates strong buyer intent.',
|
||||
tld_matrix: 'Availability across popular TLDs (.com, .net, .org etc). Green = available for registration.',
|
||||
competition: 'SEO competition level. Lower = easier to rank. "Low" is ideal for new sites.',
|
||||
|
||||
// Risk
|
||||
trademark: 'USPTO trademark database check. "Clear" means no conflicts found. Always verify before buying.',
|
||||
blacklist: 'Spam and malware blacklist check. "Clean" means domain is not flagged by security services.',
|
||||
archive: 'Wayback Machine first capture date. Shows domain history and previous content.',
|
||||
spam_score: 'Moz Spam Score (0-100). Lower = cleaner history. Above 30% is concerning.',
|
||||
|
||||
// Value
|
||||
estimated_value: 'AI-estimated market value based on comparable sales, length, keywords, and extension.',
|
||||
comps: 'Recently sold domains with similar characteristics. Used to determine market value.',
|
||||
price_range: 'Suggested listing price range based on market analysis.',
|
||||
|
||||
// DNS
|
||||
dns_records: 'Active DNS records. Shows if domain is currently configured.',
|
||||
nameservers: 'Current nameservers. Indicates where domain is hosted.',
|
||||
mx_records: 'Mail exchange records. Shows if email is configured.',
|
||||
|
||||
// General
|
||||
length: 'Character count. Shorter is generally more valuable. Under 8 characters is premium.',
|
||||
extension: 'Top-level domain (.com, .io, etc). .com is most valuable, followed by ccTLDs and new gTLDs.',
|
||||
}
|
||||
return tooltips[key] || ''
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMPONENT
|
||||
// ============================================================================
|
||||
@ -108,7 +179,8 @@ export function AnalyzePanel() {
|
||||
fastMode,
|
||||
setFastMode,
|
||||
sectionVisibility,
|
||||
setSectionVisibility
|
||||
setSectionVisibility,
|
||||
dropStatus,
|
||||
} = useAnalyzePanelStore()
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
@ -119,7 +191,8 @@ export function AnalyzePanel() {
|
||||
authority: true,
|
||||
market: true,
|
||||
risk: true,
|
||||
value: true
|
||||
value: true,
|
||||
vision: true,
|
||||
})
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@ -176,9 +249,16 @@ export function AnalyzePanel() {
|
||||
const visibleSections = useMemo(() => {
|
||||
const sections = data?.sections || []
|
||||
const order = ['authority', 'market', 'risk', 'value']
|
||||
return [...sections]
|
||||
const sorted = [...sections]
|
||||
.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key))
|
||||
.filter((s) => sectionVisibility[s.key] !== false)
|
||||
|
||||
// Append VISION section
|
||||
if (sectionVisibility.vision !== false) {
|
||||
const visionSection: AnalyzeSection = { key: 'vision', title: 'VISION', items: [] }
|
||||
return [...sorted, visionSection]
|
||||
}
|
||||
return sorted
|
||||
}, [data, sectionVisibility])
|
||||
|
||||
// Calculate overall score
|
||||
@ -199,34 +279,33 @@ export function AnalyzePanel() {
|
||||
}, [data])
|
||||
|
||||
const headerDomain = data?.domain || domain || ''
|
||||
const dropCountdown = useMemo(() => formatCountdown(dropStatus?.deletion_date ?? null), [dropStatus])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200]">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/85 backdrop-blur-md" onClick={close} />
|
||||
<div
|
||||
className="absolute inset-0 bg-black/90 backdrop-blur-sm"
|
||||
onClick={close}
|
||||
/>
|
||||
|
||||
{/* Panel - WIDER & MORE READABLE */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-full sm:w-[600px] lg:w-[680px] bg-[#0A0A0A] border-l border-white/10 flex flex-col overflow-hidden shadow-2xl">
|
||||
{/* Panel */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-full sm:w-[560px] lg:w-[640px] bg-[#030303] border-l border-white/[0.08] flex flex-col overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="shrink-0 border-b border-white/10 bg-[#050505]">
|
||||
<div className="shrink-0 border-b border-white/[0.08]">
|
||||
{/* Top Bar */}
|
||||
<div className="px-6 py-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-accent/15 border border-accent/30 flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-mono text-accent uppercase tracking-widest mb-1">Domain Analysis</div>
|
||||
<div className="text-xl font-bold text-white font-mono truncate max-w-[300px]">
|
||||
{headerDomain}
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-mono text-white/50 uppercase tracking-wider mb-1">Domain Analysis</div>
|
||||
<div className="text-xl font-bold text-white font-mono truncate">
|
||||
{headerDomain}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const ok = await copyToClipboard(headerDomain)
|
||||
@ -234,226 +313,290 @@ export function AnalyzePanel() {
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}}
|
||||
className={clsx(
|
||||
"w-10 h-10 flex items-center justify-center border transition-all",
|
||||
copied ? "border-accent bg-accent/20 text-accent" : "border-white/20 text-white/50 hover:text-white hover:bg-white/10"
|
||||
"w-9 h-9 flex items-center justify-center transition-all border border-white/[0.08]",
|
||||
copied ? "text-accent bg-accent/10" : "text-white/50 hover:text-white hover:bg-white/[0.05]"
|
||||
)}
|
||||
title="Copy domain"
|
||||
>
|
||||
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<a
|
||||
href={`https://${encodeURIComponent(headerDomain)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-10 h-10 flex items-center justify-center border border-white/20 text-white/50 hover:text-white hover:bg-white/10 transition-colors"
|
||||
className="w-9 h-9 flex items-center justify-center text-white/50 hover:text-white hover:bg-white/[0.05] transition-colors border border-white/[0.08]"
|
||||
title="Visit domain"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="w-10 h-10 flex items-center justify-center border border-white/20 text-white/50 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-50"
|
||||
className="w-9 h-9 flex items-center justify-center text-white/50 hover:text-white hover:bg-white/[0.05] transition-colors border border-white/[0.08] disabled:opacity-50"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={clsx('w-5 h-5', loading && 'animate-spin')} />
|
||||
<RefreshCw className={clsx('w-4 h-4', loading && 'animate-spin')} />
|
||||
</button>
|
||||
<button
|
||||
onClick={close}
|
||||
className="w-10 h-10 flex items-center justify-center border border-white/20 text-white/50 hover:text-white hover:bg-white/10 transition-colors ml-2"
|
||||
className="w-9 h-9 flex items-center justify-center text-white/50 hover:text-white hover:bg-white/[0.05] transition-colors border border-white/[0.08]"
|
||||
title="Close (ESC)"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score Bar - LARGER */}
|
||||
{/* Score Bar */}
|
||||
{overallScore && !loading && (
|
||||
<div className="px-6 pb-5">
|
||||
<div className="flex items-center gap-4 p-4 bg-white/[0.03] border border-white/10">
|
||||
<div className={clsx(
|
||||
"text-4xl font-bold font-mono",
|
||||
overallScore.score >= 70 ? "text-accent" : overallScore.score >= 40 ? "text-amber-400" : "text-red-400"
|
||||
)}>
|
||||
{overallScore.score}
|
||||
<div className="px-5 pb-4">
|
||||
<div className="flex items-center gap-4 p-4 bg-white/[0.02] border border-white/[0.08]">
|
||||
<div
|
||||
className={clsx(
|
||||
"w-16 h-16 flex items-center justify-center border-2",
|
||||
overallScore.score >= 70 ? "border-accent bg-accent/10 text-accent" :
|
||||
overallScore.score >= 40 ? "border-amber-400 bg-amber-400/10 text-amber-400" : "border-rose-500 bg-rose-500/10 text-rose-400"
|
||||
)}
|
||||
>
|
||||
<span className="text-2xl font-bold font-mono">{overallScore.score}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-mono text-white/50 uppercase tracking-wider mb-2">Overall Score</div>
|
||||
<div className="h-3 bg-white/10 overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-accent transition-all"
|
||||
style={{ width: `${(overallScore.pass / overallScore.total) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-amber-400 transition-all"
|
||||
style={{ width: `${(overallScore.warn / overallScore.total) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-red-500 transition-all"
|
||||
style={{ width: `${(overallScore.fail / overallScore.total) * 100}%` }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-bold text-white mb-2">Health Score</div>
|
||||
<div className="h-2 bg-white/[0.05] overflow-hidden flex mb-2">
|
||||
<div className="h-full bg-accent" style={{ width: `${(overallScore.pass / overallScore.total) * 100}%` }} />
|
||||
<div className="h-full bg-amber-400" style={{ width: `${(overallScore.warn / overallScore.total) * 100}%` }} />
|
||||
<div className="h-full bg-rose-500" style={{ width: `${(overallScore.fail / overallScore.total) * 100}%` }} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs font-mono">
|
||||
<span className="text-accent">{overallScore.pass} passed</span>
|
||||
<span className="text-amber-400">{overallScore.warn} warnings</span>
|
||||
<span className="text-rose-400">{overallScore.fail} failed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-sm font-mono">
|
||||
<span className="text-accent flex items-center gap-2"><CheckCircle2 className="w-4 h-4" /> {overallScore.pass}</span>
|
||||
<span className="text-amber-400 flex items-center gap-2"><AlertTriangle className="w-4 h-4" /> {overallScore.warn}</span>
|
||||
<span className="text-red-400 flex items-center gap-2"><XCircle className="w-4 h-4" /> {overallScore.fail}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mode Toggle */}
|
||||
<div className="px-6 pb-4 flex items-center gap-3">
|
||||
{/* Drop Status Banner */}
|
||||
{dropStatus && (
|
||||
<div className="px-5 pb-3">
|
||||
<div className={clsx(
|
||||
"p-4 border flex items-center justify-between gap-4",
|
||||
dropStatus.status === 'available' ? "border-accent/30 bg-accent/5" :
|
||||
dropStatus.status === 'dropping_soon' ? "border-amber-400/30 bg-amber-400/5" :
|
||||
dropStatus.status === 'taken' ? "border-rose-400/20 bg-rose-400/5" :
|
||||
"border-white/10 bg-white/[0.02]"
|
||||
)}>
|
||||
<div className="flex items-center gap-3">
|
||||
{dropStatus.status === 'available' ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-accent" />
|
||||
) : dropStatus.status === 'dropping_soon' ? (
|
||||
<Clock className="w-5 h-5 text-amber-400" />
|
||||
) : dropStatus.status === 'taken' ? (
|
||||
<XCircle className="w-5 h-5 text-rose-400" />
|
||||
) : (
|
||||
<Globe className="w-5 h-5 text-white/40" />
|
||||
)}
|
||||
<div>
|
||||
<div className={clsx(
|
||||
"text-sm font-bold uppercase tracking-wider",
|
||||
dropStatus.status === 'available' ? "text-accent" :
|
||||
dropStatus.status === 'dropping_soon' ? "text-amber-400" :
|
||||
dropStatus.status === 'taken' ? "text-rose-400" :
|
||||
"text-white/50"
|
||||
)}>
|
||||
{dropStatus.status === 'available' ? 'Available Now' :
|
||||
dropStatus.status === 'dropping_soon' ? 'In Transition' :
|
||||
dropStatus.status === 'taken' ? 'Re-registered' :
|
||||
'Status Unknown'}
|
||||
</div>
|
||||
{dropStatus.status === 'dropping_soon' && dropStatus.deletion_date && (
|
||||
<div className="text-xs font-mono text-amber-400/70">
|
||||
{dropCountdown
|
||||
? `Drops in ${dropCountdown} • ${parseIsoAsUtc(dropStatus.deletion_date).toLocaleDateString()}`
|
||||
: `Drops: ${parseIsoAsUtc(dropStatus.deletion_date).toLocaleDateString()}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{dropStatus.status === 'available' && domain && (
|
||||
<a
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-9 px-4 bg-accent text-black text-[10px] font-black uppercase tracking-widest flex items-center gap-1.5 hover:bg-white transition-all"
|
||||
>
|
||||
<Zap className="w-3 h-3" />
|
||||
Buy Now
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="px-5 pb-3 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setFastMode(!fastMode)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-bold uppercase tracking-wider border transition-all",
|
||||
fastMode
|
||||
? "border-accent/40 bg-accent/15 text-accent"
|
||||
: "border-white/20 text-white/50 hover:text-white hover:bg-white/10"
|
||||
"flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider border transition-all",
|
||||
fastMode ? "text-accent border-accent/30 bg-accent/10" : "text-white/50 border-white/[0.08] hover:text-white hover:bg-white/[0.05]"
|
||||
)}
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
Fast Mode
|
||||
</button>
|
||||
{data?.cached && (
|
||||
<span className="text-sm font-mono text-white/40 px-3 py-2 border border-white/20 bg-white/5">
|
||||
⚡ Cached
|
||||
<span className="text-[10px] font-mono text-white/40 flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
Cached
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body - BETTER SPACING */}
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-10 h-10 text-accent animate-spin mx-auto mb-4" />
|
||||
<div className="text-base font-mono text-white/50">Analyzing domain...</div>
|
||||
<RefreshCw className="w-8 h-8 text-accent animate-spin mx-auto mb-3" />
|
||||
<div className="text-sm font-mono text-white/40">Analyzing domain...</div>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6">
|
||||
<div className="border border-red-500/30 bg-red-500/10 p-6">
|
||||
<div className="text-lg font-bold text-red-400 mb-2">Analysis Failed</div>
|
||||
<div className="text-sm font-mono text-white/60">{error}</div>
|
||||
<div className="p-4">
|
||||
<div className="border border-rose-500/30 bg-rose-500/10 p-4">
|
||||
<div className="text-sm font-bold text-rose-400 mb-1">Analysis Failed</div>
|
||||
<div className="text-xs font-mono text-white/50">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !data ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="text-base font-mono text-white/40">No data available</div>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-sm font-mono text-white/30">No data available</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="p-4 space-y-3">
|
||||
{visibleSections.map((section) => {
|
||||
const SectionIcon = getSectionIcon(section.key)
|
||||
const sectionStyle = getSectionColor(section.key)
|
||||
const config = getSectionConfig(section.key)
|
||||
const SectionIcon = config.icon
|
||||
const isExpanded = expandedSections[section.key] !== false
|
||||
|
||||
return (
|
||||
<div key={section.key} className={clsx("border overflow-hidden", sectionStyle.border, "bg-[#050505]")}>
|
||||
{/* Section Header - LARGER */}
|
||||
<div
|
||||
key={section.key}
|
||||
className="border border-white/[0.06] overflow-hidden bg-[#020202]"
|
||||
>
|
||||
{/* Section Header */}
|
||||
<button
|
||||
onClick={() => toggleSection(section.key)}
|
||||
className={clsx(
|
||||
"w-full px-5 py-4 flex items-center justify-between transition-colors",
|
||||
sectionStyle.bg, "hover:brightness-110"
|
||||
)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between transition-colors group hover:bg-white/[0.03]"
|
||||
title={(config as any).tooltip || ''}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<SectionIcon className={clsx("w-5 h-5", sectionStyle.text)} />
|
||||
<span className={clsx("text-sm font-bold uppercase tracking-wider", sectionStyle.text)}>
|
||||
{section.title}
|
||||
</span>
|
||||
<span className="text-sm font-mono text-white/40 ml-2">
|
||||
{section.items.length} checks
|
||||
</span>
|
||||
<SectionIcon className="w-4 h-4 text-white/60" />
|
||||
<div className="text-left">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-white">
|
||||
{section.title}
|
||||
</span>
|
||||
<div className="text-[10px] font-mono text-white/50">
|
||||
{config.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{section.key !== 'vision' && section.items.length > 0 && (
|
||||
<span className="text-[10px] font-mono text-white/40">
|
||||
{section.items.length}
|
||||
</span>
|
||||
)}
|
||||
{section.key === 'vision' && (
|
||||
<span className="text-[10px] font-mono text-accent uppercase">AI</span>
|
||||
)}
|
||||
<ChevronRight className={clsx(
|
||||
"w-4 h-4 text-white/40 transition-transform",
|
||||
isExpanded && "rotate-90"
|
||||
)} />
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-white/40" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-white/40" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Section Items - BETTER CONTRAST */}
|
||||
{/* Section Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-white/10">
|
||||
{section.items.map((item) => {
|
||||
const statusStyle = getStatusColor(item.status)
|
||||
const StatusIcon = statusStyle.icon
|
||||
<div className="border-t border-white/[0.06]">
|
||||
{section.key === 'vision' ? (
|
||||
<div className="p-4">
|
||||
<VisionSection domain={headerDomain} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-white/[0.05]">
|
||||
{section.items.map((item) => {
|
||||
const statusStyle = getStatusColor(item.status)
|
||||
const tooltip = getItemTooltip(item.key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className="px-5 py-4 border-b border-white/[0.06] last:border-0 hover:bg-white/[0.03] transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Status Indicator - LARGER */}
|
||||
<div className={clsx(
|
||||
"w-10 h-10 flex items-center justify-center shrink-0",
|
||||
statusStyle.bg, statusStyle.border, "border"
|
||||
)}>
|
||||
{StatusIcon && <StatusIcon className={clsx("w-5 h-5", statusStyle.text)} />}
|
||||
</div>
|
||||
|
||||
{/* Content - BETTER READABILITY */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-4 mb-2">
|
||||
<span className="text-base font-medium text-white">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-white/40 uppercase tracking-wider">
|
||||
{item.source}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Value - LARGER TEXT */}
|
||||
<div>
|
||||
{isMatrix(item) ? (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className="px-4 py-3.5 hover:bg-white/[0.02] transition-colors group"
|
||||
title={tooltip || undefined}
|
||||
>
|
||||
{isMatrix(item) ? (
|
||||
/* TLD Matrix - Full Width Layout */
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.source && (
|
||||
<span className="text-[10px] font-mono text-white/40 uppercase">
|
||||
{item.source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
|
||||
{(item.value as any[]).slice(0, 12).map((row: any) => (
|
||||
<div
|
||||
key={String(row.domain)}
|
||||
className={clsx(
|
||||
"px-3 py-2 text-sm font-mono flex items-center justify-between border",
|
||||
"h-10 flex items-center justify-center text-sm font-mono font-medium border",
|
||||
row.status === 'available'
|
||||
? "border-accent/30 bg-accent/10 text-accent"
|
||||
: "border-white/10 bg-white/[0.03] text-white/50"
|
||||
? "bg-accent/10 text-accent border-accent/30"
|
||||
: "bg-white/[0.02] text-white/30 border-white/[0.06]"
|
||||
)}
|
||||
title={`${String(row.domain).split('.').pop()}: ${row.status === 'available' ? 'Available' : 'Taken'}`}
|
||||
>
|
||||
<span className="truncate">{String(row.domain)}</span>
|
||||
{row.status === 'available' && <Check className="w-4 h-4 shrink-0 ml-2" />}
|
||||
.{String(row.domain).split('.').pop()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={clsx(
|
||||
"text-base font-mono",
|
||||
item.status === 'pass' ? "text-white/80" :
|
||||
item.status === 'warn' ? "text-amber-300" :
|
||||
item.status === 'fail' ? "text-red-300" : "text-white/50"
|
||||
)}>
|
||||
{formatValue(item.value)}
|
||||
</div>
|
||||
) : (
|
||||
/* Regular Item - Row Layout */
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.source && (
|
||||
<span className="text-[10px] font-mono text-white/40 uppercase shrink-0">
|
||||
{item.source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details Toggle */}
|
||||
{item.details && Object.keys(item.details).length > 0 && (
|
||||
<details className="mt-3">
|
||||
<summary className="text-sm font-mono text-white/40 cursor-pointer hover:text-white/60 select-none">
|
||||
View raw details
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs font-mono text-white/50 bg-black/50 border border-white/10 p-4 overflow-x-auto">
|
||||
{JSON.stringify(item.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
<span className={clsx(
|
||||
"text-base font-mono font-bold",
|
||||
item.status === 'pass' ? "text-accent" :
|
||||
item.status === 'warn' ? "text-amber-400" :
|
||||
item.status === 'fail' ? "text-rose-400" : "text-white/70"
|
||||
)}>
|
||||
{formatValue(item.value, item.key)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -462,6 +605,31 @@ export function AnalyzePanel() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="shrink-0 border-t border-white/[0.08] px-5 py-3 bg-[#020202]">
|
||||
<div className="flex items-center justify-between text-[11px] font-mono">
|
||||
<span className="text-white/40">Press ESC to close</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href={`https://who.is/whois/${encodeURIComponent(headerDomain)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white/50 hover:text-white transition-colors"
|
||||
>
|
||||
WHOIS →
|
||||
</a>
|
||||
<a
|
||||
href={`https://web.archive.org/web/*/${encodeURIComponent(headerDomain)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white/50 hover:text-white transition-colors"
|
||||
>
|
||||
Archive →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
304
frontend/src/components/analyze/VisionSection.tsx
Normal file
304
frontend/src/components/analyze/VisionSection.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
Sparkles,
|
||||
Lock,
|
||||
RefreshCw,
|
||||
Mail,
|
||||
Target,
|
||||
Coins,
|
||||
Info,
|
||||
Rocket,
|
||||
Users,
|
||||
Radio,
|
||||
Lightbulb,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { useStore } from '@/lib/store'
|
||||
|
||||
type VisionPayload = {
|
||||
domain: string
|
||||
cached: boolean
|
||||
model: string
|
||||
prompt_version: string
|
||||
generated_at: string
|
||||
result: {
|
||||
business_concept: string
|
||||
industry_vertical: string
|
||||
buyer_persona: string
|
||||
cold_email_subject: string
|
||||
cold_email_body: string
|
||||
monetization_idea: string
|
||||
radio_test_score: number
|
||||
reasoning: string
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Card component for consistent styling
|
||||
function VisionCard({
|
||||
icon: Icon,
|
||||
iconColor,
|
||||
title,
|
||||
children,
|
||||
copyValue,
|
||||
className,
|
||||
}: {
|
||||
icon: typeof Target
|
||||
iconColor: string
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
copyValue?: string
|
||||
className?: string
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!copyValue) return
|
||||
const ok = await copyToClipboard(copyValue)
|
||||
setCopied(ok)
|
||||
if (ok) setTimeout(() => setCopied(false), 1200)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("border border-white/[0.06] bg-white/[0.02]", className)}>
|
||||
<div className="px-3 py-2.5 flex items-center justify-between border-b border-white/[0.04]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={clsx("w-3.5 h-3.5", iconColor)} />
|
||||
<span className="text-xs font-bold text-white uppercase tracking-wider">{title}</span>
|
||||
</div>
|
||||
{copyValue && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="w-7 h-7 flex items-center justify-center border border-white/[0.08] text-white/30 hover:text-white hover:bg-white/[0.05] transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-accent" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VisionSection({ domain }: { domain: string }) {
|
||||
const subscription = useStore((s) => s.subscription)
|
||||
const tier = (subscription?.tier || 'scout').toLowerCase()
|
||||
const canUse = tier === 'trader' || tier === 'tycoon'
|
||||
|
||||
const [data, setData] = useState<VisionPayload | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const headline = useMemo(() => domain?.trim().toLowerCase() || '', [domain])
|
||||
|
||||
const run = useCallback(async (opts?: { refresh?: boolean }) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await api.getVision(headline, Boolean(opts?.refresh))
|
||||
setData(res)
|
||||
} catch (e) {
|
||||
setData(null)
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [headline])
|
||||
|
||||
// Upgrade CTA for Scout users
|
||||
if (!canUse) {
|
||||
return (
|
||||
<div className="border border-white/[0.08] bg-white/[0.02] p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 flex items-center justify-center border border-white/[0.08] bg-white/[0.02] shrink-0">
|
||||
<Lock className="w-5 h-5 text-white/30" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<span className="text-white/60">VISION</span>
|
||||
<span className="px-1.5 py-0.5 text-[9px] font-mono uppercase bg-white/[0.08] text-white/50 border border-white/[0.08]">
|
||||
Trader+
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-white/40 mt-1">
|
||||
AI-powered business concept, buyer persona, and outreach strategy for this domain.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 bg-accent text-black text-[10px] font-bold uppercase tracking-wider hover:bg-white transition-colors"
|
||||
>
|
||||
Upgrade to unlock
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[10px] font-mono text-white/40">
|
||||
<Sparkles className="w-3.5 h-3.5 text-accent inline mr-1.5" />
|
||||
Generates business insights via AI
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => run({ refresh: false })}
|
||||
disabled={loading}
|
||||
className={clsx(
|
||||
"h-8 px-3 border text-[10px] font-bold uppercase tracking-wider transition-colors flex items-center gap-1.5",
|
||||
"border-white/[0.08] text-white/50 hover:text-white hover:bg-white/[0.05]",
|
||||
loading && "opacity-60"
|
||||
)}
|
||||
title="Generate (uses cache if available)"
|
||||
>
|
||||
{loading ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
|
||||
{data ? 'Regenerate' : 'Generate'}
|
||||
</button>
|
||||
{data && (
|
||||
<button
|
||||
onClick={() => run({ refresh: true })}
|
||||
disabled={loading}
|
||||
className="h-8 w-8 flex items-center justify-center border border-white/[0.08] text-white/40 hover:text-white hover:bg-white/[0.05] transition-colors"
|
||||
title="Force refresh"
|
||||
>
|
||||
<RefreshCw className={clsx("w-3.5 h-3.5", loading && "animate-spin")} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 border border-rose-500/30 bg-rose-500/10 text-rose-300 text-xs font-mono">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!data && !loading && !error && (
|
||||
<div className="p-6 border border-dashed border-white/[0.08] text-center">
|
||||
<Sparkles className="w-8 h-8 text-white/10 mx-auto mb-2" />
|
||||
<p className="text-xs text-white/30 font-mono">
|
||||
Click <span className="text-white/50 font-bold">Generate</span> to create AI insights
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{data && (
|
||||
<div className="space-y-2">
|
||||
{/* Business Concept */}
|
||||
<VisionCard
|
||||
icon={Rocket}
|
||||
iconColor="text-accent"
|
||||
title="Business Concept"
|
||||
copyValue={data.result.business_concept}
|
||||
>
|
||||
<p className="text-sm text-white/80 leading-relaxed">{data.result.business_concept}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-[9px] font-mono text-white/30 uppercase">Vertical:</span>
|
||||
<span className="text-[10px] font-mono text-white/50 px-1.5 py-0.5 bg-white/[0.05] border border-white/[0.08]">
|
||||
{data.result.industry_vertical}
|
||||
</span>
|
||||
</div>
|
||||
</VisionCard>
|
||||
|
||||
{/* Ideal Buyer */}
|
||||
<VisionCard
|
||||
icon={Users}
|
||||
iconColor="text-emerald-400"
|
||||
title="Ideal Buyer"
|
||||
copyValue={data.result.buyer_persona}
|
||||
>
|
||||
<p className="text-sm text-white/70">{data.result.buyer_persona}</p>
|
||||
</VisionCard>
|
||||
|
||||
{/* Outreach Draft */}
|
||||
<VisionCard
|
||||
icon={Mail}
|
||||
iconColor="text-sky-400"
|
||||
title="Outreach Draft"
|
||||
copyValue={`Subject: ${data.result.cold_email_subject}\n\n${data.result.cold_email_body}`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase mb-1">Subject</div>
|
||||
<div className="text-sm text-white/70">{data.result.cold_email_subject}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase mb-1">Body</div>
|
||||
<div className="text-xs text-white/50 whitespace-pre-wrap leading-relaxed">
|
||||
{data.result.cold_email_body}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VisionCard>
|
||||
|
||||
{/* Monetization + Radio Test Row */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<VisionCard
|
||||
icon={Coins}
|
||||
iconColor="text-amber-400"
|
||||
title="Monetization"
|
||||
copyValue={data.result.monetization_idea}
|
||||
>
|
||||
<p className="text-xs text-white/60 leading-relaxed">{data.result.monetization_idea}</p>
|
||||
</VisionCard>
|
||||
|
||||
<VisionCard
|
||||
icon={Radio}
|
||||
iconColor="text-violet-400"
|
||||
title="Radio Test"
|
||||
copyValue={String(data.result.radio_test_score)}
|
||||
>
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="text-3xl font-bold font-mono text-white">{data.result.radio_test_score}</div>
|
||||
<div className="text-[9px] font-mono text-white/30 text-right">
|
||||
1–10<br/>higher = better
|
||||
</div>
|
||||
</div>
|
||||
</VisionCard>
|
||||
</div>
|
||||
|
||||
{/* Reasoning */}
|
||||
<VisionCard
|
||||
icon={Lightbulb}
|
||||
iconColor="text-white/40"
|
||||
title="Why This Domain Has Value"
|
||||
copyValue={data.result.reasoning}
|
||||
>
|
||||
<p className="text-xs text-white/50 leading-relaxed">{data.result.reasoning}</p>
|
||||
<div className="mt-3 pt-2 border-t border-white/[0.04] flex items-center justify-between text-[9px] font-mono text-white/20">
|
||||
<span>{data.cached ? 'Cached' : 'Fresh'} • {new Date(data.generated_at).toLocaleDateString()}</span>
|
||||
<span>{data.model}</span>
|
||||
</div>
|
||||
</VisionCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||
import { useStore } from '@/lib/store'
|
||||
import {
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
@ -14,7 +14,6 @@ import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Search,
|
||||
Eye,
|
||||
EyeOff,
|
||||
@ -23,6 +22,11 @@ import {
|
||||
X,
|
||||
Filter,
|
||||
Shield,
|
||||
Gavel,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Timer,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@ -109,6 +113,8 @@ interface AuctionsTabProps {
|
||||
|
||||
export function AuctionsTab({ showToast }: AuctionsTabProps) {
|
||||
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
||||
const addDomain = useStore((s) => s.addDomain)
|
||||
const deleteDomain = useStore((s) => s.deleteDomain)
|
||||
|
||||
const [items, setItems] = useState<MarketItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@ -202,7 +208,7 @@ export function AuctionsTab({ showToast }: AuctionsTabProps) {
|
||||
const result = await api.getDomains(1, 100)
|
||||
const domainToDelete = result.domains.find((d: any) => d.name === domain)
|
||||
if (domainToDelete) {
|
||||
await api.deleteDomain(domainToDelete.id)
|
||||
await deleteDomain(domainToDelete.id)
|
||||
setTrackedDomains((prev) => {
|
||||
const next = new Set(Array.from(prev))
|
||||
next.delete(domain)
|
||||
@ -211,7 +217,7 @@ export function AuctionsTab({ showToast }: AuctionsTabProps) {
|
||||
showToast(`Removed: ${domain}`, 'success')
|
||||
}
|
||||
} else {
|
||||
await api.addDomain(domain)
|
||||
await addDomain(domain)
|
||||
setTrackedDomains((prev) => new Set([...Array.from(prev), domain]))
|
||||
showToast(`Tracking: ${domain}`, 'success')
|
||||
}
|
||||
@ -220,7 +226,7 @@ export function AuctionsTab({ showToast }: AuctionsTabProps) {
|
||||
} finally {
|
||||
setTrackingInProgress(null)
|
||||
}
|
||||
}, [trackedDomains, trackingInProgress, showToast])
|
||||
}, [trackedDomains, trackingInProgress, showToast, addDomain, deleteDomain])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
let filtered = items
|
||||
@ -275,110 +281,117 @@ export function AuctionsTab({ showToast }: AuctionsTabProps) {
|
||||
|
||||
const activeFiltersCount = [sourceFilter !== 'all', priceRange !== 'all', tldFilter !== 'all', hideSpam].filter(Boolean).length
|
||||
|
||||
// Loading State
|
||||
if (loading && items.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<Loader2 className="w-8 h-8 text-accent animate-spin mb-4" />
|
||||
<span className="text-xs font-mono text-white/30 uppercase tracking-widest">Loading marketplace...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search & Filters */}
|
||||
<div className="space-y-3">
|
||||
{/* Search */}
|
||||
<div className={clsx(
|
||||
"relative border transition-all duration-200",
|
||||
searchFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]"
|
||||
)}>
|
||||
<div className="flex items-center">
|
||||
<Search className={clsx("w-4 h-4 ml-3 transition-colors", searchFocused ? "text-accent" : "text-white/30")} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => setSearchFocused(false)}
|
||||
placeholder="Filter auctions..."
|
||||
className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button onClick={() => setSearchQuery('')} className="p-3 text-white/30 hover:text-white transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleRefresh} disabled={refreshing} className="p-3 text-white/30 hover:text-white transition-colors">
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||
</button>
|
||||
<div className="space-y-6">
|
||||
{/* Header Stats */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-accent/20 to-accent/5 border border-accent/30 flex items-center justify-center">
|
||||
<Gavel className="w-6 h-6 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-black text-white font-mono tracking-tight">
|
||||
{stats.total.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-white/40 uppercase tracking-widest">Live auctions & listings</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Toggle */}
|
||||
<button
|
||||
onClick={() => setFiltersOpen(!filtersOpen)}
|
||||
className={clsx(
|
||||
"flex items-center justify-between w-full py-2 px-3 border transition-colors",
|
||||
filtersOpen ? "border-accent/30 bg-accent/[0.05]" : "border-white/[0.08] bg-white/[0.02]"
|
||||
)}
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="p-3 border border-white/10 text-white/40 hover:text-accent hover:border-accent/30 hover:bg-accent/5 transition-all"
|
||||
title="Refresh auctions"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-white/40" />
|
||||
<span className="text-xs font-mono text-white/60">Filters</span>
|
||||
{activeFiltersCount > 0 && <span className="px-1.5 py-0.5 text-[9px] font-bold bg-accent text-black">{activeFiltersCount}</span>}
|
||||
</div>
|
||||
<ChevronRight className={clsx("w-4 h-4 text-white/30 transition-transform", filtersOpen && "rotate-90")} />
|
||||
<RefreshCw className={clsx("w-5 h-5", refreshing && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters Panel */}
|
||||
{filtersOpen && (
|
||||
<div className="p-3 border border-white/[0.08] bg-white/[0.02] space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
{/* Source */}
|
||||
<div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider mb-2">Source</div>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'pounce', label: 'Pounce', icon: Diamond },
|
||||
{ value: 'external', label: 'External' },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
onClick={() => setSourceFilter(item.value as SourceFilter)}
|
||||
className={clsx(
|
||||
"flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border transition-colors flex items-center justify-center gap-1",
|
||||
sourceFilter === item.value ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon className="w-3 h-3" />}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Search */}
|
||||
<div className={clsx(
|
||||
"relative border-2 transition-all duration-200",
|
||||
searchFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]"
|
||||
)}>
|
||||
<div className="flex items-center">
|
||||
<Search className={clsx("w-5 h-5 ml-4 transition-colors", searchFocused ? "text-accent" : "text-white/30")} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => setSearchFocused(false)}
|
||||
placeholder="Search auctions and listings..."
|
||||
className="flex-1 bg-transparent px-4 py-4 text-sm text-white placeholder:text-white/20 outline-none font-mono"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button onClick={() => setSearchQuery('')} className="p-4 text-white/30 hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
<button
|
||||
onClick={() => setFiltersOpen(!filtersOpen)}
|
||||
className={clsx(
|
||||
"flex items-center justify-between w-full py-3 px-5 border transition-all",
|
||||
filtersOpen ? "border-accent/30 bg-accent/[0.03]" : "border-white/[0.08] bg-white/[0.02] hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Filter className={clsx("w-4 h-4", filtersOpen ? "text-accent" : "text-white/40")} />
|
||||
<span className={clsx("text-xs font-mono uppercase tracking-widest", filtersOpen ? "text-accent" : "text-white/50")}>
|
||||
Market Filters
|
||||
</span>
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-[9px] font-black bg-accent text-black">{activeFiltersCount}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className={clsx("w-4 h-4 transition-transform", filtersOpen ? "rotate-90 text-accent" : "text-white/30")} />
|
||||
</button>
|
||||
|
||||
{filtersOpen && (
|
||||
<div className="p-5 border border-white/[0.08] bg-white/[0.01] space-y-5 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
{/* Source Filter */}
|
||||
<div>
|
||||
<div className="text-[10px] font-mono text-white/40 uppercase tracking-widest mb-3">Source</div>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ value: 'all', label: 'All Sources' },
|
||||
{ value: 'pounce', label: 'Pounce Direct', icon: Diamond },
|
||||
{ value: 'external', label: 'External' },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
onClick={() => setSourceFilter(item.value as SourceFilter)}
|
||||
className={clsx(
|
||||
"flex-1 py-3 text-xs font-bold uppercase tracking-wider border transition-all flex items-center justify-center gap-2",
|
||||
sourceFilter === item.value
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-white/[0.08] text-white/40 hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon className="w-4 h-4" />}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TLD */}
|
||||
{/* Price & TLD */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider mb-2">TLD</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{['all', 'com', 'ai', 'io', 'net'].map((tld) => (
|
||||
<button
|
||||
key={tld}
|
||||
onClick={() => setTldFilter(tld)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 text-[10px] font-mono uppercase border transition-colors",
|
||||
tldFilter === tld ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
|
||||
)}
|
||||
>
|
||||
{tld === 'all' ? 'All' : `.${tld}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider mb-2">Price</div>
|
||||
<div className="text-[10px] font-mono text-white/40 uppercase tracking-widest mb-3">Price Range</div>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ value: 'all', label: 'All' },
|
||||
@ -390,8 +403,10 @@ export function AuctionsTab({ showToast }: AuctionsTabProps) {
|
||||
key={item.value}
|
||||
onClick={() => setPriceRange(item.value as PriceRange)}
|
||||
className={clsx(
|
||||
"flex-1 py-1.5 text-[10px] font-mono border transition-colors",
|
||||
priceRange === item.value ? "border-amber-400 bg-amber-400/10 text-amber-400" : "border-white/[0.08] text-white/40"
|
||||
"flex-1 py-3 text-xs font-mono border transition-all",
|
||||
priceRange === item.value
|
||||
? "border-amber-400 bg-amber-400/10 text-amber-400 font-bold"
|
||||
: "border-white/[0.08] text-white/40 hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
@ -400,241 +415,328 @@ export function AuctionsTab({ showToast }: AuctionsTabProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spam Filter */}
|
||||
<button
|
||||
onClick={() => setHideSpam(!hideSpam)}
|
||||
className={clsx(
|
||||
"flex items-center justify-between w-full py-2 px-3 border transition-colors",
|
||||
hideSpam ? "border-white/20 bg-white/[0.05]" : "border-white/[0.08]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ban className="w-4 h-4 text-white/40" />
|
||||
<span className="text-xs font-mono text-white/60">Hide spam domains</span>
|
||||
<div>
|
||||
<div className="text-[10px] font-mono text-white/40 uppercase tracking-widest mb-3">TLD Filter</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{['all', 'com', 'ai', 'io', 'net', 'ch'].map((tld) => (
|
||||
<button
|
||||
key={tld}
|
||||
onClick={() => setTldFilter(tld)}
|
||||
className={clsx(
|
||||
"px-4 py-3 text-xs font-mono uppercase border transition-all",
|
||||
tldFilter === tld
|
||||
? "border-accent bg-accent/10 text-accent font-bold"
|
||||
: "border-white/[0.08] text-white/40 hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
{tld === 'all' ? 'All' : `.${tld}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={clsx("w-4 h-4 border flex items-center justify-center", hideSpam ? "border-accent bg-accent" : "border-white/30")}>
|
||||
{hideSpam && <span className="text-black text-[10px] font-bold">✓</span>}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hide Spam */}
|
||||
<button
|
||||
onClick={() => setHideSpam(!hideSpam)}
|
||||
className={clsx(
|
||||
"flex items-center justify-between w-full py-3 px-4 border transition-all",
|
||||
hideSpam ? "border-accent/30 bg-accent/5" : "border-white/[0.08] hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Ban className={clsx("w-4 h-4", hideSpam ? "text-accent" : "text-white/30")} />
|
||||
<span className={clsx("text-xs font-mono uppercase tracking-wider", hideSpam ? "text-accent" : "text-white/50")}>
|
||||
Hide spam domains (numbers, hyphens)
|
||||
</span>
|
||||
</div>
|
||||
<div className={clsx(
|
||||
"w-5 h-5 border flex items-center justify-center transition-all",
|
||||
hideSpam ? "border-accent bg-accent" : "border-white/20"
|
||||
)}>
|
||||
{hideSpam && <CheckCircle2 className="w-3 h-3 text-black" />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Info */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-3 text-[11px] font-mono text-white/40 uppercase tracking-widest">
|
||||
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" />
|
||||
<span>{filteredItems.length} active listings</span>
|
||||
{stats.highScore > 0 && (
|
||||
<>
|
||||
<span className="text-white/20">•</span>
|
||||
<span className="text-accent">{stats.highScore} premium</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<span className="text-[11px] font-mono text-white/30 uppercase tracking-widest">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-white/40">
|
||||
<span>{filteredItems.length} domains shown</span>
|
||||
<span>{filteredItems.filter((i) => i.pounce_score >= 80).length} high score</span>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{/* Results Table */}
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="text-center py-16 border border-dashed border-white/[0.08]">
|
||||
<Search className="w-8 h-8 text-white/10 mx-auto mb-3" />
|
||||
<p className="text-white/40 text-sm font-mono">No domains found</p>
|
||||
<p className="text-white/25 text-xs font-mono mt-1">Try adjusting filters</p>
|
||||
<div className="text-center py-24 border border-dashed border-white/[0.08] bg-white/[0.01]">
|
||||
<Search className="w-16 h-16 text-white/5 mx-auto mb-6" />
|
||||
<p className="text-white/50 text-sm font-mono uppercase tracking-widest font-bold">No auctions found</p>
|
||||
<p className="text-white/20 text-xs font-mono mt-3 uppercase tracking-wider max-w-sm mx-auto leading-relaxed">
|
||||
Try adjusting your search criteria or filters
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
|
||||
{/* Desktop Table Header */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_80px_100px_80px_120px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
|
||||
<button onClick={() => handleSort('domain')} className="flex items-center gap-1 hover:text-white/60 text-left">
|
||||
Domain
|
||||
{sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
<div className="border border-white/[0.08] bg-[#020202] overflow-hidden">
|
||||
{/* Table Header */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_80px_120px_100px_180px] gap-6 px-6 py-4 text-[10px] font-mono text-white/40 uppercase tracking-[0.15em] border-b border-white/[0.08] bg-white/[0.02]">
|
||||
<button
|
||||
onClick={() => handleSort('domain')}
|
||||
className="flex items-center gap-2 hover:text-white transition-colors text-left"
|
||||
>
|
||||
<span className={clsx(sortField === 'domain' && "text-accent font-bold")}>Domain</span>
|
||||
{sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3 text-accent" /> : <ChevronDown className="w-3 h-3 text-accent" />)}
|
||||
</button>
|
||||
<button onClick={() => handleSort('score')} className="flex items-center gap-1 justify-center hover:text-white/60">
|
||||
Score
|
||||
{sortField === 'score' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
<button
|
||||
onClick={() => handleSort('score')}
|
||||
className="flex items-center gap-2 justify-center hover:text-white transition-colors"
|
||||
>
|
||||
<span className={clsx(sortField === 'score' && "text-accent font-bold")}>Score</span>
|
||||
{sortField === 'score' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3 text-accent" /> : <ChevronDown className="w-3 h-3 text-accent" />)}
|
||||
</button>
|
||||
<button onClick={() => handleSort('price')} className="flex items-center gap-1 justify-end hover:text-white/60">
|
||||
Price
|
||||
{sortField === 'price' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
<button
|
||||
onClick={() => handleSort('price')}
|
||||
className="flex items-center gap-2 justify-end hover:text-white transition-colors"
|
||||
>
|
||||
<span className={clsx(sortField === 'price' && "text-accent font-bold")}>Price</span>
|
||||
{sortField === 'price' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3 text-accent" /> : <ChevronDown className="w-3 h-3 text-accent" />)}
|
||||
</button>
|
||||
<button onClick={() => handleSort('time')} className="flex items-center gap-1 justify-center hover:text-white/60">
|
||||
Time
|
||||
{sortField === 'time' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
<button
|
||||
onClick={() => handleSort('time')}
|
||||
className="flex items-center gap-2 justify-center hover:text-white transition-colors"
|
||||
>
|
||||
<span className={clsx(sortField === 'time' && "text-accent font-bold")}>Ends</span>
|
||||
{sortField === 'time' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3 text-accent" /> : <ChevronDown className="w-3 h-3 text-accent" />)}
|
||||
</button>
|
||||
<div className="text-right">Actions</div>
|
||||
</div>
|
||||
|
||||
{filteredItems.map((item) => {
|
||||
const timeLeftSec = getSecondsUntilEnd(item.end_time)
|
||||
const isUrgent = timeLeftSec > 0 && timeLeftSec < 3600
|
||||
const isPounce = item.is_pounce
|
||||
const displayTime = item.status === 'auction' ? calcTimeRemaining(item.end_time) : null
|
||||
const isTracked = trackedDomains.has(item.domain)
|
||||
const isTracking = trackingInProgress === item.domain
|
||||
{/* Table Body */}
|
||||
<div className="divide-y divide-white/[0.04]">
|
||||
{filteredItems.map((item) => {
|
||||
const timeLeftSec = getSecondsUntilEnd(item.end_time)
|
||||
const isUrgent = timeLeftSec > 0 && timeLeftSec < 3600
|
||||
const isPounce = item.is_pounce
|
||||
const displayTime = item.status === 'auction' ? calcTimeRemaining(item.end_time) : null
|
||||
const isTracked = trackedDomains.has(item.domain)
|
||||
const isTracking = trackingInProgress === item.domain
|
||||
|
||||
return (
|
||||
<div key={item.id} className={clsx("bg-[#020202] hover:bg-white/[0.02] transition-all", isPounce && "bg-accent/[0.02]")}>
|
||||
{/* Mobile Row */}
|
||||
<div className="lg:hidden p-3">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className={clsx("w-8 h-8 flex items-center justify-center border shrink-0", isPounce ? "bg-accent/10 border-accent/20" : "bg-white/[0.02] border-white/[0.06]")}>
|
||||
{isPounce ? <Diamond className="w-4 h-4 text-accent" /> : <span className="text-[9px] font-mono text-white/40">{item.source.substring(0, 2).toUpperCase()}</span>}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<button onClick={() => openAnalyze(item.domain)} className="text-sm font-bold text-white font-mono truncate text-left">
|
||||
return (
|
||||
<div key={item.id} className={clsx("group hover:bg-white/[0.02] transition-all", isPounce && "bg-accent/[0.02]")}>
|
||||
{/* Mobile Row */}
|
||||
<div className="lg:hidden p-5">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
onClick={() => openAnalyze(item.domain)}
|
||||
className="text-lg font-bold text-white font-mono truncate block text-left hover:text-accent transition-colors"
|
||||
>
|
||||
{item.domain}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono text-white/30">
|
||||
<span className="uppercase">{item.source}</span>
|
||||
<span className="text-white/10">|</span>
|
||||
<span className={clsx(isUrgent && "text-orange-400")}>{isPounce ? 'Instant' : displayTime || 'N/A'}</span>
|
||||
<div className="flex items-center gap-2 mt-2 text-[10px] font-mono text-white/30 uppercase tracking-wider">
|
||||
<span className="bg-white/5 px-2 py-0.5 border border-white/5">{item.source}</span>
|
||||
{isPounce && item.verified && <ShieldCheck className="w-3.5 h-3.5 text-accent" />}
|
||||
<span className={clsx(isUrgent && "text-orange-400 font-bold")}>
|
||||
{isPounce ? 'INSTANT' : displayTime || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
<div className={clsx(
|
||||
"text-lg font-black font-mono tracking-tight",
|
||||
isPounce ? "text-accent" : "text-white"
|
||||
)}>
|
||||
{formatPrice(item.price)}
|
||||
</div>
|
||||
<div className={clsx(
|
||||
"text-[10px] font-mono px-2 py-0.5 mt-1 inline-block border",
|
||||
item.pounce_score >= 80 ? "text-accent bg-accent/5 border-accent/20" :
|
||||
item.pounce_score >= 50 ? "text-amber-400 bg-amber-400/5 border-amber-400/20" :
|
||||
"text-white/30 bg-white/5 border-white/5"
|
||||
)}>
|
||||
SCORE {item.pounce_score}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
<div className={clsx("text-base font-bold font-mono", isPounce ? "text-accent" : "text-white")}>{formatPrice(item.price)}</div>
|
||||
<div className={clsx("text-[9px] font-mono px-1 py-0.5 inline-block", item.pounce_score >= 80 ? "text-accent bg-accent/10" : item.pounce_score >= 50 ? "text-amber-400 bg-amber-400/10" : "text-white/30 bg-white/5")}>
|
||||
Score {item.pounce_score}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleTrack(item.domain)}
|
||||
disabled={isTracking}
|
||||
className={clsx(
|
||||
"flex-1 h-12 text-xs font-bold uppercase tracking-widest border flex items-center justify-center gap-2 transition-all",
|
||||
isTracked
|
||||
? "border-accent bg-accent/5 text-accent"
|
||||
: "border-white/10 text-white/50 hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
{isTracking ? <Loader2 className="w-4 h-4 animate-spin" /> : isTracked ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||||
{isTracked ? 'Tracked' : 'Track'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => openAnalyze(item.domain)}
|
||||
className="w-14 h-12 border border-white/10 text-white/50 flex items-center justify-center hover:text-accent hover:border-accent/30 hover:bg-accent/5 transition-all"
|
||||
>
|
||||
<Shield className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={item.url}
|
||||
target={isPounce ? '_self' : '_blank'}
|
||||
rel={isPounce ? undefined : 'noopener noreferrer'}
|
||||
className={clsx(
|
||||
"flex-1 h-12 text-xs font-black uppercase tracking-widest flex items-center justify-center gap-2 transition-all",
|
||||
isPounce ? "bg-accent text-black hover:bg-white" : "bg-white/10 text-white hover:bg-white/20"
|
||||
)}
|
||||
>
|
||||
{isPounce ? 'Buy' : 'Bid'}
|
||||
{!isPounce && <ExternalLink className="w-4 h-4" />}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleTrack(item.domain)}
|
||||
disabled={isTracking}
|
||||
className={clsx(
|
||||
"flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border flex items-center justify-center gap-1.5 transition-all",
|
||||
isTracked ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
|
||||
)}
|
||||
>
|
||||
{isTracking ? <Loader2 className="w-3 h-3 animate-spin" /> : isTracked ? <Eye className="w-3 h-3" /> : <EyeOff className="w-3 h-3" />}
|
||||
{isTracked ? 'Tracked' : 'Track'}
|
||||
</button>
|
||||
|
||||
<button onClick={() => openAnalyze(item.domain)} className="w-10 py-2 text-[10px] font-bold uppercase tracking-wider border border-white/[0.08] text-white/50 flex items-center justify-center transition-all hover:text-white hover:bg-white/5">
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={item.url}
|
||||
target={isPounce ? '_self' : '_blank'}
|
||||
rel={isPounce ? undefined : 'noopener noreferrer'}
|
||||
className={clsx("flex-1 py-2 text-[10px] font-bold uppercase tracking-wider flex items-center justify-center gap-1.5 transition-all", isPounce ? "bg-accent text-black" : "bg-white/10 text-white")}
|
||||
>
|
||||
{isPounce ? 'Buy' : 'Bid'}
|
||||
{!isPounce && <ExternalLink className="w-3 h-3" />}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Row */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_80px_100px_80px_120px] gap-4 items-center p-3 group">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className={clsx("w-8 h-8 flex items-center justify-center border shrink-0", isPounce ? "bg-accent/10 border-accent/20" : "bg-white/[0.02] border-white/[0.06]")}>
|
||||
{isPounce ? <Diamond className="w-4 h-4 text-accent" /> : <span className="text-[9px] font-mono text-white/40">{item.source.substring(0, 2).toUpperCase()}</span>}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<button onClick={() => openAnalyze(item.domain)} className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left">
|
||||
{/* Desktop Row */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_80px_120px_100px_180px] gap-6 items-center px-6 py-4">
|
||||
{/* Domain */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
onClick={() => openAnalyze(item.domain)}
|
||||
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left"
|
||||
>
|
||||
{item.domain}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono text-white/30">
|
||||
<span className="uppercase">{item.source}</span>
|
||||
{isPounce && item.verified && (
|
||||
<>
|
||||
<span className="text-white/10">|</span>
|
||||
<span className="text-accent flex items-center gap-0.5">
|
||||
<ShieldCheck className="w-3 h-3" />
|
||||
Verified
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{item.num_bids ? (
|
||||
<>
|
||||
<span className="text-white/10">|</span>
|
||||
{item.num_bids} bids
|
||||
</>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2 text-[9px] font-mono text-white/20 uppercase tracking-wider opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span>{item.source}</span>
|
||||
{isPounce && item.verified && <ShieldCheck className="w-3 h-3 text-accent" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-16 text-center shrink-0">
|
||||
<span className={clsx("text-xs font-mono font-bold px-2 py-0.5", item.pounce_score >= 80 ? "text-accent bg-accent/10" : item.pounce_score >= 50 ? "text-amber-400 bg-amber-400/10" : "text-white/40 bg-white/5")}>
|
||||
{item.pounce_score}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-24 text-right shrink-0">
|
||||
<div className={clsx("font-mono text-sm font-bold", isPounce ? "text-accent" : "text-white")}>{formatPrice(item.price)}</div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase">{item.price_type === 'bid' ? 'Bid' : 'Buy Now'}</div>
|
||||
</div>
|
||||
|
||||
<div className="w-20 text-center shrink-0">
|
||||
{isPounce ? (
|
||||
<span className="text-xs text-accent font-mono flex items-center justify-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
Instant
|
||||
{/* Score */}
|
||||
<div className="text-center">
|
||||
<span className={clsx(
|
||||
"text-xs font-mono font-bold px-3 py-1 border inline-block",
|
||||
item.pounce_score >= 80 ? "text-accent bg-accent/5 border-accent/20" :
|
||||
item.pounce_score >= 50 ? "text-amber-400 bg-amber-400/5 border-amber-400/20" :
|
||||
"text-white/30 bg-white/5 border-white/5"
|
||||
)}>
|
||||
{item.pounce_score}
|
||||
</span>
|
||||
) : (
|
||||
<span className={clsx("text-xs font-mono", isUrgent ? "text-orange-400" : "text-white/50")}>{displayTime || 'N/A'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleTrack(item.domain)}
|
||||
disabled={isTracking}
|
||||
className={clsx(
|
||||
"w-7 h-7 flex items-center justify-center border transition-colors",
|
||||
isTracked ? "bg-accent/10 text-accent border-accent/20 hover:bg-red-500/10 hover:text-red-400 hover:border-red-500/20" : "text-white/30 border-white/10 hover:text-accent hover:bg-accent/10 hover:border-accent/20"
|
||||
{/* Price */}
|
||||
<div className="text-right">
|
||||
<div className={clsx(
|
||||
"font-mono text-sm font-bold tracking-tight",
|
||||
isPounce ? "text-accent" : "text-white"
|
||||
)}>
|
||||
{formatPrice(item.price)}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-white/20 uppercase tracking-wider">
|
||||
{item.price_type === 'bid' ? 'Current Bid' : 'Buy Now'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="text-center">
|
||||
{isPounce ? (
|
||||
<span className="text-xs text-accent font-mono font-bold flex items-center justify-center gap-1.5 uppercase tracking-wider">
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
Instant
|
||||
</span>
|
||||
) : (
|
||||
<span className={clsx(
|
||||
"text-xs font-mono uppercase tracking-wider flex items-center justify-center gap-1.5",
|
||||
isUrgent ? "text-orange-400 font-bold" : "text-white/40"
|
||||
)}>
|
||||
{isUrgent && <Timer className="w-3.5 h-3.5" />}
|
||||
{displayTime || 'N/A'}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
{isTracking ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : isTracked ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button onClick={() => openAnalyze(item.domain)} className="w-7 h-7 flex items-center justify-center border transition-colors text-white/30 border-white/10 hover:text-accent hover:bg-accent/10 hover:border-accent/20">
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 opacity-40 group-hover:opacity-100 transition-all">
|
||||
<button
|
||||
onClick={() => handleTrack(item.domain)}
|
||||
disabled={isTracking}
|
||||
className={clsx(
|
||||
"w-10 h-10 flex items-center justify-center border transition-all",
|
||||
isTracked
|
||||
? "bg-accent/5 text-accent border-accent/20 hover:bg-red-500/5 hover:text-red-400 hover:border-red-500/20"
|
||||
: "text-white/50 border-white/10 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
title={isTracked ? "Untrack" : "Track Domain"}
|
||||
>
|
||||
{isTracking ? <Loader2 className="w-4 h-4 animate-spin" /> : isTracked ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={item.url}
|
||||
target={isPounce ? '_self' : '_blank'}
|
||||
rel={isPounce ? undefined : 'noopener noreferrer'}
|
||||
className={clsx("h-7 px-3 flex items-center gap-1.5 text-xs font-bold transition-colors", isPounce ? "bg-accent text-black hover:bg-white" : "bg-white/10 text-white hover:bg-white/20")}
|
||||
>
|
||||
{isPounce ? 'Buy' : 'Bid'}
|
||||
{!isPounce && <ExternalLink className="w-3 h-3" />}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => openAnalyze(item.domain)}
|
||||
className="w-10 h-10 flex items-center justify-center border border-white/10 text-white/50 hover:text-accent hover:border-accent/30 hover:bg-accent/5 transition-all"
|
||||
title="Analyze"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={item.url}
|
||||
target={isPounce ? '_self' : '_blank'}
|
||||
rel={isPounce ? undefined : 'noopener noreferrer'}
|
||||
className={clsx(
|
||||
"h-10 px-5 flex items-center gap-2 text-[10px] font-black uppercase tracking-widest transition-all",
|
||||
isPounce
|
||||
? "bg-accent text-black hover:bg-white"
|
||||
: "bg-white/10 text-white hover:bg-white/20"
|
||||
)}
|
||||
>
|
||||
{isPounce ? 'Buy' : 'Bid'}
|
||||
{!isPounce && <ExternalLink className="w-3.5 h-3.5" />}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div className="text-[10px] text-white/40 font-mono uppercase tracking-wider">
|
||||
Page {page}/{totalPages}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-white/50 font-mono px-2">
|
||||
{page}/{totalPages}
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<button
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="w-12 h-12 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-20 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center bg-white/[0.02] border border-white/[0.08] px-6 h-12">
|
||||
<span className="text-xs text-white/50 font-mono uppercase tracking-widest">
|
||||
Page <span className="text-white font-bold mx-1">{page}</span> / {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="w-12 h-12 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-20 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -3,21 +3,23 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Eye,
|
||||
Wand2,
|
||||
Settings,
|
||||
Zap,
|
||||
Copy,
|
||||
Check,
|
||||
ShoppingCart,
|
||||
Star,
|
||||
Lightbulb,
|
||||
Sparkles,
|
||||
Lock,
|
||||
RefreshCw,
|
||||
Brain,
|
||||
Dices,
|
||||
ChevronRight,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||
import { useStore } from '@/lib/store'
|
||||
@ -27,415 +29,349 @@ import { useStore } from '@/lib/store'
|
||||
// ============================================================================
|
||||
|
||||
const PATTERNS = [
|
||||
{
|
||||
key: 'cvcvc',
|
||||
label: 'CVCVC',
|
||||
desc: 'Classic 5-letter brandables',
|
||||
examples: ['Zalor', 'Mivex', 'Ronix'],
|
||||
color: 'accent'
|
||||
},
|
||||
{
|
||||
key: 'cvccv',
|
||||
label: 'CVCCV',
|
||||
desc: 'Punchy 5-letter names',
|
||||
examples: ['Bento', 'Salvo', 'Vento'],
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
key: 'human',
|
||||
label: 'Human',
|
||||
desc: 'AI agent ready names',
|
||||
examples: ['Siri', 'Alexa', 'Levi'],
|
||||
color: 'purple'
|
||||
},
|
||||
{ key: 'cvcvc', label: 'CVCVC', example: 'Zalor', desc: 'Classic 5-letter' },
|
||||
{ key: 'cvccv', label: 'CVCCV', example: 'Bento', desc: 'Punchy sound' },
|
||||
{ key: 'human', label: 'Human', example: 'Alexa', desc: 'AI agent names' },
|
||||
]
|
||||
|
||||
const TLDS = [
|
||||
{ tld: 'com', premium: true, label: '.com' },
|
||||
{ tld: 'io', premium: true, label: '.io' },
|
||||
{ tld: 'ai', premium: true, label: '.ai' },
|
||||
{ tld: 'co', premium: false, label: '.co' },
|
||||
{ tld: 'net', premium: false, label: '.net' },
|
||||
{ tld: 'org', premium: false, label: '.org' },
|
||||
{ tld: 'app', premium: false, label: '.app' },
|
||||
{ tld: 'dev', premium: false, label: '.dev' },
|
||||
]
|
||||
const TLDS = ['com', 'io', 'ai', 'co', 'net', 'app']
|
||||
|
||||
// ============================================================================
|
||||
// COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function BrandableForgeTab({ showToast }: { showToast: (message: string, type?: any) => void }) {
|
||||
export function BrandableForgeTab({ showToast }: { showToast: (msg: string, type?: any) => void }) {
|
||||
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
||||
const addDomain = useStore((s) => s.addDomain)
|
||||
const subscription = useStore((s) => s.subscription)
|
||||
const tier = (subscription?.tier || '').toLowerCase()
|
||||
const hasAI = tier === 'trader' || tier === 'tycoon'
|
||||
|
||||
// Config State
|
||||
// Mode selection - AI mode by default if available
|
||||
const [mode, setMode] = useState<'pattern' | 'ai'>(hasAI ? 'ai' : 'pattern')
|
||||
|
||||
// Config
|
||||
const [pattern, setPattern] = useState('cvcvc')
|
||||
const [selectedTlds, setSelectedTlds] = useState<string[]>(['com', 'io'])
|
||||
const [limit, setLimit] = useState(30)
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
const [tlds, setTlds] = useState(['com', 'io'])
|
||||
const [concept, setConcept] = useState('')
|
||||
|
||||
// Results State
|
||||
// State
|
||||
const [results, setResults] = useState<Array<{ domain: string }>>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [items, setItems] = useState<Array<{ domain: string; status: string }>>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [tracking, setTracking] = useState<string | null>(null)
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
const [tracking, setTracking] = useState<string | null>(null)
|
||||
|
||||
const toggleTld = useCallback((tld: string) => {
|
||||
setSelectedTlds((prev) =>
|
||||
prev.includes(tld) ? prev.filter((t) => t !== tld) : [...prev, tld]
|
||||
)
|
||||
}, [])
|
||||
|
||||
const copyDomain = useCallback((domain: string) => {
|
||||
navigator.clipboard.writeText(domain)
|
||||
setCopied(domain)
|
||||
setTimeout(() => setCopied(null), 1500)
|
||||
}, [])
|
||||
|
||||
const copyAll = useCallback(() => {
|
||||
if (items.length === 0) return
|
||||
navigator.clipboard.writeText(items.map(i => i.domain).join('\n'))
|
||||
showToast(`Copied ${items.length} domains to clipboard`, 'success')
|
||||
}, [items, showToast])
|
||||
|
||||
const run = useCallback(async () => {
|
||||
if (selectedTlds.length === 0) {
|
||||
// Generate from pattern
|
||||
const generatePattern = useCallback(async () => {
|
||||
if (tlds.length === 0) {
|
||||
showToast('Select at least one TLD', 'error')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setItems([])
|
||||
setResults([])
|
||||
try {
|
||||
const res = await api.huntBrandables({ pattern, tlds: selectedTlds, limit, max_checks: 400 })
|
||||
setItems(res.items.map((i) => ({ domain: i.domain, status: i.status })))
|
||||
if (res.items.length === 0) {
|
||||
showToast('No available domains found. Try different settings.', 'info')
|
||||
} else {
|
||||
showToast(`Found ${res.items.length} available brandable domains!`, 'success')
|
||||
}
|
||||
const res = await api.huntBrandables({ pattern, tlds, limit: 24, max_checks: 300 })
|
||||
setResults(res.items.map(i => ({ domain: i.domain })))
|
||||
showToast(`Generated ${res.items.length} assets!`, 'success')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setError(msg)
|
||||
showToast(msg, 'error')
|
||||
setItems([])
|
||||
showToast('Generation failed', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [pattern, selectedTlds, limit, showToast])
|
||||
}, [pattern, tlds, showToast])
|
||||
|
||||
const track = useCallback(
|
||||
async (domain: string) => {
|
||||
if (tracking) return
|
||||
setTracking(domain)
|
||||
try {
|
||||
await addDomain(domain)
|
||||
showToast(`Added to watchlist: ${domain}`, 'success')
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : 'Failed to track domain', 'error')
|
||||
} finally {
|
||||
setTracking(null)
|
||||
// Generate from AI concept
|
||||
const generateFromConcept = useCallback(async () => {
|
||||
if (!concept.trim() || !hasAI) return
|
||||
setAiLoading(true)
|
||||
setResults([])
|
||||
try {
|
||||
const res = await api.generateBrandableNames(concept.trim(), undefined, 15)
|
||||
if (res.names?.length) {
|
||||
const checkRes = await api.huntKeywords({ keywords: res.names, tlds })
|
||||
const available = checkRes.items.filter(i => i.status === 'available')
|
||||
setResults(available.map(i => ({ domain: i.domain })))
|
||||
showToast(`Found ${available.length} free domains via AI!`, 'success')
|
||||
}
|
||||
},
|
||||
[addDomain, showToast, tracking]
|
||||
)
|
||||
} catch (e) {
|
||||
showToast('AI generation failed', 'error')
|
||||
} finally {
|
||||
setAiLoading(false)
|
||||
}
|
||||
}, [concept, hasAI, tlds, showToast])
|
||||
|
||||
const currentPattern = PATTERNS.find(p => p.key === pattern)
|
||||
// Actions
|
||||
const copy = (domain: string) => {
|
||||
navigator.clipboard.writeText(domain)
|
||||
setCopied(domain)
|
||||
setTimeout(() => setCopied(null), 1500)
|
||||
}
|
||||
|
||||
const copyAll = () => {
|
||||
navigator.clipboard.writeText(results.map(r => r.domain).join('\n'))
|
||||
showToast(`Copied ${results.length} domains`, 'success')
|
||||
}
|
||||
|
||||
const track = async (domain: string) => {
|
||||
if (tracking) return
|
||||
setTracking(domain)
|
||||
try {
|
||||
await addDomain(domain)
|
||||
showToast(`Added: ${domain}`, 'success')
|
||||
} catch (e) {
|
||||
showToast('Failed to track', 'error')
|
||||
} finally {
|
||||
setTracking(null)
|
||||
}
|
||||
}
|
||||
|
||||
const isGenerating = loading || aiLoading
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
{/* MAIN GENERATOR CARD */}
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
<div className="border border-white/[0.08] bg-[#020202]">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-4 border-b border-white/[0.08]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-accent/20 to-purple-500/10 border border-accent/30 flex items-center justify-center">
|
||||
<Wand2 className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">Brandable Forge</h3>
|
||||
<p className="text-[11px] font-mono text-white/40">
|
||||
AI-powered brandable name generator
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowConfig(!showConfig)}
|
||||
className={clsx(
|
||||
"w-9 h-9 flex items-center justify-center border transition-all",
|
||||
showConfig
|
||||
? "border-accent/30 bg-accent/10 text-accent"
|
||||
: "border-white/10 text-white/40 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={run}
|
||||
disabled={loading || selectedTlds.length === 0}
|
||||
className={clsx(
|
||||
"h-9 px-5 text-sm font-bold uppercase tracking-wider transition-all flex items-center gap-2",
|
||||
loading || selectedTlds.length === 0
|
||||
? "bg-white/5 text-white/20 cursor-not-allowed"
|
||||
: "bg-accent text-black hover:bg-white"
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Generate
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 pb-5 border-b border-white/[0.08]">
|
||||
<div className="w-12 h-12 bg-purple-500/10 border border-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<Wand2 className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
|
||||
{/* Pattern Selection */}
|
||||
<div className="p-4 border-b border-white/[0.08]">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Lightbulb className="w-3.5 h-3.5 text-white/30" />
|
||||
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Choose Pattern</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{PATTERNS.map((p) => {
|
||||
const isActive = pattern === p.key
|
||||
const colorClass = p.color === 'accent' ? 'accent' : p.color === 'blue' ? 'blue-400' : 'purple-400'
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => setPattern(p.key)}
|
||||
className={clsx(
|
||||
"p-4 border text-left transition-all group",
|
||||
isActive
|
||||
? `border-${colorClass}/40 bg-${colorClass}/10`
|
||||
: "border-white/[0.08] hover:border-white/20 bg-white/[0.02] hover:bg-white/[0.04]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className={clsx(
|
||||
"text-sm font-bold font-mono",
|
||||
isActive ? `text-${colorClass}` : "text-white/70 group-hover:text-white"
|
||||
)}>
|
||||
{p.label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<div className={`w-2 h-2 rounded-full bg-${colorClass}`} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-white/40 mb-2">{p.desc}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{p.examples.map((ex, i) => (
|
||||
<span
|
||||
key={ex}
|
||||
className={clsx(
|
||||
"text-[10px] font-mono px-1.5 py-0.5 border",
|
||||
isActive
|
||||
? "text-white/60 border-white/20 bg-white/5"
|
||||
: "text-white/30 border-white/10"
|
||||
)}
|
||||
>
|
||||
{ex}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TLD Selection */}
|
||||
<div className="p-4 border-b border-white/[0.08]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Select TLDs</span>
|
||||
<span className="text-[10px] font-mono text-white/20">({selectedTlds.length} selected)</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedTlds(selectedTlds.length === TLDS.length ? ['com'] : TLDS.map(t => t.tld))}
|
||||
className="text-[10px] font-mono text-accent hover:text-white transition-colors"
|
||||
>
|
||||
{selectedTlds.length === TLDS.length ? 'Select .com only' : 'Select all'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TLDS.map((t) => (
|
||||
<button
|
||||
key={t.tld}
|
||||
onClick={() => toggleTld(t.tld)}
|
||||
className={clsx(
|
||||
"px-3 py-2 text-[11px] font-mono uppercase border transition-all flex items-center gap-1.5",
|
||||
selectedTlds.includes(t.tld)
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-white/[0.08] text-white/40 hover:text-white/60 hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
{t.premium && <Star className="w-3 h-3" />}
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Config */}
|
||||
{showConfig && (
|
||||
<div className="p-4 border-b border-white/[0.08] bg-white/[0.01] animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="flex items-center gap-6">
|
||||
<div>
|
||||
<label className="block text-[10px] font-mono text-white/40 mb-1.5 uppercase tracking-wider">Results Count</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(Number(e.target.value))}
|
||||
min={10}
|
||||
max={100}
|
||||
step={10}
|
||||
className="w-32 accent-accent"
|
||||
/>
|
||||
<span className="text-sm font-mono text-white w-8">{limit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 text-[10px] font-mono text-white/30 border-l border-white/10 pl-6">
|
||||
<p>We'll check up to 400 random combinations and return the first {limit} verified available domains.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="px-4 py-3 flex items-center justify-between bg-white/[0.01]">
|
||||
<span className="text-[11px] font-mono text-white/40">
|
||||
{items.length > 0 ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||
{items.length} brandable domains ready
|
||||
</span>
|
||||
) : (
|
||||
'Configure settings and click Generate'
|
||||
)}
|
||||
</span>
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
onClick={copyAll}
|
||||
className="flex items-center gap-1.5 text-[10px] font-mono text-accent hover:text-white transition-colors"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
Copy All
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white font-mono tracking-tight">Brandable Forge</h2>
|
||||
<p className="text-[10px] font-mono text-white/40 uppercase tracking-widest mt-1">Synthesize unique, high-value naming assets</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-4 border border-rose-500/20 bg-rose-500/5 flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-rose-500/10 border border-rose-500/20 flex items-center justify-center shrink-0">
|
||||
<Zap className="w-4 h-4 text-rose-400" />
|
||||
{/* Mode Selector */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* AI Mode - First/Left */}
|
||||
<button
|
||||
onClick={() => hasAI && setMode('ai')}
|
||||
disabled={!hasAI}
|
||||
className={clsx(
|
||||
"relative p-5 border transition-all duration-300 text-left group overflow-hidden",
|
||||
!hasAI && "opacity-50 grayscale",
|
||||
mode === 'ai'
|
||||
? "border-purple-500 bg-purple-500/5 shadow-[0_0_30px_-10px_rgba(168,85,247,0.3)]"
|
||||
: "border-white/[0.08] bg-white/[0.01] hover:border-white/20 hover:bg-white/[0.03]"
|
||||
)}
|
||||
>
|
||||
{!hasAI && <div className="absolute top-2 right-2"><Lock className="w-3 h-3 text-white/20" /></div>}
|
||||
<div className="flex items-center gap-4 relative z-10">
|
||||
<div className={clsx(
|
||||
"w-12 h-12 flex items-center justify-center border transition-colors",
|
||||
mode === 'ai' ? "border-purple-500/40 bg-purple-500/10" : "border-white/10 bg-white/5"
|
||||
)}>
|
||||
<Brain className={clsx("w-6 h-6", mode === 'ai' ? "text-purple-400" : "text-white/30")} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={clsx("font-bold font-mono text-sm tracking-tight", mode === 'ai' ? "text-purple-400" : "text-white/70")}>Vision Core AI</p>
|
||||
<p className="text-[10px] font-mono text-white/30 uppercase tracking-widest mt-0.5">Concept to Name</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-mono text-rose-400">{error}</p>
|
||||
<button onClick={run} className="text-[10px] font-mono text-rose-400/60 hover:text-rose-400 mt-1">
|
||||
Try again →
|
||||
</button>
|
||||
{mode === 'ai' && <div className="absolute top-0 right-0 w-2 h-2 bg-purple-500" />}
|
||||
</button>
|
||||
|
||||
{/* Pattern Mode - Second/Right */}
|
||||
<button
|
||||
onClick={() => setMode('pattern')}
|
||||
className={clsx(
|
||||
"relative p-5 border transition-all duration-300 text-left group overflow-hidden",
|
||||
mode === 'pattern'
|
||||
? "border-accent bg-accent/5 shadow-[0_0_30px_-10px_rgba(34,211,126,0.2)]"
|
||||
: "border-white/[0.08] bg-white/[0.01] hover:border-white/20 hover:bg-white/[0.03]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4 relative z-10">
|
||||
<div className={clsx(
|
||||
"w-12 h-12 flex items-center justify-center border transition-colors",
|
||||
mode === 'pattern' ? "border-accent/30 bg-accent/10" : "border-white/10 bg-white/5"
|
||||
)}>
|
||||
<Dices className={clsx("w-6 h-6", mode === 'pattern' ? "text-accent" : "text-white/30")} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={clsx("font-bold font-mono text-sm tracking-tight", mode === 'pattern' ? "text-accent" : "text-white/70")}>Pattern Engine</p>
|
||||
<p className="text-[10px] font-mono text-white/30 uppercase tracking-widest mt-0.5">Classic Brandables</p>
|
||||
</div>
|
||||
</div>
|
||||
{mode === 'pattern' && <div className="absolute top-0 right-0 w-2 h-2 bg-accent" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Config Panel */}
|
||||
<div className={clsx(
|
||||
"border overflow-hidden transition-all duration-500",
|
||||
mode === 'ai' ? "border-purple-500/30 bg-purple-500/[0.01]" : "border-white/[0.08] bg-white/[0.01]"
|
||||
)}>
|
||||
<div className="px-5 py-4 border-b border-white/[0.08] bg-white/[0.01] flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] font-mono text-white/30 uppercase tracking-[0.2em]">Synthesis Configuration</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={clsx("w-1 h-1 rounded-full", mode === 'ai' ? "bg-purple-500 animate-pulse" : "bg-accent")} />
|
||||
<span className="text-[10px] font-mono text-white/20 uppercase tracking-widest">{mode.toUpperCase()} MODE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-6">
|
||||
{mode === 'pattern' ? (
|
||||
<div className="space-y-4">
|
||||
<span className="text-[10px] font-mono text-white/30 uppercase tracking-widest">Select Linguistic Pattern</span>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{PATTERNS.map(p => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => setPattern(p.key)}
|
||||
className={clsx(
|
||||
"p-3 border transition-all duration-300 group",
|
||||
pattern === p.key
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-white/10 hover:border-white/20 hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<p className={clsx("text-xs font-black font-mono tracking-widest", pattern === p.key ? "text-accent" : "text-white/40 group-hover:text-white/60")}>{p.label}</p>
|
||||
<p className="text-[9px] font-mono text-white/20 mt-1 uppercase tracking-tighter">{p.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<span className="text-[10px] font-mono text-purple-400 uppercase tracking-widest font-bold">Describe Your Identity Concept</span>
|
||||
<textarea
|
||||
value={concept}
|
||||
onChange={(e) => setConcept(e.target.value)}
|
||||
placeholder="e.g., A minimalist AI agent for legal risk management..."
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 bg-white/[0.03] border border-purple-500/40 text-sm font-mono text-white placeholder:text-white/20 outline-none focus:border-purple-500 resize-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<span className="text-[10px] font-mono text-white/30 uppercase tracking-widest">Target Extensions</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{TLDS.map(tld => (
|
||||
<button
|
||||
key={tld}
|
||||
onClick={() => setTlds(prev =>
|
||||
prev.includes(tld) ? prev.filter(t => t !== tld) : [...prev, tld]
|
||||
)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 text-[10px] font-mono border transition-all uppercase tracking-widest",
|
||||
tlds.includes(tld)
|
||||
? mode === 'ai' ? "border-purple-500 bg-purple-500/10 text-purple-300 font-black" : "border-accent bg-accent/10 text-accent font-black"
|
||||
: "border-white/10 text-white/40 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
.{tld}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={mode === 'pattern' ? generatePattern : generateFromConcept}
|
||||
disabled={isGenerating || (mode === 'ai' && !concept.trim()) || tlds.length === 0}
|
||||
className={clsx(
|
||||
"w-full py-4 text-[11px] font-black uppercase tracking-[0.2em] flex items-center justify-center gap-3 transition-all",
|
||||
isGenerating || (mode === 'ai' && !concept.trim()) || tlds.length === 0
|
||||
? "bg-white/5 text-white/20 border border-white/5"
|
||||
: mode === 'ai'
|
||||
? "bg-purple-600 text-white hover:bg-purple-500 shadow-[0_0_30px_-10px_rgba(168,85,247,0.4)]"
|
||||
: "bg-accent text-black hover:bg-white shadow-[0_0_30px_-10px_rgba(34,211,126,0.4)]"
|
||||
)}
|
||||
>
|
||||
{isGenerating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
{mode === 'ai' ? 'Synthesize AI Assets' : `Forge ${pattern.toUpperCase()} Names`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Upgrade CTA */}
|
||||
{mode === 'ai' && !hasAI && (
|
||||
<div className="border border-white/10 bg-white/[0.01] p-10 text-center animate-in fade-in zoom-in-95 duration-500">
|
||||
<Lock className="w-12 h-12 text-white/5 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold text-white font-mono tracking-tight mb-2 uppercase">Neural Forge Locked</h3>
|
||||
<p className="text-sm text-white/30 font-mono mb-6 max-w-sm mx-auto uppercase tracking-wider leading-relaxed">
|
||||
AI-driven naming requires Trader or Tycoon clearance
|
||||
</p>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="inline-flex items-center gap-3 px-8 py-3 bg-accent text-black text-[11px] font-black uppercase tracking-widest hover:bg-white transition-all active:scale-95"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Upgrade Plan
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
{/* RESULTS */}
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
{items.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{/* Results */}
|
||||
{results.length > 0 && (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">
|
||||
Generated Domains
|
||||
</span>
|
||||
<button
|
||||
onClick={run}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 text-[10px] font-mono text-white/40 hover:text-accent transition-colors"
|
||||
>
|
||||
<RefreshCw className={clsx("w-3 h-3", loading && "animate-spin")} />
|
||||
Regenerate
|
||||
</button>
|
||||
<p className="text-[10px] font-mono text-white/30 uppercase tracking-[0.2em]">
|
||||
<span className={clsx("font-black", mode === 'ai' ? "text-purple-400" : "text-accent")}>{results.length}</span> Synthesized naming assets available
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<button onClick={copyAll} className="text-[9px] font-mono text-white/20 hover:text-white uppercase tracking-widest transition-colors">
|
||||
Batch Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
|
||||
{items.map((i, idx) => (
|
||||
<div
|
||||
key={i.domain}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{results.map((r, idx) => (
|
||||
<div
|
||||
key={r.domain}
|
||||
className={clsx(
|
||||
"group p-3 border bg-[#020202] hover:bg-accent/[0.03] transition-all",
|
||||
"border-white/[0.06] hover:border-accent/20"
|
||||
"flex items-center justify-between p-4 border transition-all duration-300 group",
|
||||
mode === 'ai'
|
||||
? "bg-purple-500/[0.02] border-purple-500/20 hover:border-purple-500/40 hover:bg-purple-500/[0.04]"
|
||||
: "bg-accent/[0.02] border-accent/20 hover:border-accent/40 hover:bg-accent/[0.04]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center shrink-0 text-[10px] font-mono text-accent font-bold">
|
||||
{String(idx + 1).padStart(2, '0')}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openAnalyze(i.domain)}
|
||||
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left"
|
||||
>
|
||||
{i.domain}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="hidden sm:inline-flex text-[9px] font-mono font-bold text-accent bg-accent/10 px-2 py-1 border border-accent/20">
|
||||
✓ AVAIL
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => copyDomain(i.domain)}
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-all"
|
||||
title="Copy"
|
||||
>
|
||||
{copied === i.domain ? <Check className="w-3.5 h-3.5 text-accent" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => track(i.domain)}
|
||||
disabled={tracking === i.domain}
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-all"
|
||||
title="Add to Watchlist"
|
||||
>
|
||||
{tracking === i.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => openAnalyze(i.domain)}
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10 transition-all"
|
||||
title="Analyze"
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${encodeURIComponent(i.domain)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-8 px-3 bg-accent text-black text-[10px] font-bold uppercase flex items-center gap-1.5 hover:bg-white transition-colors"
|
||||
>
|
||||
<ShoppingCart className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">Buy</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className={clsx(
|
||||
"text-[9px] font-bold font-mono px-1.5 py-0.5 border shrink-0",
|
||||
mode === 'ai'
|
||||
? "bg-purple-500/10 border-purple-500/20 text-purple-300"
|
||||
: "bg-accent/10 border-accent/20 text-accent"
|
||||
)}>
|
||||
{(idx + 1).toString().padStart(2, '0')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => openAnalyze(r.domain)}
|
||||
className={clsx(
|
||||
"text-sm font-bold font-mono text-white truncate transition-colors",
|
||||
mode === 'ai' ? "group-hover:text-purple-300" : "group-hover:text-accent"
|
||||
)}
|
||||
>
|
||||
{r.domain}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-all transform translate-x-2 group-hover:translate-x-0">
|
||||
<button onClick={() => copy(r.domain)} className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5">
|
||||
{copied === r.domain ? <Check className="w-3.5 h-3.5 text-accent" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button onClick={() => track(r.domain)} disabled={tracking === r.domain} className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5">
|
||||
{tracking === r.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button onClick={() => openAnalyze(r.domain)} className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/5">
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<a
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${r.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={clsx(
|
||||
"h-7 px-3 text-[10px] font-black uppercase tracking-widest flex items-center transition-all",
|
||||
mode === 'ai'
|
||||
? "bg-purple-600 text-white hover:bg-purple-500"
|
||||
: "bg-accent text-black hover:bg-white"
|
||||
)}
|
||||
>
|
||||
Buy
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -444,38 +380,21 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{items.length === 0 && !loading && (
|
||||
<div className="text-center py-16 border border-dashed border-white/[0.08] bg-white/[0.01]">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-accent/5 border border-accent/20 flex items-center justify-center">
|
||||
<Wand2 className="w-8 h-8 text-accent/40" />
|
||||
</div>
|
||||
<h3 className="text-white/60 text-sm font-medium mb-1">Ready to forge</h3>
|
||||
<p className="text-white/30 text-xs font-mono max-w-xs mx-auto">
|
||||
Select a pattern and TLDs, then click "Generate" to discover available brandable domain names
|
||||
{results.length === 0 && !isGenerating && (
|
||||
<div className="text-center py-24 border border-dashed border-white/[0.08] bg-white/[0.01]">
|
||||
<Wand2 className="w-12 h-12 text-white/5 mx-auto mb-4" />
|
||||
<p className="text-white/40 text-sm font-mono uppercase tracking-widest font-bold">Forge is currently idle</p>
|
||||
<p className="text-white/20 text-[10px] font-mono mt-3 uppercase tracking-wider max-w-xs mx-auto leading-relaxed">
|
||||
Select a generation mode above to synthesize available brandable assets
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-3 text-[10px] font-mono text-white/20">
|
||||
<span className="flex items-center gap-1"><Zap className="w-3 h-3" /> Verified available</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1"><Shield className="w-3 h-3" /> DNS checked</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && items.length === 0 && (
|
||||
<div className="space-y-2">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="p-3 border border-white/[0.06] bg-[#020202] animate-pulse">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-white/10 rounded" />
|
||||
<div className="h-4 w-32 bg-white/10 rounded" />
|
||||
<div className="ml-auto flex gap-2">
|
||||
<div className="w-8 h-8 bg-white/5 rounded" />
|
||||
<div className="w-8 h-8 bg-white/5 rounded" />
|
||||
<div className="w-16 h-8 bg-white/5 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Loading Grid */}
|
||||
{isGenerating && results.length === 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div key={i} className="h-14 bg-white/[0.01] border border-white/[0.05] animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -3,9 +3,8 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { formatCountdown } from '@/lib/time'
|
||||
import {
|
||||
Clock,
|
||||
Globe,
|
||||
Loader2,
|
||||
Search,
|
||||
@ -22,6 +21,9 @@ import {
|
||||
Filter,
|
||||
Ban,
|
||||
Hash,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@ -29,13 +31,19 @@ import clsx from 'clsx'
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
type AvailabilityStatus = 'available' | 'dropping_soon' | 'taken' | 'unknown'
|
||||
|
||||
interface DroppedDomain {
|
||||
id: number
|
||||
domain: string
|
||||
tld: string
|
||||
dropped_date: string
|
||||
length: number
|
||||
is_numeric: boolean
|
||||
has_hyphen: boolean
|
||||
availability_status: AvailabilityStatus
|
||||
last_status_check: string | null
|
||||
deletion_date: string | null
|
||||
}
|
||||
|
||||
interface ZoneStats {
|
||||
@ -44,7 +52,6 @@ interface ZoneStats {
|
||||
daily_drops: number
|
||||
}
|
||||
|
||||
// All supported TLDs
|
||||
type SupportedTld = 'ch' | 'li' | 'xyz' | 'org' | 'online' | 'info' | 'dev' | 'app'
|
||||
|
||||
const ALL_TLDS: { tld: SupportedTld; flag: string }[] = [
|
||||
@ -67,8 +74,20 @@ interface DropsTabProps {
|
||||
}
|
||||
|
||||
export function DropsTab({ showToast }: DropsTabProps) {
|
||||
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
||||
const addDomain = useStore((s) => s.addDomain)
|
||||
const openAnalyzePanel = useAnalyzePanelStore((s) => s.open)
|
||||
|
||||
// Wrapper to open analyze panel with drop status
|
||||
const openAnalyze = useCallback((domain: string, item?: DroppedDomain) => {
|
||||
if (item) {
|
||||
openAnalyzePanel(domain, {
|
||||
status: item.availability_status || 'unknown',
|
||||
deletion_date: item.deletion_date,
|
||||
is_drop: true,
|
||||
})
|
||||
} else {
|
||||
openAnalyzePanel(domain)
|
||||
}
|
||||
}, [openAnalyzePanel])
|
||||
|
||||
// Data State
|
||||
const [items, setItems] = useState<DroppedDomain[]>([])
|
||||
@ -85,6 +104,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
||||
const [maxLength, setMaxLength] = useState<number | undefined>(undefined)
|
||||
const [excludeNumeric, setExcludeNumeric] = useState(true)
|
||||
const [excludeHyphen, setExcludeHyphen] = useState(true)
|
||||
const [showOnlyAvailable, setShowOnlyAvailable] = useState(false)
|
||||
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||
|
||||
// Pagination
|
||||
@ -95,8 +115,26 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
||||
const [sortField, setSortField] = useState<'domain' | 'length' | 'date'>('length')
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
|
||||
|
||||
// Tracking
|
||||
const [tracking, setTracking] = useState<string | null>(null)
|
||||
// Status Checking
|
||||
const [checkingStatus, setCheckingStatus] = useState<number | null>(null)
|
||||
const [trackingDrop, setTrackingDrop] = useState<number | null>(null)
|
||||
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
|
||||
|
||||
// Prefetch Watchlist domains (so Track button shows correct state)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const loadTracked = async () => {
|
||||
try {
|
||||
const res = await api.getDomains(1, 200)
|
||||
if (cancelled) return
|
||||
setTrackedDomains(new Set(res.domains.map(d => d.name.toLowerCase())))
|
||||
} catch {
|
||||
// If unauthenticated, Drops list still renders; "Track" will prompt on action.
|
||||
}
|
||||
}
|
||||
loadTracked()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
// Load Stats
|
||||
const loadStats = useCallback(async () => {
|
||||
@ -108,7 +146,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load Drops (only last 24h)
|
||||
// Load Drops
|
||||
const loadDrops = useCallback(async (currentPage = 1, isRefresh = false) => {
|
||||
if (isRefresh) setRefreshing(true)
|
||||
else setLoading(true)
|
||||
@ -116,7 +154,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
||||
try {
|
||||
const result = await api.getDrops({
|
||||
tld: selectedTld || undefined,
|
||||
hours: 24, // Only last 24h - fresh drops only!
|
||||
hours: 24,
|
||||
min_length: minLength,
|
||||
max_length: maxLength,
|
||||
exclude_numeric: excludeNumeric,
|
||||
@ -140,7 +178,6 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
||||
}
|
||||
}, [selectedTld, minLength, maxLength, excludeNumeric, excludeHyphen, searchQuery, showToast])
|
||||
|
||||
// Initial Load
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
}, [loadStats])
|
||||
@ -160,23 +197,76 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
||||
await loadStats()
|
||||
}, [loadDrops, loadStats, page])
|
||||
|
||||
const track = useCallback(async (domain: string) => {
|
||||
if (tracking) return
|
||||
setTracking(domain)
|
||||
// Check real-time status of a drop
|
||||
const checkStatus = useCallback(async (dropId: number, domain: string) => {
|
||||
if (checkingStatus) return
|
||||
setCheckingStatus(dropId)
|
||||
try {
|
||||
await addDomain(domain)
|
||||
showToast(`Tracking ${domain}`, 'success')
|
||||
const result = await api.checkDropStatus(dropId)
|
||||
// Update the item in our list
|
||||
setItems(prev => prev.map(item =>
|
||||
item.id === dropId
|
||||
? {
|
||||
...item,
|
||||
availability_status: result.status,
|
||||
last_status_check: new Date().toISOString(),
|
||||
deletion_date: result.deletion_date,
|
||||
}
|
||||
: item
|
||||
))
|
||||
|
||||
showToast(result.message, result.can_register_now ? 'success' : 'info')
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : 'Failed', 'error')
|
||||
showToast(e instanceof Error ? e.message : 'Status check failed', 'error')
|
||||
} finally {
|
||||
setTracking(null)
|
||||
setCheckingStatus(null)
|
||||
}
|
||||
}, [addDomain, showToast, tracking])
|
||||
}, [checkingStatus, showToast])
|
||||
|
||||
// Track a drop (add to watchlist)
|
||||
const trackDrop = useCallback(async (dropId: number, domain: string) => {
|
||||
if (trackingDrop) return
|
||||
if (trackedDomains.has(domain.toLowerCase())) {
|
||||
showToast(`${domain} is already in your Watchlist`, 'info')
|
||||
return
|
||||
}
|
||||
|
||||
setTrackingDrop(dropId)
|
||||
try {
|
||||
const result = await api.trackDrop(dropId)
|
||||
// Mark as tracked regardless of status
|
||||
setTrackedDomains(prev => {
|
||||
const next = new Set(prev)
|
||||
next.add(domain.toLowerCase())
|
||||
return next
|
||||
})
|
||||
|
||||
if (result.status === 'already_tracking') {
|
||||
showToast(`${domain} is already in your Watchlist`, 'info')
|
||||
} else {
|
||||
showToast(result.message || `Added ${domain} to Watchlist!`, 'success')
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : 'Failed to track', 'error')
|
||||
} finally {
|
||||
setTrackingDrop(null)
|
||||
}
|
||||
}, [trackingDrop, trackedDomains, showToast])
|
||||
|
||||
// Check if a drop is already tracked (domain-based, persists across sessions)
|
||||
const isTracked = useCallback((fullDomain: string) => trackedDomains.has(fullDomain.toLowerCase()), [trackedDomains])
|
||||
|
||||
// Sorted Items
|
||||
// Filtered and Sorted Items
|
||||
const sortedItems = useMemo(() => {
|
||||
// Filter first if "show only available" is enabled
|
||||
let filtered = items
|
||||
if (showOnlyAvailable) {
|
||||
filtered = items.filter(item => item.availability_status === 'available')
|
||||
}
|
||||
|
||||
// Then sort
|
||||
const mult = sortDirection === 'asc' ? 1 : -1
|
||||
return [...items].sort((a, b) => {
|
||||
return [...filtered].sort((a, b) => {
|
||||
switch (sortField) {
|
||||
case 'domain':
|
||||
return mult * a.domain.localeCompare(b.domain)
|
||||
@ -188,7 +278,12 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
}, [items, sortField, sortDirection])
|
||||
}, [items, sortField, sortDirection, showOnlyAvailable])
|
||||
|
||||
// Count available domains
|
||||
const availableCount = useMemo(() =>
|
||||
items.filter(item => item.availability_status === 'available').length
|
||||
, [items])
|
||||
|
||||
const handleSort = useCallback((field: typeof sortField) => {
|
||||
if (sortField === field) {
|
||||
@ -206,6 +301,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
||||
maxLength !== undefined,
|
||||
excludeNumeric,
|
||||
excludeHyphen,
|
||||
showOnlyAvailable,
|
||||
].filter(Boolean).length
|
||||
|
||||
const formatTime = (iso: string) => {
|
||||
@ -217,128 +313,136 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
||||
return `${diffH}h ago`
|
||||
}
|
||||
|
||||
// Loading State
|
||||
if (loading && items.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<Loader2 className="w-8 h-8 text-accent animate-spin mb-4" />
|
||||
<span className="text-xs font-mono text-white/30 uppercase tracking-widest">Loading zone file drops...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
{/* Header Stats */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center">
|
||||
<Zap className="w-5 h-5 text-accent" />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-accent/20 to-accent/5 border border-accent/30 flex items-center justify-center">
|
||||
<Zap className="w-6 h-6 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-white font-mono">
|
||||
<div className="text-2xl font-black text-white font-mono tracking-tight">
|
||||
{stats?.daily_drops?.toLocaleString() || total.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-white/40 uppercase">Fresh drops (24h)</div>
|
||||
<div className="text-[10px] font-mono text-white/40 uppercase tracking-widest">Fresh drops in last 24h</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="p-2 border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-colors"
|
||||
className="p-3 border border-white/10 text-white/40 hover:text-accent hover:border-accent/30 hover:bg-accent/5 transition-all"
|
||||
title="Refresh drops"
|
||||
>
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||
<RefreshCw className={clsx("w-5 h-5", refreshing && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className={clsx(
|
||||
"relative border transition-all duration-200",
|
||||
"relative border-2 transition-all duration-200",
|
||||
searchFocused ? "border-accent/50 bg-accent/[0.02]" : "border-white/[0.08] bg-white/[0.02]"
|
||||
)}>
|
||||
<div className="flex items-center">
|
||||
<Search className={clsx("w-4 h-4 ml-3 transition-colors", searchFocused ? "text-accent" : "text-white/30")} />
|
||||
<Search className={clsx("w-5 h-5 ml-4 transition-colors", searchFocused ? "text-accent" : "text-white/30")} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => setSearchFocused(false)}
|
||||
placeholder="Search drops..."
|
||||
className="flex-1 bg-transparent px-3 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono"
|
||||
placeholder="Search dropped domains..."
|
||||
className="flex-1 bg-transparent px-4 py-4 text-sm text-white placeholder:text-white/20 outline-none font-mono"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button onClick={() => setSearchQuery('')} className="p-3 text-white/30 hover:text-white transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
<button onClick={() => setSearchQuery('')} className="p-4 text-white/30 hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TLD Quick Filter */}
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setSelectedTld(null)}
|
||||
className={clsx(
|
||||
"px-2.5 py-1.5 text-[10px] font-mono uppercase border transition-colors",
|
||||
selectedTld === null ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
|
||||
"px-4 py-2.5 text-xs font-mono uppercase tracking-wider border transition-all",
|
||||
selectedTld === null
|
||||
? "border-accent bg-accent/10 text-accent font-bold"
|
||||
: "border-white/[0.08] text-white/40 hover:border-white/20 hover:text-white/60"
|
||||
)}
|
||||
>
|
||||
All
|
||||
All TLDs
|
||||
</button>
|
||||
{ALL_TLDS.map(({ tld, flag }) => (
|
||||
{ALL_TLDS.map(({ tld }) => (
|
||||
<button
|
||||
key={tld}
|
||||
onClick={() => setSelectedTld(tld)}
|
||||
className={clsx(
|
||||
"px-2.5 py-1.5 text-[10px] font-mono uppercase border transition-colors flex items-center gap-1",
|
||||
selectedTld === tld ? "border-accent bg-accent/10 text-accent" : "border-white/[0.08] text-white/40"
|
||||
"px-4 py-2.5 text-xs font-mono uppercase tracking-wider border transition-all",
|
||||
selectedTld === tld
|
||||
? "border-accent bg-accent/10 text-accent font-bold"
|
||||
: "border-white/[0.08] text-white/40 hover:border-white/20 hover:text-white/60"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs">{flag}</span>.{tld}
|
||||
.{tld}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter Toggle */}
|
||||
{/* Advanced Filters */}
|
||||
<button
|
||||
onClick={() => setFiltersOpen(!filtersOpen)}
|
||||
className={clsx(
|
||||
"flex items-center justify-between w-full py-2 px-3 border transition-colors",
|
||||
filtersOpen ? "border-accent/30 bg-accent/[0.05]" : "border-white/[0.08] bg-white/[0.02]"
|
||||
"flex items-center justify-between w-full py-3 px-5 border transition-all",
|
||||
filtersOpen ? "border-accent/30 bg-accent/[0.03]" : "border-white/[0.08] bg-white/[0.02] hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-white/40" />
|
||||
<span className="text-xs font-mono text-white/60">Filters</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<Filter className={clsx("w-4 h-4", filtersOpen ? "text-accent" : "text-white/40")} />
|
||||
<span className={clsx("text-xs font-mono uppercase tracking-widest", filtersOpen ? "text-accent" : "text-white/50")}>
|
||||
Advanced Filters
|
||||
</span>
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 text-[9px] font-bold bg-accent text-black">{activeFiltersCount}</span>
|
||||
<span className="px-2 py-0.5 text-[9px] font-black bg-accent text-black">{activeFiltersCount}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className={clsx("w-4 h-4 text-white/30 transition-transform", filtersOpen && "rotate-90")} />
|
||||
<ChevronRight className={clsx("w-4 h-4 transition-transform", filtersOpen ? "rotate-90 text-accent" : "text-white/30")} />
|
||||
</button>
|
||||
|
||||
{/* Filters Panel */}
|
||||
{filtersOpen && (
|
||||
<div className="p-3 border border-white/[0.08] bg-white/[0.02] space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="p-5 border border-white/[0.08] bg-white/[0.01] space-y-5 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
{/* Length Filter */}
|
||||
<div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider mb-2">Length</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="text-[10px] font-mono text-white/40 uppercase tracking-widest mb-3">Domain Length</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<input
|
||||
type="number"
|
||||
value={minLength || ''}
|
||||
onChange={(e) => setMinLength(e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder="Min"
|
||||
className="w-16 bg-white/[0.02] border border-white/10 px-2 py-1.5 text-xs text-white placeholder:text-white/25 outline-none font-mono"
|
||||
className="w-24 bg-white/[0.02] border border-white/10 px-4 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono focus:border-accent/30 transition-colors"
|
||||
min={1}
|
||||
max={63}
|
||||
/>
|
||||
<span className="text-white/20">–</span>
|
||||
<span className="text-white/20 font-mono text-xs">to</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maxLength || ''}
|
||||
onChange={(e) => setMaxLength(e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder="Max"
|
||||
className="w-16 bg-white/[0.02] border border-white/10 px-2 py-1.5 text-xs text-white placeholder:text-white/25 outline-none font-mono"
|
||||
className="w-24 bg-white/[0.02] border border-white/10 px-4 py-3 text-sm text-white placeholder:text-white/20 outline-none font-mono focus:border-accent/30 transition-colors"
|
||||
min={1}
|
||||
max={63}
|
||||
/>
|
||||
@ -346,190 +450,426 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
||||
</div>
|
||||
|
||||
{/* Quality Filters */}
|
||||
<div className="flex gap-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => setExcludeNumeric(!excludeNumeric)}
|
||||
className={clsx(
|
||||
"flex-1 flex items-center justify-between py-2 px-3 border transition-colors",
|
||||
excludeNumeric ? "border-white/20 bg-white/[0.05]" : "border-white/[0.08]"
|
||||
"flex items-center justify-between py-3 px-4 border transition-all",
|
||||
excludeNumeric ? "border-accent/30 bg-accent/5" : "border-white/[0.08] hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="w-3.5 h-3.5 text-white/40" />
|
||||
<span className="text-[10px] font-mono text-white/60">No numbers</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<Hash className={clsx("w-4 h-4", excludeNumeric ? "text-accent" : "text-white/30")} />
|
||||
<span className={clsx("text-xs font-mono uppercase tracking-wider", excludeNumeric ? "text-accent" : "text-white/50")}>
|
||||
No Numbers
|
||||
</span>
|
||||
</div>
|
||||
<div className={clsx("w-3.5 h-3.5 border flex items-center justify-center", excludeNumeric ? "border-accent bg-accent" : "border-white/30")}>
|
||||
{excludeNumeric && <span className="text-black text-[8px] font-bold">✓</span>}
|
||||
<div className={clsx(
|
||||
"w-5 h-5 border flex items-center justify-center transition-all",
|
||||
excludeNumeric ? "border-accent bg-accent" : "border-white/20"
|
||||
)}>
|
||||
{excludeNumeric && <CheckCircle2 className="w-3 h-3 text-black" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setExcludeHyphen(!excludeHyphen)}
|
||||
className={clsx(
|
||||
"flex-1 flex items-center justify-between py-2 px-3 border transition-colors",
|
||||
excludeHyphen ? "border-white/20 bg-white/[0.05]" : "border-white/[0.08]"
|
||||
"flex items-center justify-between py-3 px-4 border transition-all",
|
||||
excludeHyphen ? "border-accent/30 bg-accent/5" : "border-white/[0.08] hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ban className="w-3.5 h-3.5 text-white/40" />
|
||||
<span className="text-[10px] font-mono text-white/60">No hyphens</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<Ban className={clsx("w-4 h-4", excludeHyphen ? "text-accent" : "text-white/30")} />
|
||||
<span className={clsx("text-xs font-mono uppercase tracking-wider", excludeHyphen ? "text-accent" : "text-white/50")}>
|
||||
No Hyphens
|
||||
</span>
|
||||
</div>
|
||||
<div className={clsx("w-3.5 h-3.5 border flex items-center justify-center", excludeHyphen ? "border-accent bg-accent" : "border-white/30")}>
|
||||
{excludeHyphen && <span className="text-black text-[8px] font-bold">✓</span>}
|
||||
<div className={clsx(
|
||||
"w-5 h-5 border flex items-center justify-center transition-all",
|
||||
excludeHyphen ? "border-accent bg-accent" : "border-white/20"
|
||||
)}>
|
||||
{excludeHyphen && <CheckCircle2 className="w-3 h-3 text-black" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Show Only Available Filter */}
|
||||
<button
|
||||
onClick={() => setShowOnlyAvailable(!showOnlyAvailable)}
|
||||
className={clsx(
|
||||
"flex items-center justify-between py-3 px-4 border transition-all",
|
||||
showOnlyAvailable ? "border-accent/30 bg-accent/5" : "border-white/[0.08] hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className={clsx("w-4 h-4", showOnlyAvailable ? "text-accent" : "text-white/30")} />
|
||||
<span className={clsx("text-xs font-mono uppercase tracking-wider", showOnlyAvailable ? "text-accent" : "text-white/50")}>
|
||||
Only Available
|
||||
</span>
|
||||
</div>
|
||||
<div className={clsx(
|
||||
"w-5 h-5 border flex items-center justify-center transition-all",
|
||||
showOnlyAvailable ? "border-accent bg-accent" : "border-white/20"
|
||||
)}>
|
||||
{showOnlyAvailable && <CheckCircle2 className="w-3 h-3 text-black" />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-white/40">
|
||||
<span>{total.toLocaleString()} fresh drops</span>
|
||||
{totalPages > 1 && <span>Page {page}/{totalPages}</span>}
|
||||
{/* Results Info */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-4 text-[11px] font-mono uppercase tracking-widest">
|
||||
<div className="flex items-center gap-2 text-white/40">
|
||||
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" />
|
||||
<span>{showOnlyAvailable ? sortedItems.length : total.toLocaleString()} domains</span>
|
||||
</div>
|
||||
{availableCount > 0 && !showOnlyAvailable && (
|
||||
<div className="flex items-center gap-2 text-accent">
|
||||
<Zap className="w-3 h-3" />
|
||||
<span>{availableCount} available now</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && !showOnlyAvailable && (
|
||||
<span className="text-[11px] font-mono text-white/30 uppercase tracking-widest">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{/* Results Table */}
|
||||
{sortedItems.length === 0 ? (
|
||||
<div className="text-center py-16 border border-dashed border-white/[0.08]">
|
||||
<Globe className="w-8 h-8 text-white/10 mx-auto mb-3" />
|
||||
<p className="text-white/40 text-sm font-mono">No fresh drops</p>
|
||||
<p className="text-white/25 text-xs font-mono mt-1">Check back after the next sync</p>
|
||||
<div className="text-center py-24 border border-dashed border-white/[0.08] bg-white/[0.01]">
|
||||
<Globe className="w-16 h-16 text-white/5 mx-auto mb-6" />
|
||||
<p className="text-white/50 text-sm font-mono uppercase tracking-widest font-bold">No drops found</p>
|
||||
<p className="text-white/20 text-xs font-mono mt-3 uppercase tracking-wider max-w-sm mx-auto leading-relaxed">
|
||||
Zone file comparison runs daily. Try adjusting your filters.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
|
||||
{/* Desktop Table Header */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_60px_80px_100px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
|
||||
<button onClick={() => handleSort('domain')} className="flex items-center gap-1 hover:text-white/60 text-left">
|
||||
Domain
|
||||
{sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
<div className="border border-white/[0.08] bg-[#020202] overflow-hidden">
|
||||
{/* Table Header */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_80px_130px_100px_180px] gap-4 px-6 py-4 text-[10px] font-mono text-white/40 uppercase tracking-[0.15em] border-b border-white/[0.08] bg-white/[0.02]">
|
||||
<button
|
||||
onClick={() => handleSort('domain')}
|
||||
className="flex items-center gap-2 hover:text-white transition-colors text-left"
|
||||
>
|
||||
<span className={clsx(sortField === 'domain' && "text-accent font-bold")}>Domain</span>
|
||||
{sortField === 'domain' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3 text-accent" /> : <ChevronDown className="w-3 h-3 text-accent" />)}
|
||||
</button>
|
||||
<button onClick={() => handleSort('length')} className="flex items-center gap-1 justify-center hover:text-white/60">
|
||||
Len
|
||||
{sortField === 'length' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
<button
|
||||
onClick={() => handleSort('length')}
|
||||
className="flex items-center gap-2 justify-center hover:text-white transition-colors"
|
||||
>
|
||||
<span className={clsx(sortField === 'length' && "text-accent font-bold")}>Length</span>
|
||||
{sortField === 'length' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3 text-accent" /> : <ChevronDown className="w-3 h-3 text-accent" />)}
|
||||
</button>
|
||||
<button onClick={() => handleSort('date')} className="flex items-center gap-1 justify-center hover:text-white/60">
|
||||
When
|
||||
{sortField === 'date' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
||||
<div className="text-center">Status</div>
|
||||
<button
|
||||
onClick={() => handleSort('date')}
|
||||
className="flex items-center gap-2 justify-center hover:text-white transition-colors"
|
||||
>
|
||||
<span className={clsx(sortField === 'date' && "text-accent font-bold")}>Detected</span>
|
||||
{sortField === 'date' && (sortDirection === 'asc' ? <ChevronUp className="w-3 h-3 text-accent" /> : <ChevronDown className="w-3 h-3 text-accent" />)}
|
||||
</button>
|
||||
<div className="text-right">Actions</div>
|
||||
</div>
|
||||
|
||||
{sortedItems.map((item) => (
|
||||
<div key={item.domain} className="bg-[#020202] hover:bg-white/[0.02] transition-all">
|
||||
{/* Mobile Row */}
|
||||
<div className="lg:hidden p-3">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className="text-sm shrink-0">{ALL_TLDS.find(t => t.tld === item.tld)?.flag || '🌐'}</span>
|
||||
<button onClick={() => openAnalyze(item.domain)} className="text-sm font-bold text-white font-mono truncate text-left">
|
||||
{item.domain}
|
||||
</button>
|
||||
{/* Table Body */}
|
||||
<div className="divide-y divide-white/[0.04]">
|
||||
{sortedItems.map((item) => {
|
||||
const fullDomain = `${item.domain}.${item.tld}`
|
||||
const isChecking = checkingStatus === item.id
|
||||
const isTrackingThis = trackingDrop === item.id
|
||||
const alreadyTracked = isTracked(fullDomain)
|
||||
const status = item.availability_status || 'unknown'
|
||||
|
||||
// Status display config with better labels
|
||||
const countdown = item.deletion_date ? formatCountdown(item.deletion_date) : null
|
||||
const statusConfig = {
|
||||
available: {
|
||||
label: 'Available Now',
|
||||
color: 'text-accent',
|
||||
bg: 'bg-accent/10',
|
||||
border: 'border-accent/30',
|
||||
icon: CheckCircle2,
|
||||
showBuy: true,
|
||||
},
|
||||
dropping_soon: {
|
||||
label: countdown ? `In Transition • ${countdown}` : 'In Transition',
|
||||
color: 'text-amber-400',
|
||||
bg: 'bg-amber-400/10',
|
||||
border: 'border-amber-400/30',
|
||||
icon: Clock,
|
||||
showBuy: false,
|
||||
},
|
||||
taken: {
|
||||
label: 'Re-registered',
|
||||
color: 'text-rose-400/60',
|
||||
bg: 'bg-rose-400/5',
|
||||
border: 'border-rose-400/20',
|
||||
icon: Ban,
|
||||
showBuy: false,
|
||||
},
|
||||
unknown: {
|
||||
label: 'Check Status',
|
||||
color: 'text-white/50',
|
||||
bg: 'bg-white/5',
|
||||
border: 'border-white/20',
|
||||
icon: Search,
|
||||
showBuy: false,
|
||||
},
|
||||
}[status]
|
||||
|
||||
const StatusIcon = statusConfig.icon
|
||||
|
||||
return (
|
||||
<div key={`${item.id}-${fullDomain}`} className="group hover:bg-white/[0.02] transition-all">
|
||||
{/* Mobile Row */}
|
||||
<div className="lg:hidden p-5">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
onClick={() => openAnalyze(fullDomain, item)}
|
||||
className="text-lg font-bold text-white font-mono truncate block text-left hover:text-accent transition-colors"
|
||||
>
|
||||
{item.domain}<span className="text-white/30">.{item.tld}</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<span className={clsx(
|
||||
"text-[10px] font-mono font-bold px-2.5 py-1 border",
|
||||
item.length <= 4 ? "text-accent border-accent/20 bg-accent/5" :
|
||||
item.length <= 6 ? "text-amber-400 border-amber-400/20 bg-amber-400/5" :
|
||||
"text-white/40 border-white/10 bg-white/5"
|
||||
)}>
|
||||
{item.length} chars
|
||||
</span>
|
||||
<button
|
||||
onClick={() => checkStatus(item.id, fullDomain)}
|
||||
disabled={isChecking}
|
||||
className={clsx(
|
||||
"text-[10px] font-mono font-bold px-2.5 py-1 border flex items-center gap-1.5",
|
||||
statusConfig.color, statusConfig.bg, statusConfig.border
|
||||
)}
|
||||
>
|
||||
{isChecking ? <Loader2 className="w-3 h-3 animate-spin" /> : <StatusIcon className="w-3 h-3" />}
|
||||
{statusConfig.label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* Track Button - shows "Tracked" if already in watchlist */}
|
||||
<button
|
||||
onClick={() => trackDrop(item.id, fullDomain)}
|
||||
disabled={isTrackingThis || alreadyTracked}
|
||||
className={clsx(
|
||||
"h-12 px-4 border text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-2 transition-all",
|
||||
alreadyTracked
|
||||
? "border-accent/30 text-accent bg-accent/5 cursor-default"
|
||||
: "border-white/10 text-white/60 hover:bg-white/5 active:scale-[0.98]"
|
||||
)}
|
||||
>
|
||||
{isTrackingThis ? <Loader2 className="w-4 h-4 animate-spin" /> :
|
||||
alreadyTracked ? <CheckCircle2 className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{alreadyTracked ? 'Tracked' : 'Track'}
|
||||
</button>
|
||||
|
||||
{/* Action Button based on status */}
|
||||
{status === 'available' ? (
|
||||
<a
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${fullDomain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 h-12 bg-accent text-black text-xs font-black uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-white active:scale-[0.98] transition-all"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Buy Now
|
||||
</a>
|
||||
) : status === 'dropping_soon' ? (
|
||||
<div className="flex-1 h-12 border border-amber-400/30 text-amber-400 bg-amber-400/5 text-xs font-bold uppercase tracking-widest flex flex-col items-center justify-center">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
In Transition
|
||||
</span>
|
||||
{countdown && (
|
||||
<span className="text-[9px] text-amber-400/70 font-mono">{countdown} until drop</span>
|
||||
)}
|
||||
</div>
|
||||
) : status === 'taken' ? (
|
||||
<span className="flex-1 h-12 border border-rose-400/20 text-rose-400/60 text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-2 bg-rose-400/5">
|
||||
<Ban className="w-4 h-4" />
|
||||
Re-registered
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => checkStatus(item.id, fullDomain)}
|
||||
disabled={isChecking}
|
||||
className="flex-1 h-12 border border-accent/30 text-accent text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-accent/10 active:scale-[0.98] transition-all"
|
||||
>
|
||||
{isChecking ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||
Check Status
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openAnalyze(fullDomain, item)}
|
||||
className="w-14 h-12 border border-white/10 text-white/50 flex items-center justify-center hover:text-accent hover:border-accent/30 hover:bg-accent/5 transition-all"
|
||||
>
|
||||
<Shield className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={clsx(
|
||||
"text-[10px] font-mono font-bold px-1.5 py-0.5",
|
||||
item.length <= 5 ? "text-accent bg-accent/10" : "text-white/40 bg-white/5"
|
||||
)}>
|
||||
{item.length}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-white/30">{formatTime(item.dropped_date)}</span>
|
||||
|
||||
{/* Desktop Row */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_80px_130px_100px_180px] gap-4 items-center px-6 py-3">
|
||||
{/* Domain */}
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
onClick={() => openAnalyze(fullDomain, item)}
|
||||
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left block"
|
||||
>
|
||||
{item.domain}<span className="text-white/30 group-hover:text-accent/40">.{item.tld}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Length */}
|
||||
<div className="text-center">
|
||||
<span className={clsx(
|
||||
"text-xs font-mono font-bold px-3 py-1 border inline-block",
|
||||
item.length <= 4 ? "text-accent border-accent/20 bg-accent/5" :
|
||||
item.length <= 6 ? "text-amber-400 border-amber-400/20 bg-amber-400/5" :
|
||||
"text-white/40 border-white/10 bg-white/5"
|
||||
)}>
|
||||
{item.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status - clickable to refresh */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => checkStatus(item.id, fullDomain)}
|
||||
disabled={isChecking}
|
||||
className={clsx(
|
||||
"text-[10px] font-mono font-bold px-2.5 py-1.5 border inline-flex items-center gap-1.5 transition-all hover:opacity-80",
|
||||
statusConfig.color, statusConfig.bg, statusConfig.border
|
||||
)}
|
||||
title="Click to check real-time status"
|
||||
>
|
||||
{isChecking ? <Loader2 className="w-3 h-3 animate-spin" /> : <StatusIcon className="w-3 h-3" />}
|
||||
<span className="max-w-[100px] truncate">{statusConfig.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="text-center">
|
||||
<span className="text-xs font-mono text-white/40 uppercase tracking-wider">
|
||||
{formatTime(item.dropped_date)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 opacity-60 group-hover:opacity-100 transition-all">
|
||||
{/* Track Button - shows checkmark if tracked */}
|
||||
<button
|
||||
onClick={() => trackDrop(item.id, fullDomain)}
|
||||
disabled={isTrackingThis || alreadyTracked}
|
||||
className={clsx(
|
||||
"w-9 h-9 flex items-center justify-center border transition-all",
|
||||
alreadyTracked
|
||||
? "border-accent/30 text-accent bg-accent/5 cursor-default"
|
||||
: "border-white/10 text-white/50 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
title={alreadyTracked ? "Already in Watchlist" : "Add to Watchlist"}
|
||||
>
|
||||
{isTrackingThis ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> :
|
||||
alreadyTracked ? <CheckCircle2 className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openAnalyze(fullDomain, item)}
|
||||
className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/50 hover:text-accent hover:border-accent/30 hover:bg-accent/5 transition-all"
|
||||
title="Analyze Domain"
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Dynamic Action Button based on status */}
|
||||
{status === 'available' ? (
|
||||
<a
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${fullDomain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-9 px-4 bg-accent text-black text-[10px] font-black uppercase tracking-widest flex items-center gap-1.5 hover:bg-white transition-all"
|
||||
title="Register this domain now!"
|
||||
>
|
||||
<Zap className="w-3 h-3" />
|
||||
Buy
|
||||
</a>
|
||||
) : status === 'dropping_soon' ? (
|
||||
alreadyTracked ? (
|
||||
<span className="h-9 px-3 text-accent text-[10px] font-bold uppercase tracking-widest flex items-center gap-1.5 border border-accent/30 bg-accent/5">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
Tracked
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => trackDrop(item.id, fullDomain)}
|
||||
disabled={isTrackingThis}
|
||||
className="h-9 px-3 text-amber-400 text-[10px] font-bold uppercase tracking-widest flex items-center gap-1.5 border border-amber-400/30 bg-amber-400/5 hover:bg-amber-400/10 transition-all"
|
||||
title={countdown ? `Drops in ${countdown} - Track to get notified!` : 'Track to get notified when available'}
|
||||
>
|
||||
{isTrackingThis ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
|
||||
Track
|
||||
</button>
|
||||
)
|
||||
) : status === 'taken' ? (
|
||||
<span className="h-9 px-3 text-rose-400/50 text-[10px] font-bold uppercase tracking-widest flex items-center gap-1.5 border border-rose-400/20 bg-rose-400/5">
|
||||
<Ban className="w-3 h-3" />
|
||||
Taken
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => checkStatus(item.id, fullDomain)}
|
||||
disabled={isChecking}
|
||||
className="h-9 px-4 border border-accent/40 text-accent text-[10px] font-bold uppercase tracking-widest flex items-center gap-1.5 hover:bg-accent/10 transition-all"
|
||||
title="Check availability status"
|
||||
>
|
||||
{isChecking ? <Loader2 className="w-3 h-3 animate-spin" /> : <Search className="w-3 h-3" />}
|
||||
Check
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => track(item.domain)}
|
||||
disabled={tracking === item.domain}
|
||||
className="flex-1 py-2 text-[10px] font-bold uppercase tracking-wider border border-white/[0.08] text-white/40 flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{tracking === item.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
|
||||
Track
|
||||
</button>
|
||||
<button onClick={() => openAnalyze(item.domain)} className="w-10 py-2 border border-white/[0.08] text-white/50 flex items-center justify-center">
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<a
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${item.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 py-2 bg-accent text-black text-[10px] font-bold uppercase flex items-center justify-center gap-1"
|
||||
>
|
||||
Get <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Row */}
|
||||
<div className="hidden lg:grid grid-cols-[1fr_60px_80px_100px] gap-4 items-center p-3 group">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className="text-sm shrink-0">{ALL_TLDS.find(t => t.tld === item.tld)?.flag || '🌐'}</span>
|
||||
<button
|
||||
onClick={() => openAnalyze(item.domain)}
|
||||
className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors text-left"
|
||||
>
|
||||
{item.domain}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<span className={clsx(
|
||||
"text-[10px] font-mono font-bold px-1.5 py-0.5",
|
||||
item.length <= 5 ? "text-accent bg-accent/10" : item.length <= 8 ? "text-amber-400 bg-amber-400/10" : "text-white/40 bg-white/5"
|
||||
)}>
|
||||
{item.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<span className="text-[10px] font-mono text-white/50">{formatTime(item.dropped_date)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1.5 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => track(item.domain)}
|
||||
disabled={tracking === item.domain}
|
||||
className="w-6 h-6 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5"
|
||||
>
|
||||
{tracking === item.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openAnalyze(item.domain)}
|
||||
className="w-6 h-6 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10"
|
||||
>
|
||||
<Shield className="w-3 h-3" />
|
||||
</button>
|
||||
<a
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${item.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-6 px-2 bg-accent text-black text-[10px] font-bold flex items-center gap-1 hover:bg-white"
|
||||
>
|
||||
Get
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-1 pt-2">
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<button
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="w-12 h-12 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-20 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="text-xs text-white/50 font-mono px-3">{page}/{totalPages}</span>
|
||||
<div className="flex items-center bg-white/[0.02] border border-white/[0.08] px-6 h-12">
|
||||
<span className="text-xs text-white/50 font-mono uppercase tracking-widest">
|
||||
Page <span className="text-white font-bold mx-1">{page}</span> / {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="w-12 h-12 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5 disabled:opacity-20 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -25,7 +25,7 @@ import {
|
||||
import clsx from 'clsx'
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// TYPES & CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
interface SearchResult {
|
||||
@ -35,8 +35,30 @@ interface SearchResult {
|
||||
registrar: string | null
|
||||
expiration_date: string | null
|
||||
loading: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface TldCheckResult {
|
||||
tld: string
|
||||
domain: string
|
||||
is_available: boolean | null
|
||||
loading: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Popular TLDs to check when user enters only a name without extension
|
||||
// Ordered by popularity/importance - most common first for faster perceived loading
|
||||
const POPULAR_TLDS = ['com', 'ch', 'io', 'net', 'org', 'de', 'ai', 'co', 'app', 'dev']
|
||||
|
||||
// Known valid TLDs (subset for quick validation)
|
||||
const KNOWN_TLDS = new Set([
|
||||
'com', 'net', 'org', 'io', 'ch', 'de', 'app', 'dev', 'co', 'ai', 'me', 'tv', 'cc',
|
||||
'xyz', 'info', 'biz', 'online', 'site', 'tech', 'store', 'club', 'shop', 'blog',
|
||||
'uk', 'fr', 'nl', 'eu', 'be', 'at', 'us', 'ca', 'au', 'li', 'it', 'es', 'pl',
|
||||
'pro', 'mobi', 'name', 'page', 'new', 'day', 'world', 'email', 'link', 'click',
|
||||
'digital', 'media', 'agency', 'studio', 'design', 'marketing', 'solutions',
|
||||
])
|
||||
|
||||
// ============================================================================
|
||||
// COMPONENT
|
||||
// ============================================================================
|
||||
@ -51,9 +73,11 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResult, setSearchResult] = useState<SearchResult | null>(null)
|
||||
const [tldResults, setTldResults] = useState<TldCheckResult[]>([])
|
||||
const [addingToWatchlist, setAddingToWatchlist] = useState(false)
|
||||
const [searchFocused, setSearchFocused] = useState(false)
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([])
|
||||
const [searchMode, setSearchMode] = useState<'single' | 'multi'>('single')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Load recent searches from localStorage
|
||||
@ -78,30 +102,139 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Check if TLD is valid
|
||||
const isValidTld = useCallback((tld: string): boolean => {
|
||||
return KNOWN_TLDS.has(tld.toLowerCase())
|
||||
}, [])
|
||||
|
||||
// Check single domain
|
||||
const checkSingleDomain = useCallback(async (domain: string): Promise<SearchResult> => {
|
||||
try {
|
||||
const result = await api.checkDomain(domain)
|
||||
return {
|
||||
domain: result.domain,
|
||||
status: result.status,
|
||||
is_available: result.is_available,
|
||||
registrar: result.registrar,
|
||||
expiration_date: result.expiration_date,
|
||||
loading: false,
|
||||
}
|
||||
} catch (err: any) {
|
||||
return {
|
||||
domain,
|
||||
status: 'error',
|
||||
is_available: null,
|
||||
registrar: null,
|
||||
expiration_date: null,
|
||||
loading: false,
|
||||
error: err?.message || 'Check failed',
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check multiple TLDs for a name - with progressive loading and quick mode
|
||||
const checkMultipleTlds = useCallback(async (name: string) => {
|
||||
// Initialize results with loading state
|
||||
const initialResults: TldCheckResult[] = POPULAR_TLDS.map(tld => ({
|
||||
tld,
|
||||
domain: `${name}.${tld}`,
|
||||
is_available: null,
|
||||
loading: true,
|
||||
}))
|
||||
setTldResults(initialResults)
|
||||
|
||||
// Check each TLD in parallel with progressive updates (using quick=true for speed)
|
||||
POPULAR_TLDS.forEach(async (tld, index) => {
|
||||
const domain = `${name}.${tld}`
|
||||
try {
|
||||
// Use quick=true for DNS-only check (much faster!)
|
||||
const result = await api.checkDomain(domain, true)
|
||||
setTldResults(prev => {
|
||||
const updated = [...prev]
|
||||
updated[index] = {
|
||||
tld,
|
||||
domain,
|
||||
is_available: result.is_available,
|
||||
loading: false,
|
||||
}
|
||||
return updated
|
||||
})
|
||||
} catch {
|
||||
setTldResults(prev => {
|
||||
const updated = [...prev]
|
||||
updated[index] = {
|
||||
tld,
|
||||
domain,
|
||||
is_available: null,
|
||||
loading: false,
|
||||
error: 'Check failed',
|
||||
}
|
||||
return updated
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Search Handler
|
||||
const handleSearch = useCallback(async (domainInput: string) => {
|
||||
if (!domainInput.trim()) {
|
||||
setSearchResult(null)
|
||||
setTldResults([])
|
||||
return
|
||||
}
|
||||
const cleanDomain = domainInput.trim().toLowerCase()
|
||||
setSearchResult({ domain: cleanDomain, status: 'checking', is_available: null, registrar: null, expiration_date: null, loading: true })
|
||||
|
||||
try {
|
||||
const whoisResult = await api.checkDomain(cleanDomain).catch(() => null)
|
||||
setSearchResult({
|
||||
domain: whoisResult?.domain || cleanDomain,
|
||||
status: whoisResult?.status || 'unknown',
|
||||
is_available: whoisResult?.is_available ?? null,
|
||||
registrar: whoisResult?.registrar || null,
|
||||
expiration_date: whoisResult?.expiration_date || null,
|
||||
loading: false,
|
||||
})
|
||||
saveToRecent(cleanDomain)
|
||||
} catch {
|
||||
setSearchResult({ domain: cleanDomain, status: 'error', is_available: null, registrar: null, expiration_date: null, loading: false })
|
||||
|
||||
const cleanInput = domainInput.trim().toLowerCase().replace(/\s+/g, '')
|
||||
|
||||
// Check if input contains a dot (has TLD)
|
||||
if (cleanInput.includes('.')) {
|
||||
// Single domain mode
|
||||
setSearchMode('single')
|
||||
setTldResults([])
|
||||
|
||||
const parts = cleanInput.split('.')
|
||||
const tld = parts[parts.length - 1]
|
||||
|
||||
// Check if TLD is valid
|
||||
if (!isValidTld(tld)) {
|
||||
setSearchResult({
|
||||
domain: cleanInput,
|
||||
status: 'invalid_tld',
|
||||
is_available: null,
|
||||
registrar: null,
|
||||
expiration_date: null,
|
||||
loading: false,
|
||||
error: `".${tld}" is not a valid domain extension`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setSearchResult({ domain: cleanInput, status: 'checking', is_available: null, registrar: null, expiration_date: null, loading: true })
|
||||
|
||||
const result = await checkSingleDomain(cleanInput)
|
||||
setSearchResult(result)
|
||||
if (!result.error) saveToRecent(cleanInput)
|
||||
} else {
|
||||
// Multi-TLD mode - check multiple extensions
|
||||
setSearchMode('multi')
|
||||
setSearchResult(null)
|
||||
|
||||
// Validate the name part
|
||||
if (cleanInput.length < 1 || cleanInput.length > 63) {
|
||||
setTldResults([])
|
||||
showToast('Domain name must be 1-63 characters', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(cleanInput) || cleanInput.startsWith('-') || cleanInput.endsWith('-')) {
|
||||
setTldResults([])
|
||||
showToast('Domain name contains invalid characters', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
await checkMultipleTlds(cleanInput)
|
||||
saveToRecent(cleanInput)
|
||||
}
|
||||
}, [saveToRecent])
|
||||
}, [saveToRecent, checkSingleDomain, checkMultipleTlds, isValidTld, showToast])
|
||||
|
||||
const handleAddToWatchlist = useCallback(async () => {
|
||||
if (!searchQuery.trim()) return
|
||||
@ -119,8 +252,11 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (searchQuery.length > 3) handleSearch(searchQuery)
|
||||
else setSearchResult(null)
|
||||
if (searchQuery.length >= 2) handleSearch(searchQuery)
|
||||
else {
|
||||
setSearchResult(null)
|
||||
setTldResults([])
|
||||
}
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchQuery, handleSearch])
|
||||
@ -147,7 +283,7 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => setSearchFocused(false)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch(searchQuery)}
|
||||
placeholder="example.com"
|
||||
placeholder="domain or name.tld"
|
||||
className="flex-1 bg-transparent px-3 py-4 text-base lg:text-lg text-white placeholder:text-white/20 outline-none font-mono"
|
||||
/>
|
||||
{searchQuery && (
|
||||
@ -162,7 +298,7 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
||||
onClick={() => handleSearch(searchQuery)}
|
||||
disabled={!searchQuery.trim()}
|
||||
className={clsx(
|
||||
"h-full px-4 py-4 text-sm font-bold uppercase tracking-wider transition-all",
|
||||
"h-full px-6 py-4 text-xs font-bold uppercase tracking-widest transition-all shrink-0 border-l border-white/10",
|
||||
searchQuery.trim() ? "bg-accent text-black hover:bg-white" : "bg-white/5 text-white/20"
|
||||
)}
|
||||
>
|
||||
@ -172,97 +308,122 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-white/40">
|
||||
<span>Enter a domain to check availability via RDAP/WHOIS</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Instant check
|
||||
</span>
|
||||
<div className="flex items-center justify-between px-1 text-[10px] font-mono text-white/30 uppercase tracking-[0.1em]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1 h-1 bg-accent rounded-full animate-pulse" />
|
||||
<span>Enter a name or full domain</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-3 h-3 text-accent/60" />
|
||||
<span>RDAP/WHOIS READY</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Result */}
|
||||
{searchResult && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 duration-200">
|
||||
{/* Single Domain Result */}
|
||||
{searchMode === 'single' && searchResult && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
{searchResult.loading ? (
|
||||
<div className="flex items-center justify-center gap-3 py-12 border border-white/[0.08] bg-white/[0.02]">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-accent" />
|
||||
<span className="text-sm text-white/50 font-mono">Checking availability...</span>
|
||||
<div className="flex flex-col items-center justify-center py-20 border border-white/[0.08] bg-white/[0.01]">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-accent/20 blur-xl rounded-full animate-pulse" />
|
||||
<Loader2 className="w-8 h-8 animate-spin text-accent relative z-10" />
|
||||
</div>
|
||||
<span className="text-[10px] text-white/40 font-mono mt-4 uppercase tracking-[0.2em]">Checking global availability...</span>
|
||||
</div>
|
||||
) : searchResult.error ? (
|
||||
// Error state
|
||||
<div className="border border-rose-500/30 bg-rose-500/[0.02]">
|
||||
<div className="p-4 lg:p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 flex items-center justify-center border border-rose-500/30 bg-rose-500/10 shrink-0">
|
||||
<XCircle className="w-6 h-6 text-rose-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xl font-bold text-white font-mono truncate tracking-tight">{searchResult.domain}</div>
|
||||
<div className="text-[11px] font-mono text-rose-400 mt-1 uppercase tracking-wider">{searchResult.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={clsx(
|
||||
"border-2 overflow-hidden bg-[#020202]",
|
||||
searchResult.is_available ? "border-accent/40" : "border-white/[0.08]"
|
||||
"border bg-[#020202] transition-all duration-500",
|
||||
searchResult.is_available ? "border-accent/30 shadow-[0_0_40px_-20px_rgba(34,211,126,0.2)]" : "border-rose-500/20"
|
||||
)}>
|
||||
{/* Result Row */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||
{/* Status Icon */}
|
||||
<div className="p-4 lg:p-6">
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-[1fr_auto] gap-6 items-center">
|
||||
<div className="flex items-center gap-5 min-w-0 w-full">
|
||||
{/* Status Indicator */}
|
||||
<div className={clsx(
|
||||
"w-12 h-12 flex items-center justify-center border shrink-0",
|
||||
searchResult.is_available ? "bg-accent/10 border-accent/30" : "bg-white/[0.02] border-white/[0.08]"
|
||||
"w-16 h-16 flex items-center justify-center border shrink-0 transition-colors duration-500",
|
||||
searchResult.is_available ? "bg-accent/10 border-accent/20" : "bg-rose-500/5 border-rose-500/20"
|
||||
)}>
|
||||
{searchResult.is_available ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-accent" />
|
||||
<CheckCircle2 className="w-8 h-8 text-accent" />
|
||||
) : (
|
||||
<XCircle className="w-6 h-6 text-white/30" />
|
||||
<XCircle className="w-8 h-8 text-rose-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Domain Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-lg font-bold text-white font-mono truncate">{searchResult.domain}</div>
|
||||
<div className="flex items-center gap-3 text-[10px] font-mono text-white/40 mt-1">
|
||||
<div className={clsx(
|
||||
"text-2xl lg:text-3xl font-bold font-mono truncate tracking-tight",
|
||||
searchResult.is_available ? "text-white" : "text-rose-400"
|
||||
)}>
|
||||
{searchResult.domain}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-y-2 gap-x-4 mt-2">
|
||||
<span className={clsx(
|
||||
"px-2 py-0.5 uppercase font-bold",
|
||||
searchResult.is_available ? "bg-accent/20 text-accent" : "bg-white/10 text-white/50"
|
||||
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest border",
|
||||
searchResult.is_available
|
||||
? "bg-accent/10 border-accent/20 text-accent"
|
||||
: "bg-rose-500/10 border-rose-500/20 text-rose-400"
|
||||
)}>
|
||||
{searchResult.is_available ? 'Available' : 'Taken'}
|
||||
</span>
|
||||
|
||||
{searchResult.registrar && (
|
||||
<>
|
||||
<span className="text-white/10">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Building className="w-3 h-3" />
|
||||
{searchResult.registrar}
|
||||
</span>
|
||||
</>
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-mono text-white/30 uppercase tracking-wider">
|
||||
<Building className="w-3 h-3" />
|
||||
{searchResult.registrar}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResult.expiration_date && (
|
||||
<>
|
||||
<span className="text-white/10">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
Expires {new Date(searchResult.expiration_date).toLocaleDateString()}
|
||||
</span>
|
||||
</>
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-mono text-white/30 uppercase tracking-wider">
|
||||
<Clock className="w-3 h-3" />
|
||||
Expires {new Date(searchResult.expiration_date).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex items-center gap-2 w-full lg:w-auto">
|
||||
<button
|
||||
onClick={() => openAnalyze(searchResult.domain)}
|
||||
className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10 transition-colors"
|
||||
className="flex-1 lg:w-12 lg:h-12 h-12 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/30 hover:bg-accent/5 transition-all"
|
||||
title="Deep Analysis"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
<Shield className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleAddToWatchlist}
|
||||
disabled={addingToWatchlist}
|
||||
className={clsx(
|
||||
"w-9 h-9 flex items-center justify-center border transition-colors",
|
||||
"flex-1 lg:w-12 lg:h-12 h-12 flex items-center justify-center border transition-all",
|
||||
searchResult.is_available
|
||||
? "border-white/10 text-white/30 hover:text-white hover:bg-white/5"
|
||||
: "border-accent/30 text-accent hover:bg-accent/10"
|
||||
: "border-rose-500/20 text-rose-400/40 hover:text-rose-400 hover:bg-rose-500/5"
|
||||
)}
|
||||
title={searchResult.is_available ? "Track" : "Monitor for drops"}
|
||||
title={searchResult.is_available ? "Track domain" : "Monitor drop"}
|
||||
>
|
||||
{addingToWatchlist ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
|
||||
{addingToWatchlist ? <Loader2 className="w-5 h-5 animate-spin" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
{searchResult.is_available ? (
|
||||
@ -270,21 +431,20 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${searchResult.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-9 px-4 bg-accent text-black text-xs font-bold uppercase tracking-wider flex items-center gap-2 hover:bg-white transition-colors"
|
||||
className="flex-[2] lg:flex-none h-12 px-8 bg-accent text-black text-[11px] font-black uppercase tracking-[0.1em] flex items-center justify-center gap-2 hover:bg-white transition-all shadow-[0_0_20px_-10px_rgba(34,211,126,0.5)]"
|
||||
>
|
||||
Register
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
Buy Now
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={`https://www.expireddomains.net/domain-name-search/?q=${searchResult.domain.split('.')[0]}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-9 px-4 bg-white/10 text-white text-xs font-bold uppercase tracking-wider flex items-center gap-2 hover:bg-white/20 transition-colors"
|
||||
<button
|
||||
onClick={handleAddToWatchlist}
|
||||
disabled={addingToWatchlist}
|
||||
className="flex-[2] lg:flex-none h-12 px-8 bg-rose-500/10 text-rose-400 text-[11px] font-bold uppercase tracking-[0.1em] flex items-center justify-center gap-2 hover:bg-rose-500/20 border border-rose-500/20 transition-all"
|
||||
>
|
||||
Find Similar
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
{addingToWatchlist ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
|
||||
Monitor
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -294,25 +454,122 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multi-TLD Results */}
|
||||
{searchMode === 'multi' && tldResults.length > 0 && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 duration-400">
|
||||
<div className="border border-white/[0.08] bg-[#020202]">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-white/[0.08] flex items-center justify-between bg-white/[0.01]">
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-4 h-4 text-accent/60" />
|
||||
<span className="text-[10px] font-mono text-white/50 uppercase tracking-[0.2em]">
|
||||
Global availability check: <span className="text-white">"{searchQuery}"</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
<span className="text-[10px] font-mono text-accent uppercase font-bold">
|
||||
{tldResults.filter(r => r.is_available === true).length} Free
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-rose-500/40" />
|
||||
<span className="text-[10px] font-mono text-white/20 uppercase">
|
||||
{tldResults.filter(r => r.is_available === false).length} Taken
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TLD Grid */}
|
||||
<div className="p-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
|
||||
{tldResults.map((result) => (
|
||||
<div
|
||||
key={result.tld}
|
||||
className={clsx(
|
||||
"relative p-4 border transition-all duration-300 group",
|
||||
result.loading
|
||||
? "border-white/[0.05] bg-white/[0.01]"
|
||||
: result.is_available
|
||||
? "border-accent/20 bg-accent/[0.02] hover:border-accent/60 hover:bg-accent/[0.08] cursor-pointer"
|
||||
: "border-white/[0.05] bg-white/[0.01] opacity-60"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (result.is_available && !result.loading) {
|
||||
setSearchQuery(result.domain)
|
||||
setSearchMode('single')
|
||||
setTldResults([])
|
||||
handleSearch(result.domain)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{result.loading ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-white/10" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className={clsx(
|
||||
"text-sm font-mono font-black tracking-tight",
|
||||
result.is_available ? "text-white group-hover:text-accent" : "text-white/20"
|
||||
)}>
|
||||
.{result.tld}
|
||||
</span>
|
||||
{result.is_available ? (
|
||||
<div className="w-5 h-5 rounded-full bg-accent/10 border border-accent/20 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-3 h-3 text-accent" />
|
||||
</div>
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-white/10" />
|
||||
)}
|
||||
</div>
|
||||
<div className={clsx(
|
||||
"text-[9px] font-mono uppercase tracking-[0.1em] font-bold",
|
||||
result.is_available ? "text-accent" : "text-white/20"
|
||||
)}>
|
||||
{result.is_available ? 'Available' : 'Taken'}
|
||||
</div>
|
||||
|
||||
{result.is_available && (
|
||||
<div className="absolute bottom-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ArrowRight className="w-3 h-3 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="px-4 py-3 border-t border-white/[0.04] bg-white/[0.01] text-[9px] font-mono text-white/20 text-center uppercase tracking-widest">
|
||||
Click an available extension to analyze and buy
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Searches */}
|
||||
{!searchResult && recentSearches.length > 0 && (
|
||||
<div className="border border-white/[0.08] bg-white/[0.02]">
|
||||
<div className="px-4 py-3 border-b border-white/[0.08] flex items-center justify-between">
|
||||
<div className="border border-white/[0.08] bg-white/[0.01]">
|
||||
<div className="px-5 py-3 border-b border-white/[0.08] flex items-center justify-between bg-white/[0.01]">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="w-4 h-4 text-white/30" />
|
||||
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Recent Searches</span>
|
||||
<History className="w-4 h-4 text-white/20" />
|
||||
<span className="text-[10px] font-mono text-white/40 uppercase tracking-[0.2em]">Recent History</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setRecentSearches([])
|
||||
localStorage.removeItem('pounce_recent_searches')
|
||||
}}
|
||||
className="text-[10px] font-mono text-white/30 hover:text-white transition-colors"
|
||||
className="text-[10px] font-mono text-white/20 hover:text-white transition-colors uppercase tracking-widest"
|
||||
>
|
||||
Clear
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="p-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recentSearches.map((domain) => (
|
||||
<button
|
||||
@ -321,9 +578,9 @@ export function SearchTab({ showToast }: SearchTabProps) {
|
||||
setSearchQuery(domain)
|
||||
handleSearch(domain)
|
||||
}}
|
||||
className="group px-3 py-2 border border-white/[0.08] bg-[#020202] hover:border-accent/30 hover:bg-accent/[0.03] transition-all"
|
||||
className="group px-4 py-2 border border-white/[0.08] bg-white/[0.01] hover:border-accent/30 hover:bg-accent/[0.03] transition-all"
|
||||
>
|
||||
<span className="text-xs font-mono text-white/60 group-hover:text-accent transition-colors">{domain}</span>
|
||||
<span className="text-xs font-mono text-white/40 group-hover:text-accent transition-colors">{domain}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,612 +1,436 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Search,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Eye,
|
||||
TrendingUp,
|
||||
RefreshCw,
|
||||
Globe,
|
||||
Zap,
|
||||
X,
|
||||
Check,
|
||||
Copy,
|
||||
ShoppingCart,
|
||||
Flame,
|
||||
ArrowRight,
|
||||
AlertCircle
|
||||
Sparkles,
|
||||
Lock,
|
||||
Globe,
|
||||
X,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||
import { useStore } from '@/lib/store'
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & CONSTANTS
|
||||
// CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
const GEO_OPTIONS = [
|
||||
{ value: 'US', label: 'United States', flag: '🇺🇸' },
|
||||
{ value: 'CH', label: 'Switzerland', flag: '🇨🇭' },
|
||||
{ value: 'DE', label: 'Germany', flag: '🇩🇪' },
|
||||
{ value: 'GB', label: 'United Kingdom', flag: '🇬🇧' },
|
||||
{ value: 'FR', label: 'France', flag: '🇫🇷' },
|
||||
{ value: 'CA', label: 'Canada', flag: '🇨🇦' },
|
||||
{ value: 'AU', label: 'Australia', flag: '🇦🇺' },
|
||||
const GEOS = [
|
||||
{ code: 'US', flag: '🇺🇸', name: 'USA' },
|
||||
{ code: 'DE', flag: '🇩🇪', name: 'Germany' },
|
||||
{ code: 'GB', flag: '🇬🇧', name: 'UK' },
|
||||
{ code: 'CH', flag: '🇨🇭', name: 'Switzerland' },
|
||||
{ code: 'FR', flag: '🇫🇷', name: 'France' },
|
||||
]
|
||||
|
||||
const POPULAR_TLDS = ['com', 'io', 'ai', 'co', 'net', 'org', 'app', 'dev']
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
function normalizeKeyword(s: string) {
|
||||
return s.trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
const TLDS = ['com', 'io', 'ai', 'co', 'net', 'app']
|
||||
|
||||
// ============================================================================
|
||||
// COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function TrendSurferTab({ showToast }: { showToast: (message: string, type?: any) => void }) {
|
||||
export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?: any) => void }) {
|
||||
const openAnalyze = useAnalyzePanelStore((s) => s.open)
|
||||
const addDomain = useStore((s) => s.addDomain)
|
||||
|
||||
// Trends State
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const subscription = useStore((s) => s.subscription)
|
||||
const tier = (subscription?.tier || '').toLowerCase()
|
||||
const hasAI = tier === 'trader' || tier === 'tycoon'
|
||||
|
||||
// State
|
||||
const [geo, setGeo] = useState('US')
|
||||
const [trends, setTrends] = useState<Array<{ title: string; approx_traffic?: string | null; link?: string | null }>>([])
|
||||
const [selected, setSelected] = useState<string>('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
// Keyword Check State
|
||||
const [keywordInput, setKeywordInput] = useState('')
|
||||
const [keywordFocused, setKeywordFocused] = useState(false)
|
||||
const [selectedTlds, setSelectedTlds] = useState<string[]>(['com', 'io', 'ai'])
|
||||
const [availability, setAvailability] = useState<Array<{ domain: string; status: string; is_available: boolean | null }>>([])
|
||||
const [checking, setChecking] = useState(false)
|
||||
|
||||
// Typo Check State
|
||||
const [brand, setBrand] = useState('')
|
||||
const [brandFocused, setBrandFocused] = useState(false)
|
||||
const [typos, setTypos] = useState<Array<{ domain: string; status: string }>>([])
|
||||
const [typoLoading, setTypoLoading] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [trends, setTrends] = useState<Array<{ title: string; approx_traffic?: string | null }>>([])
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [tlds, setTlds] = useState(['com', 'io', 'ai'])
|
||||
|
||||
// Tracking & Copy State
|
||||
const [tracking, setTracking] = useState<string | null>(null)
|
||||
// Keywords to check (original + AI expanded)
|
||||
const [keywords, setKeywords] = useState<string[]>([])
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
|
||||
// Results
|
||||
const [results, setResults] = useState<Array<{ domain: string; available: boolean }>>([])
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
const [tracking, setTracking] = useState<string | null>(null)
|
||||
|
||||
const copyDomain = useCallback((domain: string) => {
|
||||
navigator.clipboard.writeText(domain)
|
||||
setCopied(domain)
|
||||
setTimeout(() => setCopied(null), 1500)
|
||||
}, [])
|
||||
|
||||
const track = useCallback(
|
||||
async (domain: string) => {
|
||||
if (tracking) return
|
||||
setTracking(domain)
|
||||
try {
|
||||
await addDomain(domain)
|
||||
showToast(`Added to watchlist: ${domain}`, 'success')
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : 'Failed to track domain', 'error')
|
||||
} finally {
|
||||
setTracking(null)
|
||||
}
|
||||
},
|
||||
[addDomain, showToast, tracking]
|
||||
)
|
||||
|
||||
const loadTrends = useCallback(async (isRefresh = false) => {
|
||||
if (isRefresh) setRefreshing(true)
|
||||
setError(null)
|
||||
// Load trends
|
||||
const loadTrends = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.getHuntTrends(geo)
|
||||
setTrends(res.items || [])
|
||||
if (!selected && res.items?.[0]?.title) setSelected(res.items[0].title)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setError(msg)
|
||||
setTrends([])
|
||||
showToast('Failed to load trends', 'error')
|
||||
} finally {
|
||||
if (isRefresh) setRefreshing(false)
|
||||
setLoading(false)
|
||||
}
|
||||
}, [geo, selected])
|
||||
}, [geo, showToast])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await loadTrends()
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
run()
|
||||
return () => { cancelled = true }
|
||||
loadTrends()
|
||||
}, [loadTrends])
|
||||
|
||||
const keyword = useMemo(() => normalizeKeyword(keywordInput || selected || ''), [keywordInput, selected])
|
||||
|
||||
const toggleTld = useCallback((tld: string) => {
|
||||
setSelectedTlds(prev =>
|
||||
prev.includes(tld) ? prev.filter(t => t !== tld) : [...prev, tld]
|
||||
)
|
||||
}, [])
|
||||
|
||||
const runCheck = useCallback(async () => {
|
||||
if (!keyword) return
|
||||
if (selectedTlds.length === 0) {
|
||||
showToast('Select at least one TLD', 'error')
|
||||
return
|
||||
// When a trend is selected, set the base keyword and auto-expand with AI if available
|
||||
const selectTrend = useCallback(async (trend: string) => {
|
||||
const baseKeyword = trend.toLowerCase().replace(/\s+/g, '')
|
||||
setSelected(trend)
|
||||
setKeywords([baseKeyword])
|
||||
setResults([])
|
||||
|
||||
// Auto-expand with AI if available
|
||||
if (hasAI) {
|
||||
setAiLoading(true)
|
||||
try {
|
||||
const res = await api.expandTrendKeywords(trend, geo)
|
||||
if (res.keywords?.length) {
|
||||
// Combine base + AI keywords, remove duplicates
|
||||
const all = [baseKeyword, ...res.keywords.filter(k => k !== baseKeyword)]
|
||||
setKeywords(all.slice(0, 8)) // Max 8 keywords
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail for AI
|
||||
} finally {
|
||||
setAiLoading(false)
|
||||
}
|
||||
}
|
||||
}, [geo, hasAI])
|
||||
|
||||
// Check availability for all keywords
|
||||
const checkAvailability = useCallback(async () => {
|
||||
if (keywords.length === 0 || tlds.length === 0) return
|
||||
setChecking(true)
|
||||
setResults([])
|
||||
try {
|
||||
const kw = keyword.toLowerCase().replace(/\s+/g, '')
|
||||
const res = await api.huntKeywords({ keywords: [kw], tlds: selectedTlds })
|
||||
setAvailability(res.items.map((r) => ({ domain: r.domain, status: r.status, is_available: r.is_available })))
|
||||
const res = await api.huntKeywords({ keywords, tlds })
|
||||
setResults(res.items.map(i => ({ domain: i.domain, available: i.status === 'available' })))
|
||||
const avail = res.items.filter(i => i.status === 'available').length
|
||||
showToast(`Found ${avail} available domains!`, 'success')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to check availability'
|
||||
showToast(msg, 'error')
|
||||
setAvailability([])
|
||||
showToast('Check failed', 'error')
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}, [keyword, selectedTlds, showToast])
|
||||
}, [keywords, tlds, showToast])
|
||||
|
||||
const runTypos = useCallback(async () => {
|
||||
const b = brand.trim()
|
||||
if (!b) return
|
||||
setTypoLoading(true)
|
||||
try {
|
||||
const res = await api.huntTypos({ brand: b, tlds: ['com'], limit: 50 })
|
||||
setTypos(res.items.map((i) => ({ domain: i.domain, status: i.status })))
|
||||
if (res.items.length === 0) {
|
||||
showToast('No available typo domains found', 'info')
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to run typo check'
|
||||
showToast(msg, 'error')
|
||||
setTypos([])
|
||||
} finally {
|
||||
setTypoLoading(false)
|
||||
}
|
||||
}, [brand, showToast])
|
||||
|
||||
const availableCount = useMemo(() => availability.filter(a => a.status === 'available').length, [availability])
|
||||
const currentGeo = GEO_OPTIONS.find(g => g.value === geo)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Skeleton Loader */}
|
||||
<div className="border border-white/[0.08] bg-[#020202] animate-pulse">
|
||||
<div className="px-4 py-4 border-b border-white/[0.08]">
|
||||
<div className="h-5 w-48 bg-white/10 rounded mb-2" />
|
||||
<div className="h-3 w-32 bg-white/5 rounded" />
|
||||
</div>
|
||||
<div className="p-4 flex flex-wrap gap-2">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="h-10 w-24 bg-white/5 rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
// Remove a keyword
|
||||
const removeKeyword = (kw: string) => {
|
||||
setKeywords(prev => prev.filter(k => k !== kw))
|
||||
}
|
||||
|
||||
// Add custom keyword
|
||||
const [customKw, setCustomKw] = useState('')
|
||||
const addKeyword = () => {
|
||||
const kw = customKw.trim().toLowerCase().replace(/\s+/g, '')
|
||||
if (kw && !keywords.includes(kw)) {
|
||||
setKeywords(prev => [...prev, kw])
|
||||
setCustomKw('')
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
const copy = (domain: string) => {
|
||||
navigator.clipboard.writeText(domain)
|
||||
setCopied(domain)
|
||||
setTimeout(() => setCopied(null), 1500)
|
||||
}
|
||||
|
||||
const track = async (domain: string) => {
|
||||
if (tracking) return
|
||||
setTracking(domain)
|
||||
try {
|
||||
await addDomain(domain)
|
||||
showToast(`Added: ${domain}`, 'success')
|
||||
} catch (e) {
|
||||
showToast('Failed to track', 'error')
|
||||
} finally {
|
||||
setTracking(null)
|
||||
}
|
||||
}
|
||||
|
||||
const availableResults = results.filter(r => r.available)
|
||||
const takenResults = results.filter(r => !r.available)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
{/* TRENDING TOPICS */}
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
<div className="border border-white/[0.08] bg-[#020202]">
|
||||
<div className="px-4 py-4 border-b border-white/[0.08]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-accent/20 to-accent/5 border border-accent/30 flex items-center justify-center">
|
||||
<Flame className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">Trending Now</h3>
|
||||
<p className="text-[11px] font-mono text-white/40">
|
||||
Real-time Google Trends • {currentGeo?.flag} {currentGeo?.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={geo}
|
||||
onChange={(e) => { setGeo(e.target.value); setSelected(''); setAvailability([]) }}
|
||||
className="bg-white/[0.03] border border-white/10 px-3 py-2 text-xs font-mono text-white/70 outline-none focus:border-accent/40 cursor-pointer hover:bg-white/[0.05] transition-colors"
|
||||
>
|
||||
{GEO_OPTIONS.map(g => (
|
||||
<option key={g.value} value={g.value}>{g.flag} {g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => loadTrends(true)}
|
||||
disabled={refreshing}
|
||||
className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/40 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-white/[0.08]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-orange-500/10 border border-orange-500/20 flex items-center justify-center shrink-0">
|
||||
<Flame className="w-6 h-6 text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white font-mono tracking-tight">Trend Surfer</h2>
|
||||
<p className="text-[10px] font-mono text-white/40 uppercase tracking-widest mt-1">Ride the viral wave with AI-powered domain hunt</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="p-4 flex items-center gap-3 bg-rose-500/5 border-b border-rose-500/20">
|
||||
<AlertCircle className="w-4 h-4 text-rose-400 shrink-0" />
|
||||
<p className="text-xs font-mono text-rose-400">{error}</p>
|
||||
<button
|
||||
onClick={() => loadTrends(true)}
|
||||
className="ml-auto text-[10px] font-mono text-rose-400 underline hover:no-underline"
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<select
|
||||
value={geo}
|
||||
onChange={(e) => { setGeo(e.target.value); setSelected(null); setKeywords([]); setResults([]) }}
|
||||
className="h-10 pl-4 pr-10 bg-white/[0.02] border border-white/10 text-xs font-mono text-white uppercase tracking-widest appearance-none outline-none focus:border-accent/30 transition-all cursor-pointer"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
{GEOS.map(g => (
|
||||
<option key={g.code} value={g.code}>{g.flag} {g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/30 pointer-events-none" />
|
||||
</div>
|
||||
<button
|
||||
onClick={loadTrends}
|
||||
disabled={loading}
|
||||
className="h-10 w-10 flex items-center justify-center border border-white/10 text-white/40 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trends Grid */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 px-1 text-[10px] font-mono text-white/30 uppercase tracking-[0.2em]">
|
||||
<div className="w-1 h-1 bg-orange-500 rounded-full animate-pulse" />
|
||||
<span>Real-time Viral Topics ({geo})</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div key={i} className="h-14 bg-white/[0.01] border border-white/[0.05] animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{trends.slice(0, 16).map((t, idx) => {
|
||||
const active = selected === t.title
|
||||
const isHot = idx < 3
|
||||
return (
|
||||
<button
|
||||
key={t.title}
|
||||
onClick={() => {
|
||||
setSelected(t.title)
|
||||
setKeywordInput('')
|
||||
setAvailability([])
|
||||
}}
|
||||
className={clsx(
|
||||
'group relative px-4 py-2.5 border text-left transition-all',
|
||||
active
|
||||
? 'border-accent bg-accent/10'
|
||||
: 'border-white/[0.08] hover:border-white/20 bg-white/[0.02] hover:bg-white/[0.04]'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isHot && (
|
||||
<span className="text-[9px] font-bold text-orange-400 bg-orange-400/10 px-1 py-0.5">
|
||||
🔥
|
||||
</span>
|
||||
)}
|
||||
<span className={clsx(
|
||||
"text-xs font-medium truncate max-w-[140px]",
|
||||
active ? "text-accent" : "text-white/70 group-hover:text-white"
|
||||
)}>
|
||||
{t.title}
|
||||
</span>
|
||||
</div>
|
||||
{t.approx_traffic && (
|
||||
<div className="text-[9px] text-white/30 mt-0.5 font-mono">{t.approx_traffic}</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{trends.length === 0 && (
|
||||
<div className="text-center py-6 text-white/30 text-xs font-mono">
|
||||
No trends available for this region
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{trends.slice(0, 12).map((t, idx) => {
|
||||
const isSelected = selected === t.title
|
||||
return (
|
||||
<button
|
||||
key={t.title}
|
||||
onClick={() => selectTrend(t.title)}
|
||||
className={clsx(
|
||||
"relative px-4 py-3.5 text-left border transition-all duration-300 group overflow-hidden",
|
||||
isSelected
|
||||
? "border-accent bg-accent/10 shadow-[0_0_20px_-10px_rgba(34,211,126,0.3)]"
|
||||
: "border-white/[0.08] bg-white/[0.01] hover:border-white/20 hover:bg-white/[0.03]"
|
||||
)}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-2">
|
||||
<span className={clsx(
|
||||
"text-xs font-bold font-mono truncate tracking-tight",
|
||||
isSelected ? "text-accent" : "text-white/70 group-hover:text-white"
|
||||
)}>
|
||||
{t.title}
|
||||
</span>
|
||||
{idx < 3 && !isSelected && <Flame className="w-3 h-3 text-orange-500/40 group-hover:text-orange-500 transition-colors" />}
|
||||
</div>
|
||||
{isSelected && <div className="absolute top-0 right-0 w-1.5 h-1.5 bg-accent" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
{/* DOMAIN AVAILABILITY CHECKER */}
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
<div className="border border-white/[0.08] bg-[#020202]">
|
||||
<div className="px-4 py-4 border-b border-white/[0.08]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-white/[0.03] border border-white/[0.08] flex items-center justify-center">
|
||||
<Globe className="w-5 h-5 text-white/50" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">Check Availability</h3>
|
||||
<p className="text-[11px] font-mono text-white/40">
|
||||
{keyword ? `Find ${keyword.toLowerCase().replace(/\s+/g, '')} across multiple TLDs` : 'Select a trend or enter a keyword'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Keyword Input */}
|
||||
<div className="flex gap-2">
|
||||
<div className={clsx(
|
||||
"flex-1 relative border-2 transition-all",
|
||||
keywordFocused ? "border-accent bg-accent/[0.03]" : "border-white/[0.08] bg-white/[0.02]"
|
||||
)}>
|
||||
<div className="flex items-center">
|
||||
<Search className={clsx("w-4 h-4 ml-4 transition-colors", keywordFocused ? "text-accent" : "text-white/30")} />
|
||||
<input
|
||||
value={keywordInput || selected}
|
||||
onChange={(e) => setKeywordInput(e.target.value)}
|
||||
onFocus={() => setKeywordFocused(true)}
|
||||
onBlur={() => setKeywordFocused(false)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && runCheck()}
|
||||
placeholder="Enter keyword or select trend above..."
|
||||
className="flex-1 bg-transparent px-3 py-3.5 text-sm text-white placeholder:text-white/25 outline-none font-mono"
|
||||
/>
|
||||
{(keywordInput || selected) && (
|
||||
<button
|
||||
onClick={() => { setKeywordInput(''); setSelected(''); setAvailability([]) }}
|
||||
className="p-3 text-white/30 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{/* Keyword Builder */}
|
||||
{selected && (
|
||||
<div className="border border-white/[0.08] bg-white/[0.01] overflow-hidden animate-in fade-in slide-in-from-top-4 duration-500">
|
||||
<div className="px-5 py-4 border-b border-white/[0.08] flex items-center justify-between bg-white/[0.01]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-accent/10 border border-accent/20 flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-mono text-white/30 uppercase tracking-[0.2em]">Target Concept</div>
|
||||
<div className="text-sm font-bold text-white uppercase tracking-wider">{selected}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={runCheck}
|
||||
disabled={!keyword || checking}
|
||||
onClick={() => { setSelected(null); setKeywords([]); setResults([]) }}
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-6">
|
||||
{/* Keywords */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-mono text-white/30 uppercase tracking-widest">Constructed Keywords</span>
|
||||
{aiLoading && (
|
||||
<span className="flex items-center gap-2 text-[9px] font-mono text-purple-400 uppercase font-bold animate-pulse">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
AI Expansion in progress...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{keywords.map((kw, idx) => (
|
||||
<span
|
||||
key={kw}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-2 px-3 py-1.5 text-xs font-mono border transition-all",
|
||||
idx === 0
|
||||
? "bg-accent/10 border-accent/30 text-accent font-bold"
|
||||
: "bg-purple-500/10 border-purple-500/20 text-purple-300"
|
||||
)}
|
||||
>
|
||||
{kw}
|
||||
<button onClick={() => removeKeyword(kw)} className="text-white/20 hover:text-rose-400 transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{/* Add custom keyword */}
|
||||
<div className="relative">
|
||||
<input
|
||||
value={customKw}
|
||||
onChange={(e) => setCustomKw(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addKeyword()}
|
||||
placeholder="+ CUSTOM KW"
|
||||
className="w-28 px-3 py-1.5 bg-white/[0.03] border border-white/10 text-[10px] font-mono text-white placeholder:text-white/20 outline-none focus:border-accent/30 uppercase tracking-wider transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TLDs */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-[10px] font-mono text-white/30 uppercase tracking-widest">Selected Extensions</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{TLDS.map(tld => (
|
||||
<button
|
||||
key={tld}
|
||||
onClick={() => setTlds(prev =>
|
||||
prev.includes(tld) ? prev.filter(t => t !== tld) : [...prev, tld]
|
||||
)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 text-[10px] font-mono border transition-all uppercase tracking-widest",
|
||||
tlds.includes(tld)
|
||||
? "border-accent bg-accent/10 text-accent font-black"
|
||||
: "border-white/10 text-white/40 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
.{tld}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Check Button */}
|
||||
<button
|
||||
onClick={checkAvailability}
|
||||
disabled={checking || tlds.length === 0 || keywords.length === 0}
|
||||
className={clsx(
|
||||
"px-6 py-3 text-sm font-bold uppercase tracking-wider transition-all flex items-center gap-2",
|
||||
!keyword || checking
|
||||
? "bg-white/5 text-white/20 cursor-not-allowed"
|
||||
: "bg-accent text-black hover:bg-white"
|
||||
"w-full py-4 text-[11px] font-black uppercase tracking-[0.2em] flex items-center justify-center gap-3 transition-all",
|
||||
checking || tlds.length === 0 || keywords.length === 0
|
||||
? "bg-white/5 text-white/20 border border-white/5"
|
||||
: "bg-accent text-black hover:bg-white shadow-[0_0_30px_-10px_rgba(34,211,126,0.4)]"
|
||||
)}
|
||||
>
|
||||
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />}
|
||||
Check
|
||||
Check {keywords.length * tlds.length} Variations
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* TLD Selection */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Select TLDs</span>
|
||||
<span className="text-[10px] font-mono text-white/20">({selectedTlds.length} selected)</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{POPULAR_TLDS.map(tld => (
|
||||
<button
|
||||
key={tld}
|
||||
onClick={() => toggleTld(tld)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 text-[11px] font-mono uppercase border transition-all",
|
||||
selectedTlds.includes(tld)
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-white/[0.08] text-white/40 hover:text-white/60 hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
.{tld}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{availability.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">
|
||||
Results • {availableCount} available
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{availability.map((a) => {
|
||||
const isAvailable = a.status === 'available'
|
||||
return (
|
||||
<div
|
||||
key={a.domain}
|
||||
className={clsx(
|
||||
"p-3 flex items-center justify-between gap-3 border transition-all",
|
||||
isAvailable
|
||||
? "bg-accent/[0.03] border-accent/20 hover:bg-accent/[0.06]"
|
||||
: "bg-white/[0.02] border-white/[0.06] hover:bg-white/[0.04]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className={clsx(
|
||||
"w-2.5 h-2.5 rounded-full shrink-0",
|
||||
isAvailable ? "bg-accent" : "bg-white/20"
|
||||
)} />
|
||||
<button
|
||||
onClick={() => openAnalyze(a.domain)}
|
||||
className={clsx(
|
||||
"text-sm font-mono truncate text-left transition-colors",
|
||||
isAvailable ? "text-white hover:text-accent" : "text-white/50"
|
||||
)}
|
||||
>
|
||||
{a.domain}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={clsx(
|
||||
"text-[10px] font-mono font-bold px-2 py-1 border",
|
||||
isAvailable
|
||||
? "text-accent bg-accent/10 border-accent/30"
|
||||
: "text-white/30 bg-white/5 border-white/10"
|
||||
)}>
|
||||
{isAvailable ? '✓ AVAIL' : 'TAKEN'}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => copyDomain(a.domain)}
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-all"
|
||||
title="Copy"
|
||||
>
|
||||
{copied === a.domain ? <Check className="w-3.5 h-3.5 text-accent" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => track(a.domain)}
|
||||
disabled={tracking === a.domain}
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-all"
|
||||
title="Add to Watchlist"
|
||||
>
|
||||
{tracking === a.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => openAnalyze(a.domain)}
|
||||
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent hover:border-accent/20 hover:bg-accent/10 transition-all"
|
||||
title="Analyze"
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{isAvailable && (
|
||||
<a
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${a.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-8 px-3 bg-accent text-black text-[10px] font-bold uppercase flex items-center gap-1.5 hover:bg-white transition-colors"
|
||||
>
|
||||
<ShoppingCart className="w-3 h-3" />
|
||||
Buy
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{availability.length === 0 && keyword && !checking && (
|
||||
<div className="text-center py-10 border border-dashed border-white/[0.08] bg-white/[0.01]">
|
||||
<Zap className="w-8 h-8 text-white/10 mx-auto mb-3" />
|
||||
<p className="text-white/40 text-sm font-mono mb-1">Ready to check</p>
|
||||
<p className="text-white/25 text-xs font-mono">
|
||||
Click "Check" to find available domains for <span className="text-accent">{keyword.toLowerCase().replace(/\s+/g, '')}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
{/* TYPO FINDER */}
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
<div className="border border-white/[0.08] bg-[#020202]">
|
||||
<div className="px-4 py-4 border-b border-white/[0.08]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-500/10 border border-purple-500/20 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">Typo Finder</h3>
|
||||
<p className="text-[11px] font-mono text-white/40">
|
||||
Find available misspellings of popular brands
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className={clsx(
|
||||
"flex-1 relative border-2 transition-all",
|
||||
brandFocused ? "border-purple-400/50 bg-purple-400/[0.03]" : "border-white/[0.08] bg-white/[0.02]"
|
||||
)}>
|
||||
<div className="flex items-center">
|
||||
<Sparkles className={clsx("w-4 h-4 ml-4 transition-colors", brandFocused ? "text-purple-400" : "text-white/30")} />
|
||||
<input
|
||||
value={brand}
|
||||
onChange={(e) => setBrand(e.target.value)}
|
||||
onFocus={() => setBrandFocused(true)}
|
||||
onBlur={() => setBrandFocused(false)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && runTypos()}
|
||||
placeholder="Enter a brand name (e.g. Google, Amazon, Shopify)..."
|
||||
className="flex-1 bg-transparent px-3 py-3.5 text-sm text-white placeholder:text-white/25 outline-none font-mono"
|
||||
/>
|
||||
{brand && (
|
||||
<button onClick={() => { setBrand(''); setTypos([]) }} className="p-3 text-white/30 hover:text-white">
|
||||
<X className="w-4 h-4" />
|
||||
{/* Results */}
|
||||
{results.length > 0 && (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
{/* Available */}
|
||||
{availableResults.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<p className="text-[10px] font-mono text-accent uppercase tracking-[0.2em] font-black flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" />
|
||||
{availableResults.length} High-Potential Assets Identified
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<button onClick={() => setResults(availableResults.map(r => ({ domain: r.domain, available: true })))} className="text-[9px] font-mono text-white/20 hover:text-accent uppercase tracking-widest transition-colors">
|
||||
Copy List
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={runTypos}
|
||||
disabled={!brand.trim() || typoLoading}
|
||||
className={clsx(
|
||||
"px-6 py-3 text-sm font-bold uppercase tracking-wider transition-all flex items-center gap-2",
|
||||
!brand.trim() || typoLoading
|
||||
? "bg-white/5 text-white/20 cursor-not-allowed"
|
||||
: "bg-purple-500 text-white hover:bg-purple-400"
|
||||
)}
|
||||
>
|
||||
{typoLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowRight className="w-4 h-4" />}
|
||||
Find
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Typo Results */}
|
||||
{typos.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{typos.map((t) => (
|
||||
<div
|
||||
key={t.domain}
|
||||
className="group border border-white/[0.08] bg-white/[0.02] px-3 py-2.5 flex items-center justify-between hover:border-purple-400/30 hover:bg-purple-400/[0.03] transition-all"
|
||||
>
|
||||
<button
|
||||
onClick={() => openAnalyze(t.domain)}
|
||||
className="text-xs font-mono text-white/70 group-hover:text-purple-400 truncate text-left transition-colors"
|
||||
>
|
||||
{t.domain}
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||
<button
|
||||
onClick={() => copyDomain(t.domain)}
|
||||
className="w-6 h-6 flex items-center justify-center text-white/30 hover:text-white transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
{copied === t.domain ? <Check className="w-3 h-3 text-accent" /> : <Copy className="w-3 h-3" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => track(t.domain)}
|
||||
disabled={tracking === t.domain}
|
||||
className="w-6 h-6 flex items-center justify-center text-white/30 hover:text-white transition-colors"
|
||||
title="Track"
|
||||
>
|
||||
{tracking === t.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
|
||||
</button>
|
||||
<a
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${t.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-6 h-6 flex items-center justify-center text-white/30 hover:text-accent transition-colors"
|
||||
title="Buy"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{availableResults.map(r => (
|
||||
<div key={r.domain} className="flex items-center justify-between p-4 bg-accent/[0.02] border border-accent/20 hover:border-accent/40 hover:bg-accent/[0.04] transition-all group">
|
||||
<button
|
||||
onClick={() => openAnalyze(r.domain)}
|
||||
className="text-sm font-bold font-mono text-white group-hover:text-accent truncate tracking-tight transition-colors"
|
||||
>
|
||||
{r.domain}
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => copy(r.domain)} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-white hover:bg-white/5" title="Copy">
|
||||
{copied === r.domain ? <Check className="w-3.5 h-3.5 text-accent" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button onClick={() => track(r.domain)} disabled={tracking === r.domain} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-white hover:bg-white/5" title="Track">
|
||||
{tracking === r.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button onClick={() => openAnalyze(r.domain)} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-accent hover:border-accent/20 hover:bg-accent/5" title="Analyze">
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<a
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${r.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-8 px-3 bg-accent text-black text-[10px] font-black uppercase tracking-widest flex items-center gap-1.5 hover:bg-white"
|
||||
>
|
||||
Buy
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{typos.length === 0 && !typoLoading && (
|
||||
<div className="text-center py-8 border border-dashed border-white/[0.08] bg-white/[0.01]">
|
||||
<p className="text-white/30 text-xs font-mono">
|
||||
Enter a brand name to discover available typo domains
|
||||
</p>
|
||||
</div>
|
||||
{/* Taken (collapsed) */}
|
||||
{takenResults.length > 0 && (
|
||||
<details className="group">
|
||||
<summary className="text-[10px] font-mono text-white/20 uppercase tracking-[0.2em] cursor-pointer flex items-center gap-2 py-3 hover:text-white/40 transition-colors list-none border-t border-white/[0.04]">
|
||||
<ChevronDown className="w-3 h-3 group-open:rotate-180 transition-transform" />
|
||||
{takenResults.length} Registered Variations
|
||||
</summary>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 mt-3 animate-in slide-in-from-top-2">
|
||||
{takenResults.map(r => (
|
||||
<div key={r.domain} className="flex items-center justify-between px-3 py-2 bg-white/[0.01] border border-white/[0.05] group">
|
||||
<span className="text-[11px] font-mono text-white/20 truncate group-hover:text-white/40 transition-colors">{r.domain}</span>
|
||||
<button onClick={() => openAnalyze(r.domain)} className="text-white/10 hover:text-accent transition-colors">
|
||||
<Shield className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!selected && !loading && trends.length > 0 && (
|
||||
<div className="text-center py-24 border border-dashed border-white/[0.08] bg-white/[0.01]">
|
||||
<Globe className="w-12 h-12 text-white/5 mx-auto mb-4" />
|
||||
<p className="text-white/40 text-sm font-mono uppercase tracking-widest font-bold">Select a trending topic above</p>
|
||||
<p className="text-white/20 text-[10px] font-mono mt-3 uppercase tracking-wider max-w-xs mx-auto leading-relaxed">
|
||||
Our engines will analyze the viral potential and suggest premium assets
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,17 +2,25 @@ import { create } from 'zustand'
|
||||
|
||||
export type AnalyzeSectionVisibility = Record<string, boolean>
|
||||
|
||||
export type DropStatusInfo = {
|
||||
status: 'available' | 'dropping_soon' | 'taken' | 'unknown'
|
||||
deletion_date?: string | null
|
||||
is_drop?: boolean
|
||||
}
|
||||
|
||||
export type AnalyzePanelState = {
|
||||
isOpen: boolean
|
||||
domain: string | null
|
||||
fastMode: boolean
|
||||
filterText: string
|
||||
sectionVisibility: AnalyzeSectionVisibility
|
||||
open: (domain: string) => void
|
||||
dropStatus: DropStatusInfo | null
|
||||
open: (domain: string, dropStatus?: DropStatusInfo) => void
|
||||
close: () => void
|
||||
setFastMode: (fast: boolean) => void
|
||||
setFilterText: (value: string) => void
|
||||
setSectionVisibility: (next: AnalyzeSectionVisibility) => void
|
||||
setDropStatus: (status: DropStatusInfo | null) => void
|
||||
}
|
||||
|
||||
const DEFAULT_VISIBILITY: AnalyzeSectionVisibility = {
|
||||
@ -28,11 +36,13 @@ export const useAnalyzePanelStore = create<AnalyzePanelState>((set) => ({
|
||||
fastMode: false,
|
||||
filterText: '',
|
||||
sectionVisibility: DEFAULT_VISIBILITY,
|
||||
open: (domain) => set({ isOpen: true, domain, filterText: '' }),
|
||||
close: () => set({ isOpen: false }),
|
||||
dropStatus: null,
|
||||
open: (domain, dropStatus) => set({ isOpen: true, domain, filterText: '', dropStatus: dropStatus || null }),
|
||||
close: () => set({ isOpen: false, dropStatus: null }),
|
||||
setFastMode: (fastMode) => set({ fastMode }),
|
||||
setFilterText: (filterText) => set({ filterText }),
|
||||
setSectionVisibility: (sectionVisibility) => set({ sectionVisibility }),
|
||||
setDropStatus: (dropStatus) => set({ dropStatus }),
|
||||
}))
|
||||
|
||||
export const ANALYZE_PREFS_KEY = 'pounce_analyze_prefs_v1'
|
||||
|
||||
@ -246,6 +246,78 @@ class ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
// LLM Naming (AI-powered suggestions for Trends & Forge)
|
||||
async expandTrendKeywords(trend: string, geo: string = 'US') {
|
||||
return this.request<{ keywords: string[]; trend: string }>('/naming/trends/expand', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ trend, geo }),
|
||||
})
|
||||
}
|
||||
|
||||
async analyzeTrend(trend: string, geo: string = 'US') {
|
||||
return this.request<{ analysis: string; trend: string }>('/naming/trends/analyze', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ trend, geo }),
|
||||
})
|
||||
}
|
||||
|
||||
async generateBrandableNames(concept: string, style?: string, count: number = 15) {
|
||||
return this.request<{ names: string[]; concept: string }>('/naming/forge/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ concept, style, count }),
|
||||
})
|
||||
}
|
||||
|
||||
async generateSimilarNames(brand: string, count: number = 12) {
|
||||
return this.request<{ names: string[]; brand: string }>('/naming/forge/similar', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ brand, count }),
|
||||
})
|
||||
}
|
||||
|
||||
// LLM Vision (Trader/Tycoon)
|
||||
async getVision(domain: string, refresh: boolean = false) {
|
||||
const qs = new URLSearchParams({ domain })
|
||||
if (refresh) qs.set('refresh', 'true')
|
||||
return this.request<{
|
||||
domain: string
|
||||
cached: boolean
|
||||
model: string
|
||||
prompt_version: string
|
||||
generated_at: string
|
||||
result: {
|
||||
business_concept: string
|
||||
industry_vertical: string
|
||||
buyer_persona: string
|
||||
cold_email_subject: string
|
||||
cold_email_body: string
|
||||
monetization_idea: string
|
||||
radio_test_score: number
|
||||
reasoning: string
|
||||
}
|
||||
}>(`/llm/vision?${qs.toString()}`)
|
||||
}
|
||||
|
||||
async getYieldLandingPreview(domain: string, refresh: boolean = false) {
|
||||
const qs = new URLSearchParams({ domain })
|
||||
if (refresh) qs.set('refresh', 'true')
|
||||
return this.request<{
|
||||
domain: string
|
||||
cached: boolean
|
||||
model: string
|
||||
prompt_version: string
|
||||
generated_at: string
|
||||
result: {
|
||||
template: string
|
||||
headline: string
|
||||
seo_intro: string
|
||||
cta_label: string
|
||||
niche: string
|
||||
color_scheme: string
|
||||
}
|
||||
}>(`/llm/yield/landing-preview?${qs.toString()}`)
|
||||
}
|
||||
|
||||
// CFO (Alpha Terminal - Management)
|
||||
async getCfoSummary() {
|
||||
return this.request<{
|
||||
@ -414,9 +486,12 @@ class ApiClient {
|
||||
is_available: boolean
|
||||
registrar: string | null
|
||||
expiration_date: string | null
|
||||
deletion_date?: string | null
|
||||
notify_on_available: boolean
|
||||
created_at: string
|
||||
last_checked: string | null
|
||||
status_checked_at?: string | null
|
||||
status_source?: string | null
|
||||
}>
|
||||
total: number
|
||||
page: number
|
||||
@ -454,6 +529,23 @@ class ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
async refreshAllDomains() {
|
||||
return this.request<{
|
||||
message: string
|
||||
checked: number
|
||||
errors: number
|
||||
changes: Array<{
|
||||
domain: string
|
||||
change: 'became_available' | 'became_taken'
|
||||
old_registrar?: string
|
||||
new_registrar?: string
|
||||
}>
|
||||
total_domains: number
|
||||
}>('/domains/refresh-all', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async updateDomainNotify(id: number, notify: boolean) {
|
||||
return this.request<{
|
||||
id: number
|
||||
@ -578,6 +670,43 @@ class ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
// Inbox Counts (for badge)
|
||||
async getInboxCounts() {
|
||||
return this.request<{
|
||||
buyer_unread: number
|
||||
seller_unread: number
|
||||
total_unread: number
|
||||
}>('/listings/inbox/counts')
|
||||
}
|
||||
|
||||
// Seller Inbox (unified view of all inquiries)
|
||||
async getSellerInbox(statusFilter?: 'all' | 'new' | 'read' | 'replied' | 'closed' | 'spam') {
|
||||
const params = statusFilter && statusFilter !== 'all' ? `?status_filter=${statusFilter}` : ''
|
||||
return this.request<{
|
||||
inquiries: Array<{
|
||||
id: number
|
||||
listing_id: number
|
||||
domain: string
|
||||
slug: string
|
||||
buyer_name: string
|
||||
buyer_email: string
|
||||
offer_amount: number | null
|
||||
status: string
|
||||
created_at: string
|
||||
read_at: string | null
|
||||
replied_at: string | null
|
||||
closed_at: string | null
|
||||
closed_reason: string | null
|
||||
has_unread_reply: boolean
|
||||
last_message_preview: string
|
||||
last_message_at: string
|
||||
last_message_is_buyer: boolean
|
||||
}>
|
||||
total: number
|
||||
unread: number
|
||||
}>(`/listings/inbox/seller${params}`)
|
||||
}
|
||||
|
||||
// Subscription
|
||||
async getSubscription() {
|
||||
return this.request<{
|
||||
@ -1752,6 +1881,14 @@ class AdminApiClient extends ApiClient {
|
||||
cname_target: string
|
||||
verification_url: string
|
||||
}
|
||||
landing?: {
|
||||
template: string
|
||||
headline: string
|
||||
seo_intro: string
|
||||
cta_label: string
|
||||
model?: string | null
|
||||
generated_at?: string | null
|
||||
} | null
|
||||
message: string
|
||||
}>('/yield/activate', {
|
||||
method: 'POST',
|
||||
@ -1909,12 +2046,16 @@ class AdminApiClient extends ApiClient {
|
||||
return this.request<{
|
||||
total: number
|
||||
items: Array<{
|
||||
id: number
|
||||
domain: string
|
||||
tld: string
|
||||
dropped_date: string
|
||||
length: number
|
||||
is_numeric: boolean
|
||||
has_hyphen: boolean
|
||||
availability_status: 'available' | 'dropping_soon' | 'taken' | 'unknown'
|
||||
last_status_check: string | null
|
||||
deletion_date: string | null
|
||||
}>
|
||||
}>(`/drops?${query}`)
|
||||
}
|
||||
@ -1929,6 +2070,27 @@ class AdminApiClient extends ApiClient {
|
||||
}>
|
||||
}>('/drops/tlds')
|
||||
}
|
||||
|
||||
async checkDropStatus(dropId: number) {
|
||||
return this.request<{
|
||||
id: number
|
||||
domain: string
|
||||
status: 'available' | 'dropping_soon' | 'taken' | 'unknown'
|
||||
rdap_status: string[]
|
||||
can_register_now: boolean
|
||||
should_track: boolean
|
||||
message: string
|
||||
deletion_date: string | null
|
||||
}>(`/drops/check-status/${dropId}`, { method: 'POST' })
|
||||
}
|
||||
|
||||
async trackDrop(dropId: number) {
|
||||
return this.request<{
|
||||
status: string
|
||||
domain: string
|
||||
message: string
|
||||
}>(`/drops/track/${dropId}`, { method: 'POST' })
|
||||
}
|
||||
}
|
||||
|
||||
// Yield Types
|
||||
@ -1943,6 +2105,12 @@ export interface YieldDomain {
|
||||
dns_verified: boolean
|
||||
dns_verified_at: string | null
|
||||
connected_at: string | null
|
||||
landing_template?: string | null
|
||||
landing_headline?: string | null
|
||||
landing_intro?: string | null
|
||||
landing_cta_label?: string | null
|
||||
landing_model?: string | null
|
||||
landing_generated_at?: string | null
|
||||
total_clicks: number
|
||||
total_conversions: number
|
||||
total_revenue: number
|
||||
|
||||
@ -19,9 +19,12 @@ interface Domain {
|
||||
is_available: boolean
|
||||
registrar: string | null
|
||||
expiration_date: string | null
|
||||
deletion_date?: string | null
|
||||
notify_on_available: boolean
|
||||
created_at: string
|
||||
last_checked: string | null
|
||||
status_checked_at?: string | null
|
||||
status_source?: string | null
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
@ -106,17 +109,46 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
// They can then log in manually via the login page
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
api.logout()
|
||||
logout: async () => {
|
||||
try {
|
||||
// Call backend to clear HttpOnly cookie
|
||||
await api.logout()
|
||||
} catch {
|
||||
// Continue with client-side cleanup even if backend call fails
|
||||
}
|
||||
|
||||
// Clear all client-side state
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
domains: [],
|
||||
subscription: null,
|
||||
isLoading: false,
|
||||
})
|
||||
// Redirect to landing page
|
||||
|
||||
// Clear ALL client-side storage
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/'
|
||||
// Clear localStorage
|
||||
try {
|
||||
localStorage.clear()
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Clear sessionStorage
|
||||
try {
|
||||
sessionStorage.clear()
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Clear any cookies we can access from JS (non-HttpOnly)
|
||||
document.cookie.split(';').forEach(cookie => {
|
||||
const name = cookie.split('=')[0].trim()
|
||||
if (name) {
|
||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`
|
||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.pounce.ch`
|
||||
}
|
||||
})
|
||||
|
||||
// Force redirect to landing page with cache-busting
|
||||
window.location.href = '/?logout=' + Date.now()
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
35
frontend/src/lib/time.ts
Normal file
35
frontend/src/lib/time.ts
Normal file
@ -0,0 +1,35 @@
|
||||
export function parseIsoAsUtc(value: string): Date {
|
||||
// If the string already contains timezone info, keep it.
|
||||
// Otherwise treat it as UTC (backend persists naive UTC timestamps).
|
||||
const hasTimezone = /([zZ]|[+-]\d{2}:\d{2})$/.test(value)
|
||||
return new Date(hasTimezone ? value : `${value}Z`)
|
||||
}
|
||||
|
||||
export function formatCountdown(iso: string | null): string | null {
|
||||
if (!iso) return null
|
||||
|
||||
const target = parseIsoAsUtc(iso)
|
||||
const now = new Date()
|
||||
const diff = target.getTime() - now.getTime()
|
||||
|
||||
if (Number.isNaN(diff)) return null
|
||||
if (diff <= 0) return 'Now'
|
||||
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||||
const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h`
|
||||
if (hours > 0) return `${hours}h ${mins}m`
|
||||
return `${mins}m`
|
||||
}
|
||||
|
||||
export function daysUntil(iso: string | null): number | null {
|
||||
if (!iso) return null
|
||||
const target = parseIsoAsUtc(iso)
|
||||
const now = new Date()
|
||||
const diff = target.getTime() - now.getTime()
|
||||
if (Number.isNaN(diff)) return null
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
51
ops/CI_CD.md
Normal file
51
ops/CI_CD.md
Normal file
@ -0,0 +1,51 @@
|
||||
# CI/CD (Gitea Actions) – Auto Deploy
|
||||
|
||||
## Goal
|
||||
Every push to `main` should:
|
||||
- sync the repo to the production server
|
||||
- build Docker images on the server
|
||||
- restart containers
|
||||
- run health checks
|
||||
|
||||
This repository uses a **remote SSH deployment** from Gitea Actions.
|
||||
|
||||
## Required Gitea Actions Secrets
|
||||
Configure these in Gitea: **Repo → Settings → Actions → Secrets**
|
||||
|
||||
### Deployment (SSH)
|
||||
- `DEPLOY_HOST` – production server IP/hostname
|
||||
- `DEPLOY_USER` – SSH user (e.g. `administrator`)
|
||||
- `DEPLOY_PATH` – absolute path where the repo is synced on the server (e.g. `/home/administrator/pounce`)
|
||||
- `DEPLOY_SSH_KEY` – private key for SSH access
|
||||
- `DEPLOY_SUDO_PASSWORD` – sudo password for `DEPLOY_USER` (used non-interactively)
|
||||
|
||||
### App Secrets (Backend)
|
||||
Used to generate `/data/pounce/env/backend.env` on the server.
|
||||
- `DATABASE_URL`
|
||||
- `SECRET_KEY`
|
||||
- `SMTP_PASSWORD`
|
||||
- `STRIPE_SECRET_KEY`
|
||||
- `STRIPE_WEBHOOK_SECRET`
|
||||
- `GOOGLE_CLIENT_SECRET`
|
||||
- `GH_OAUTH_SECRET`
|
||||
- `CZDS_USERNAME`
|
||||
- `CZDS_PASSWORD`
|
||||
|
||||
## Server Requirements
|
||||
- `sudo` installed
|
||||
- `docker` installed
|
||||
- `DEPLOY_USER` must be able to run docker via `sudo` (pipeline uses `sudo -S docker ...`)
|
||||
|
||||
## Notes
|
||||
- Secrets are written to `/data/pounce/env/backend.env` on the server with restricted permissions.
|
||||
- Frontend build args are supplied in the workflow (`NEXT_PUBLIC_API_URL`, `BACKEND_URL`).
|
||||
|
||||
|
||||
## Trigger
|
||||
This file change triggers CI.
|
||||
|
||||
- runner dns fix validation
|
||||
|
||||
- redeploy after runner fix
|
||||
|
||||
- runner re-register
|
||||
@ -25,7 +25,7 @@ Wir teilen Pounce nun final in **4 operative Module**. Das Konzept "Intent Routi
|
||||
*„Lass das Asset arbeiten.“*
|
||||
* **Die Vision:** Verwandlung von toten Namen in aktive "Intent Router".
|
||||
* **Der Mechanismus:**
|
||||
1. **Connect:** User ändert Nameserver auf `ns.pounce.io`.
|
||||
1. **Connect:** User ändert Nameserver auf `ns.pounce.ch`.
|
||||
2. **Analyze:** Pounce erkennt `zahnarzt-zuerich.ch` → Intent: "Termin buchen".
|
||||
3. **Route:** Pounce schaltet automatisch eine Minimal-Seite ("Zahnarzt finden") und leitet Traffic zu Partnern (Doctolib, Comparis) weiter.
|
||||
* **Der Clou:** Kein "Parking" (Werbung für 0,01€), sondern "Routing" (Leads für 20,00€).
|
||||
|
||||
272
pounce_llm.md
Normal file
272
pounce_llm.md
Normal file
@ -0,0 +1,272 @@
|
||||
Ja. Wenn wir Mistral Nemo nicht nur als "Text-Generator", sondern als **"Business-Simulator"** einsetzen, wird es zum Unicorn-Feature.
|
||||
|
||||
Die meisten Tools (GoDaddy, Sedo) zeigen dir, was die Domain **IST** (ein Name).
|
||||
Das Unicorn-Feature zeigt dir, was die Domain **SEIN KÖNNTE** (ein Business).
|
||||
|
||||
Hier ist das Konzept für das **"Pounce Vision Module"**. Das ist der Grund, warum Leute ihr $9 Abo niemals kündigen werden.
|
||||
|
||||
---
|
||||
|
||||
### Das Feature: "The Asset Vision Engine"
|
||||
|
||||
Wir verwandeln Mistral Nemo in einen **AI-Investment-Banker**.
|
||||
Wenn der User auf eine Domain klickt, generiert Nemo in Echtzeit einen **Mini-Business-Plan**.
|
||||
|
||||
Wir nennen den Tab im Terminal: **🔮 VISION**.
|
||||
|
||||
#### 1. Der "Micro-Acquire" Simulator (Der Flip-Hebel)
|
||||
|
||||
*Zielgruppe: Chris Koerner (Hunter)*
|
||||
|
||||
Stell dir vor, du schaust dir eine leere Domain an, aber Pounce zeigt dir schon das Verkaufs-Inserat, wie es in 2 Jahren auf *Acquire.com* aussehen könnte.
|
||||
|
||||
* **Der Prompt an Nemo:**
|
||||
> "Analyze domain '{domain}'. Act as a VC. Create a hypothetical startup pitch for this name. What could this business be? How does it make money?"
|
||||
|
||||
|
||||
* **Der Output im Terminal:**
|
||||
> **🚀 Potential Venture:** "GreenStream" (für `green-stream.io`)
|
||||
> **Business Model:** SaaS platform for carbon footprint tracking in video streaming.
|
||||
> **Target Buyer:** Netflix, Amazon AWS, Eco-Tech VCs.
|
||||
> **Monetization:** B2B Subscription ($99/mo).
|
||||
|
||||
|
||||
|
||||
**Warum das Unicorn-Level ist:**
|
||||
Es schließt die **"Imagination Gap"**. Viele User sehen `green-stream.io` und denken "Nett". Pounce zeigt ihnen: "Das ist ein SaaS-Business". Plötzlich wirkt der Preis von $50 billig.
|
||||
|
||||
#### 2. Der "Perfect Buyer" Matchmaker (Der Sales-Hebel)
|
||||
|
||||
*Zielgruppe: Margot (Händlerin)*
|
||||
|
||||
Das schwerste am Verkauf ist: **Wem verkaufe ich das?**
|
||||
Nemo analysiert die Semantik der Domain und liefert die **Outreach-Strategie**.
|
||||
|
||||
* **Der Prompt an Nemo:**
|
||||
> "Who is the exact end-user for '{domain}'? Don't say 'Doctors'. Be specific. Write a cold email subject line to sell it to them."
|
||||
|
||||
|
||||
* **Der Output im Terminal:**
|
||||
> **🎯 Ideal Buyer Profile:** High-end Cosmetic Dentists in Miami or LA specializing in veneers.
|
||||
> **Buyer Persona:** Dr. Smith, owns private practice, spends >$5k/mo on Ads.
|
||||
> **Cold Email Hook:** "Subject: Acquiring the 'Veneers' authority in Miami before your competitor does."
|
||||
|
||||
|
||||
|
||||
**Warum das Unicorn-Level ist:**
|
||||
Du gibst dem User nicht nur die Domain, sondern den **Schlüssel zum Verkauf**. Du machst die Arbeit für ihn.
|
||||
|
||||
#### 3. Der "Semantic Search" (Der Discovery-Hebel)
|
||||
|
||||
*Zielgruppe: Blogger (Analyst)*
|
||||
|
||||
Das ist technisch anspruchsvoll, aber mit Nemo (Embeddings) machbar.
|
||||
Aktuelle Suche: User tippt "Shoes". Tool zeigt `best-shoes.com`.
|
||||
**Pounce Vision Suche:** User tippt "Startup selling sustainable sneakers".
|
||||
|
||||
* **Der Prozess:**
|
||||
* Du nutzt Nemo (oder ein kleines Embedding Model), um die *Bedeutung* der Domains zu verstehen.
|
||||
* Nemo erkennt: `soul-sole.com` oder `green-step.io` passen zu "Sustainable Sneakers", obwohl das Wort "Shoe" fehlt.
|
||||
|
||||
|
||||
* **Der Output:**
|
||||
> "Du suchst nach Schuhen? Hier sind brandable Domains, die das *Konzept* verkörpern, nicht nur das Keyword."
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Wie du das technisch umsetzt (Vibe Coding)
|
||||
|
||||
Du hast Mistral Nemo. Das Ding muss **JSON** spucken, keinen Fließtext.
|
||||
|
||||
**Der System Prompt:**
|
||||
|
||||
```text
|
||||
You are the Pounce AI, a domain intelligence engine.
|
||||
Input: A domain name.
|
||||
Task: Analyze semantic meaning, brand potential, and business use cases.
|
||||
Output: Strict JSON format only.
|
||||
{
|
||||
"business_concept": "Short 1-sentence pitch of a potential startup",
|
||||
"industry_vertical": "e.g. Fintech / Agrotech",
|
||||
"buyer_persona": "Specific job title of the person who would buy this",
|
||||
"radio_test_score": 1-10 integer,
|
||||
"reasoning": "Why this domain has value"
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**Integration:**
|
||||
|
||||
1. Du cachest die Antworten! (Lass Nemo nicht jedes Mal laufen, wenn einer klickt. Das kostet Zeit/Geld).
|
||||
2. Wenn User auf Domain klickt -> Check Datenbank.
|
||||
3. Wenn kein Eintrag -> API Call zu Mistral/Groq -> JSON speichern -> Anzeigen.
|
||||
|
||||
### Warum das den Markt gewinnt
|
||||
|
||||
* **GoDaddy** sagt: "Hier ist `pizza-zurich.ch`. Kostet $12."
|
||||
* **Pounce** sagt: "Hier ist `pizza-zurich.ch`. Es ist perfekt für eine Lead-Gen-Seite für Lieferdienste. Dein idealer Käufer ist 'Dominos Franchise Zürich'. Hier ist die E-Mail, die du ihnen schicken sollst."
|
||||
|
||||
**Das ist der Unterschied.**
|
||||
Du verkaufst keine Strings (Zeichenketten). Du verkaufst **Business-Opportunitäten**.
|
||||
Das ist das Unicorn-Feature. 🔮🦄
|
||||
|
||||
Das ist der **"Heilige Gral"** der Automatisierung.
|
||||
Wir verbinden jetzt die **Analyse** (Was ist die Domain?), die **Strategie** (Wie mache ich Geld?) und die **Ausführung** (Bau die Seite) in einen einzigen, unsichtbaren Prozess.
|
||||
|
||||
Das Feature heißt: **"The Instant Authority Engine"**.
|
||||
|
||||
Anstatt eine "geparkte Seite" (Werbung) zu zeigen, baut Mistral Nemo in Sekunden eine **vollwertige Nischen-Landingpage** mit echtem Content.
|
||||
|
||||
Hier ist der technische und konzeptionelle Blueprint, wie du das **direkt** generierst.
|
||||
|
||||
---
|
||||
|
||||
### Der Workflow: Von der Domain zum Cashflow in 3 Sekunden
|
||||
|
||||
Wenn der User im Terminal auf den Button **[⚡ Activate Yield]** klickt, passiert im Hintergrund folgende **Mistral Nemo Chain**:
|
||||
|
||||
#### Schritt 1: The "Identity Prompt" (Verstehen & Strategie)
|
||||
|
||||
*Nemo analysiert die Domain und entscheidet das Business-Modell.*
|
||||
|
||||
* **Input:** `best-garden-shears.com`
|
||||
* **Nemo Task:** Bestimme Nische, Vibe, Farben und Affiliate-Kategorie.
|
||||
* **Prompt:**
|
||||
```json
|
||||
"Analyze 'best-garden-shears.com'.
|
||||
1. Define Niche (e.g. Gardening).
|
||||
2. Define Vibe (e.g. Nature, Green, Trustworthy).
|
||||
3. Suggest Affiliate Product Category (e.g. Garden Tools).
|
||||
4. Write a strong H1 Headline.
|
||||
Output JSON."
|
||||
|
||||
```
|
||||
|
||||
|
||||
* **Output:** `{ "niche": "Gardening", "color_scheme": "green", "product": "tools", "h1": "The Best Shears for a Perfect Cut" }`
|
||||
|
||||
#### Schritt 2: The "Content Spinner" (SEO Text)
|
||||
|
||||
*Nemo schreibt den Inhalt, damit Google die Seite liebt (statt sie als Spam zu blockieren).*
|
||||
|
||||
* **Prompt:**
|
||||
```text
|
||||
"Write a 150-word helpful intro about choosing garden shears. Include keywords: pruning, durability, ergonomics. Use a professional, helpful tone. No fluff."
|
||||
|
||||
```
|
||||
|
||||
|
||||
* **Output:** Ein perfekter kleiner Ratgeber-Text, der erklärt, warum gute Scheren wichtig sind. (Das ist der SEO-Mehrwert!).
|
||||
|
||||
#### Schritt 3: The "Template Matcher" (Design & Bau)
|
||||
|
||||
*Pounce wählt automatisch das Design basierend auf Schritt 1.*
|
||||
|
||||
* Pounce hat 5 "Master Templates" (Next.js Components):
|
||||
1. **Tech/SaaS** (Dunkel, Clean, Blau/Lila) -> für `.io`, AI-Domains.
|
||||
2. **Commerce/Review** (Hell, Produkt-Fokus, Orange/Grün) -> für `best-x.com`.
|
||||
3. **Finance/Trust** (Seriös, Blau/Grau) -> für `crypto`, `bank`.
|
||||
4. **Health/Nature** (Weich, Grün/Beige) -> für `garden`, `bio`.
|
||||
5. **Local Service** (Map-Fokus) -> für `plumber-zurich.ch`.
|
||||
|
||||
|
||||
* **Die Magie:** Da Nemo im JSON `"color_scheme": "green"` ausgegeben hat, lädt Pounce automatisch das **Health/Nature Template**.
|
||||
|
||||
---
|
||||
|
||||
### Das Ergebnis: Die "Pounce Yield Page"
|
||||
|
||||
Der User muss **nichts** tun.
|
||||
Pounce deployt eine Seite unter der Domain, die so aussieht:
|
||||
|
||||
1. **Header:** "The Best Shears for a Perfect Cut" (von Nemo).
|
||||
2. **Body:** Der 150-Wörter SEO-Text (von Nemo).
|
||||
3. **Call-to-Action (Der Geld-Knopf):**
|
||||
* Ein großer Button: **"Check Prices on Amazon"** (oder Comparis/BestBuy).
|
||||
* Dieser Button ist der **Intent Router**. Er enthält den Affiliate-Link des Users (oder deinen Fallback-Link).
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Technische Umsetzung (Vibe Coding Guide)
|
||||
|
||||
Du brauchst keine Datenbank für den Content. Du generierst ihn "On the Fly" und cachest ihn.
|
||||
|
||||
**1. Die Backend-Funktion (Python/Node):**
|
||||
|
||||
```python
|
||||
async def generate_yield_page(domain):
|
||||
# 1. Frag Mistral Nemo nach der Config
|
||||
ai_config = await ask_nemo_json(f"Analyze {domain} for landing page...")
|
||||
|
||||
# ai_config ist jetzt z.B.:
|
||||
# {
|
||||
# "template": "nature",
|
||||
# "headline": "...",
|
||||
# "seo_text": "...",
|
||||
# "affiliate_label": "View Deals on Amazon"
|
||||
# }
|
||||
|
||||
# 2. Speichere das in deiner DB unter 'domains' -> 'yield_config'
|
||||
save_to_db(domain, ai_config)
|
||||
|
||||
return ai_config
|
||||
|
||||
```
|
||||
|
||||
**2. Das Frontend (Next.js Dynamic Page):**
|
||||
|
||||
Du hast eine Datei `pages/_sites/[domain]/index.js` (wenn du Middleware nutzt) oder einfach eine Route.
|
||||
|
||||
```jsx
|
||||
// Pseudo-Code React Component
|
||||
export default function YieldPage({ config }) {
|
||||
// Wähle das Design basierend auf AI Config
|
||||
const Template = Templates[config.template] || Templates.Generic;
|
||||
|
||||
return (
|
||||
<Template>
|
||||
<h1>{config.headline}</h1>
|
||||
<p>{config.seo_text}</p>
|
||||
|
||||
{/* Der Money Button */}
|
||||
<a href={getAffiliateLink(config.niche)} className="btn-primary">
|
||||
{config.affiliate_label}
|
||||
</a>
|
||||
|
||||
<footer className="text-xs">
|
||||
Monetized by Pounce. Own a domain? Get Yield.
|
||||
</footer>
|
||||
</Template>
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Warum das "Unicorn" ist (Der USP)
|
||||
|
||||
**Sedo Parking:**
|
||||
|
||||
* Zeigt wirre Links ("Zahnarzt", "Kredit", "Schuhe").
|
||||
* Sieht aus wie Spam.
|
||||
* User klickt weg.
|
||||
|
||||
**Pounce Smart Yield:**
|
||||
|
||||
* Domain: `best-garden-shears.com`
|
||||
* Seite: Zeigt Gartenscheren, grünes Design, hilfreichen Text.
|
||||
* Sieht aus wie eine **echte Marke**.
|
||||
* User klickt auf "Kaufen".
|
||||
* **Google indexiert die Seite**, weil echter Text drauf ist. Das bringt *noch mehr* Traffic.
|
||||
|
||||
**Das Verkaufsargument:**
|
||||
|
||||
> *"Pounce verwandelt deine Domain in 3 Sekunden in eine **SEO-optimierte Authority Site**.
|
||||
> Kein Coden. Kein Schreiben. Mistral AI baut das Business für dich."*
|
||||
|
||||
Das ist die perfekte Synergie aus deinem **LLM** (Nemo) und dem **Yield-Konzept**. Du automatisierst die Wertschöpfung.
|
||||
89
pounce_user.md
Normal file
89
pounce_user.md
Normal file
@ -0,0 +1,89 @@
|
||||
Das ist der finale Schritt. Wir fügen jetzt die **Business-Intelligenz** von BrandBucket (Margot Bushnaq) hinzu.
|
||||
|
||||
Chris Koerner (Video 1) ist der **Jäger** (Offense).
|
||||
Der Blogger ist der **Analyst** (Defense).
|
||||
Margot (Video 2) ist die **CFO/Händlerin** (Sustainability).
|
||||
|
||||
Wenn wir ihre Insights integrieren, wird Pounce von einem "Tool für Zocker" zu einer **"Plattform für Domain-Unternehmer"**. Das macht es "Stickier" (Kundenbindung) und schützt die User vor dem Bankrott.
|
||||
|
||||
Hier ist das **finale, angereicherte Unicorn-Konzept**. Es vereint **Jagd, Analyse, Cashflow und Finanzplanung**.
|
||||
|
||||
---
|
||||
|
||||
### THE POUNCE ALPHA TERMINAL (Final Master Plan)
|
||||
|
||||
**Der Pitch:** "Pounce combines the hunter's instinct, the analyst's diligence, and the merchant's discipline into one AI Operating System."
|
||||
|
||||
#### 1. DISCOVERY: The Opportunity Engine
|
||||
*Hier finden wir Assets, die andere übersehen.*
|
||||
|
||||
* **Feature A: "The $5 Closeout Sniper" (Koerner)**
|
||||
* *Logik:* Filtert Domains < $10, 5+ Jahre alt, Backlinks. "Free Money".
|
||||
* **Feature B: "The Viral Trend Scanner" (Koerner)**
|
||||
* *Logik:* Google Trends API + Domain Availability. "Buy the Trend".
|
||||
* **Feature C: "The Typo Generator" (Koerner)**
|
||||
* *Logik:* Phonetische Varianten von großen Marken. Traffic-Grabber.
|
||||
* **Feature D: "The Brandable Forge" (BrandBucket - NEU)** 💎
|
||||
* *Insight:* In Rezessionen kaufen Firmen "Invented Names" (Fantasienamen wie Zillow), keine Keywords.
|
||||
* *Pounce Funktion:* Ein Generator für 5-Letter CVCVC-Pattern (Konsonant-Vokal...).
|
||||
* *Check:* Verfügbar? Aussprechbar?
|
||||
* *Benefit:* Findet die "Rolex" von morgen für $10.
|
||||
* **Feature E: "The Agent Hunter" (BrandBucket - NEU)** 💎
|
||||
* *Insight:* Zukünftige Namen sind für AI Agents (kurz, menschlich, "Hey Siri").
|
||||
* *Pounce Funktion:* Filtert nach 1-2 Silben, klingt wie ein Vorname.
|
||||
* *Benefit:* Die perfekte Wette auf die AI-Zukunft.
|
||||
|
||||
#### 2. ANALYSIS: The Deep Diligence Deck
|
||||
*Hier verhindern wir Fehlkäufe.*
|
||||
|
||||
* **Feature F: "The 9-in-1 Dashboard" (Blogger)**
|
||||
* *Logik:* Keyword Volume, CPC, Trademark Check, Wayback Machine. Alles auf einen Blick.
|
||||
* **Feature G: "The Authority Score" (Koerner)**
|
||||
* *Logik:* Unterscheidet "echte" Backlinks (Wikipedia) von Spam.
|
||||
* **Feature H: "The Radio Test AI" (Koerner & BrandBucket)**
|
||||
* *Logik:* Margot und Chris sind sich einig: Aussprechbarkeit ist alles.
|
||||
* *Pounce Funktion:* AI zählt Silben & bewertet "Spelling Confusion".
|
||||
* *Display:* "🗣️ **Radio Test:** 100/100 (Klingt wie es geschrieben wird)."
|
||||
|
||||
#### 3. STRATEGY: The Yield & Pricing Engine
|
||||
*Hier machen wir Geld.*
|
||||
|
||||
* **Feature I: "The Yield Strategist" (Koerner)**
|
||||
* *Logik:* Pounce schlägt vor: "Route zu Amazon Affiliate" oder "Route zu SaaS Partnerprogramm".
|
||||
* **Feature J: "The Fixed Price Oracle" (BrandBucket - NEU)** 💎
|
||||
* *Insight:* "Make Offer" verwirrt Käufer. Festpreise verkaufen sich besser.
|
||||
* *Pounce Funktion:* Pounce analysiert Comps (Vergleichsverkäufe) und gibt dir keinen "Schätzwert", sondern einen **konkreten Listenpreis**.
|
||||
* *Output:* "List this for **$2,499**. Do not use 'Make Offer'."
|
||||
* *Benefit:* Nimmt dem User die Unsicherheit beim Pricing.
|
||||
|
||||
#### 4. EXECUTION: The Portfolio Guard (CFO Mode)
|
||||
*Hier verhindern wir, dass der User pleite geht.*
|
||||
|
||||
* **Feature K: "Trend Alert Watchlist" (Koerner)**
|
||||
* *Logik:* Überwacht Themen ("AI Agents") statt nur Domains.
|
||||
* **Feature L: "The Runway Monitor" (BrandBucket - NEU)** 💎
|
||||
* *Insight:* Anfänger kaufen zu viel und sterben an den Renewal-Gebühren nach 12 Monaten.
|
||||
* *Pounce Funktion:* Ein Finanz-Dashboard.
|
||||
* *Display:* "⚠️ **Burn Rate Alert:** Du hast $400 Renewals fällig im Oktober. Deine aktuellen Einnahmen (Yield) decken nur $50."
|
||||
* *Action:* Pounce markiert automatisch Domains zum "Droppen" (Löschen), die keinen Yield und keinen Traffic haben.
|
||||
* *Benefit:* Pounce rettet dein Business vor dem Cashflow-Tod.
|
||||
|
||||
---
|
||||
|
||||
### Warum dieses Konzept unschlagbar ist
|
||||
|
||||
Wir decken jetzt den **gesamten Lebenszyklus** eines professionellen Investors ab:
|
||||
|
||||
1. **Kauf:** Wir finden Trends (Koerner) & Brandables (BrandBucket).
|
||||
2. **Prüfung:** Wir machen den Radio-Test & Backlink-Check.
|
||||
3. **Haltezeit:** Wir generieren Cashflow durch Yield (Intent Routing) & warnen vor Renewal-Kosten (BrandBucket).
|
||||
4. **Verkauf:** Wir setzen den perfekten Festpreis (BrandBucket Oracle).
|
||||
|
||||
**Das Feedback an die Domainer:**
|
||||
Wenn du das nächste Mal mit Chris, dem Blogger oder Yuyu sprichst, sagst du:
|
||||
|
||||
> "Pounce is not just a scanner. It's a **CFO in your pocket**.
|
||||
> It finds the hidden gems (Koerner), keeps you legally safe (Blogger), and prevents you from going broke on renewals (BrandBucket).
|
||||
> It tells you what to buy, how to price it, and when to drop it."
|
||||
|
||||
Das ist das Unicorn. 🦄
|
||||
168
scripts/deploy.sh
Executable file
168
scripts/deploy.sh
Executable file
@ -0,0 +1,168 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# POUNCE DEPLOYMENT SCRIPT
|
||||
# ========================
|
||||
# Run this locally to deploy to production
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/deploy.sh # Deploy both frontend and backend
|
||||
# ./scripts/deploy.sh backend # Deploy backend only
|
||||
# ./scripts/deploy.sh frontend # Deploy frontend only
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
SERVER="185.142.213.170"
|
||||
SSH_KEY="${SSH_KEY:-$HOME/.ssh/pounce_server}"
|
||||
SSH_USER="administrator"
|
||||
REMOTE_TMP="/tmp/pounce"
|
||||
REMOTE_REPO="/home/administrator/pounce"
|
||||
REMOTE_ENV_DIR="/data/pounce/env"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[DEPLOY]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||
|
||||
# Check SSH key
|
||||
if [ ! -f "$SSH_KEY" ]; then
|
||||
error "SSH key not found: $SSH_KEY"
|
||||
fi
|
||||
|
||||
if [ -z "${DEPLOY_SUDO_PASSWORD:-}" ]; then
|
||||
error "DEPLOY_SUDO_PASSWORD is required (export it locally, do not commit it)."
|
||||
fi
|
||||
|
||||
# What to deploy
|
||||
DEPLOY_BACKEND=true
|
||||
DEPLOY_FRONTEND=true
|
||||
|
||||
if [ "$1" = "backend" ]; then
|
||||
DEPLOY_FRONTEND=false
|
||||
log "Deploying backend only"
|
||||
elif [ "$1" = "frontend" ]; then
|
||||
DEPLOY_BACKEND=false
|
||||
log "Deploying frontend only"
|
||||
else
|
||||
log "Deploying both frontend and backend"
|
||||
fi
|
||||
|
||||
# Sync and build backend
|
||||
if [ "$DEPLOY_BACKEND" = true ]; then
|
||||
log "Syncing backend code..."
|
||||
rsync -avz --delete \
|
||||
-e "ssh -i $SSH_KEY -o StrictHostKeyChecking=no" \
|
||||
--exclude '__pycache__' \
|
||||
--exclude '*.pyc' \
|
||||
--exclude '.git' \
|
||||
--exclude 'venv' \
|
||||
backend/ \
|
||||
${SSH_USER}@${SERVER}:${REMOTE_REPO}/backend/
|
||||
|
||||
log "Building backend image..."
|
||||
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ${SSH_USER}@${SERVER} \
|
||||
"printf '%s\n' \"${DEPLOY_SUDO_PASSWORD}\" | sudo -S docker build -t pounce-backend:latest ${REMOTE_REPO}/backend/" || error "Backend build failed"
|
||||
|
||||
log "Deploying backend container..."
|
||||
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ${SSH_USER}@${SERVER} << BACKEND_DEPLOY
|
||||
printf '%s\n' "${DEPLOY_SUDO_PASSWORD}" | sudo -S bash -c '
|
||||
set -e
|
||||
|
||||
mkdir -p "${REMOTE_ENV_DIR}" /data/pounce/zones
|
||||
chmod -R 755 /data/pounce || true
|
||||
|
||||
# Backend env must exist on server (created by CI or manually)
|
||||
if [ ! -f "${REMOTE_ENV_DIR}/backend.env" ]; then
|
||||
echo "Missing ${REMOTE_ENV_DIR}/backend.env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker stop pounce-backend 2>/dev/null || true
|
||||
docker rm pounce-backend 2>/dev/null || true
|
||||
|
||||
docker run -d \
|
||||
--name pounce-backend \
|
||||
--network coolify \
|
||||
--shm-size=8g \
|
||||
--env-file "${REMOTE_ENV_DIR}/backend.env" \
|
||||
-v /data/pounce/zones:/data \
|
||||
--label "traefik.enable=true" \
|
||||
--label "traefik.http.routers.pounce-backend.rule=Host(\`api.pounce.ch\`)" \
|
||||
--label "traefik.http.routers.pounce-backend.entrypoints=https" \
|
||||
--label "traefik.http.routers.pounce-backend.tls=true" \
|
||||
--label "traefik.http.routers.pounce-backend.tls.certresolver=letsencrypt" \
|
||||
--label "traefik.http.services.pounce-backend.loadbalancer.server.port=8000" \
|
||||
--health-cmd "curl -f http://localhost:8000/health || exit 1" \
|
||||
--health-interval 30s \
|
||||
--restart unless-stopped \
|
||||
pounce-backend:latest
|
||||
|
||||
docker network connect n0488s44osgoow4wgo04ogg0 pounce-backend 2>/dev/null || true
|
||||
echo "✅ Backend deployed"
|
||||
'
|
||||
BACKEND_DEPLOY
|
||||
fi
|
||||
|
||||
# Sync and build frontend
|
||||
if [ "$DEPLOY_FRONTEND" = true ]; then
|
||||
log "Syncing frontend code..."
|
||||
rsync -avz --delete \
|
||||
-e "ssh -i $SSH_KEY -o StrictHostKeyChecking=no" \
|
||||
--exclude 'node_modules' \
|
||||
--exclude '.next' \
|
||||
--exclude '.git' \
|
||||
frontend/ \
|
||||
${SSH_USER}@${SERVER}:${REMOTE_REPO}/frontend/
|
||||
|
||||
log "Building frontend image..."
|
||||
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ${SSH_USER}@${SERVER} \
|
||||
"printf '%s\n' \"${DEPLOY_SUDO_PASSWORD}\" | sudo -S docker build --build-arg NEXT_PUBLIC_API_URL=https://api.pounce.ch --build-arg BACKEND_URL=http://pounce-backend:8000 -t pounce-frontend:latest ${REMOTE_REPO}/frontend/" || error "Frontend build failed"
|
||||
|
||||
log "Deploying frontend container..."
|
||||
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ${SSH_USER}@${SERVER} << FRONTEND_DEPLOY
|
||||
printf '%s\n' "${DEPLOY_SUDO_PASSWORD}" | sudo -S bash -c '
|
||||
set -e
|
||||
docker stop pounce-frontend 2>/dev/null || true
|
||||
docker rm pounce-frontend 2>/dev/null || true
|
||||
|
||||
docker run -d \
|
||||
--name pounce-frontend \
|
||||
--network coolify \
|
||||
--restart unless-stopped \
|
||||
--label "traefik.enable=true" \
|
||||
--label "traefik.http.routers.pounce-web.rule=Host(\`pounce.ch\`) || Host(\`www.pounce.ch\`)" \
|
||||
--label "traefik.http.routers.pounce-web.entryPoints=https" \
|
||||
--label "traefik.http.routers.pounce-web.tls=true" \
|
||||
--label "traefik.http.routers.pounce-web.tls.certresolver=letsencrypt" \
|
||||
--label "traefik.http.services.pounce-web.loadbalancer.server.port=3000" \
|
||||
pounce-frontend:latest
|
||||
|
||||
docker network connect n0488s44osgoow4wgo04ogg0 pounce-frontend 2>/dev/null || true
|
||||
echo "✅ Frontend deployed"
|
||||
'
|
||||
FRONTEND_DEPLOY
|
||||
fi
|
||||
|
||||
# Health check
|
||||
log "Running health check..."
|
||||
sleep 15
|
||||
curl -sf https://api.pounce.ch/api/v1/health && echo "" && log "Backend: ✅ Healthy"
|
||||
curl -sf https://pounce.ch -o /dev/null && log "Frontend: ✅ Healthy"
|
||||
|
||||
# Cleanup
|
||||
log "Cleaning up..."
|
||||
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ${SSH_USER}@${SERVER} \
|
||||
"printf '%s\n' \"${DEPLOY_SUDO_PASSWORD}\" | sudo -S docker image prune -f" > /dev/null 2>&1
|
||||
|
||||
log "=========================================="
|
||||
log "🎉 DEPLOYMENT SUCCESSFUL!"
|
||||
log "=========================================="
|
||||
log "Frontend: https://pounce.ch"
|
||||
log "Backend: https://api.pounce.ch"
|
||||
log "=========================================="
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user