Compare commits

...

141 Commits

Author SHA1 Message Date
7c08e90a56 fix: normalize transition timestamps across terminal
Some checks failed
Deploy Pounce (Auto) / deploy (push) Has been cancelled
Convert timezone-aware datetimes to naive UTC before persisting (prevents Postgres 500s),
add deletion_date migrations, and unify transition countdown + tracked-state across Drops,
Watchlist, and Analyze panel.
2025-12-21 18:14:25 +01:00
719f4c0724 feat: Canonical status metadata across domains and drops 2025-12-21 17:39:47 +01:00
1a63533333 ui: Show status banner in AnalyzePanel for watchlist too 2025-12-21 17:20:51 +01:00
bf579b93e6 fix: Prevent admin user-delete 500 via soft-delete fallback 2025-12-21 17:18:31 +01:00
f1cb360e4f feat: Add LLM Gateway config to deployment pipeline 2025-12-21 17:10:49 +01:00
9d99e6ee0a perf: Batch verify drops status + bulk DB updates 2025-12-21 16:53:30 +01:00
f36d55f814 perf: Bulk insert drops + add critical DB indexes 2025-12-21 16:13:46 +01:00
93bd23c1cd perf: Harden zone sync + scheduler concurrency 2025-12-21 16:07:35 +01:00
54fcfd80cb chore: Trigger deploy after runner re-register 2025-12-21 16:00:51 +01:00
7415d0b696 chore: Trigger deploy after runner fix 2025-12-21 15:59:32 +01:00
9205536bf2 perf: Reuse pooled http client for RDAP 2025-12-21 15:50:59 +01:00
4ec86789cf chore: Validate runner checkout reliability 2025-12-21 15:45:50 +01:00
fd2625a34d perf: Separate scheduler + harden deploy sync 2025-12-21 15:44:35 +01:00
f17206b2f4 fix: Deploy without sudo mv (write env directly) 2025-12-21 15:38:30 +01:00
85c5c6e39d fix: Make deploy workflow valid YAML + python 2025-12-21 15:36:43 +01:00
09fe679f9b fix: Repair deploy workflow YAML (indent heredoc) 2025-12-21 15:35:20 +01:00
6a0e0c159c ci: Auto deploy via server-side pounce-deploy 2025-12-21 15:33:50 +01:00
faa1d61923 chore: Trigger CI run 2025-12-21 15:24:48 +01:00
d170d6f729 ci: Auto-deploy on push via SSH
- Gitea Actions workflow now syncs repo to server, builds images, restarts containers, and runs health checks
- Removed all hardcoded secrets from scripts/deploy.sh
- Added CI/CD documentation and ignored .env.deploy

NOTE: Existing secrets previously committed must be rotated.
2025-12-21 15:23:04 +01:00
13334f6cdd fix: Simplify CI pipeline, use local deploy script 2025-12-21 15:14:42 +01:00
436e3743ed feat: Add local deployment script
- Created scripts/deploy.sh for reliable local deployments
- Simplified CI pipeline to code quality checks only
- Deploy via: ./scripts/deploy.sh [backend|frontend]

The Gitea Actions runner cannot access host Docker in Coolify
environment, so deployments must be triggered locally.
2025-12-21 15:12:22 +01:00
86e0057adc refactor: SSH-based deployment pipeline
Changed from Docker-in-Docker to SSH-based deployment:
- Uses rsync to sync code to server
- Builds Docker images on host directly
- More reliable for Coolify environments
- Proper secret management via SSH
2025-12-21 15:07:58 +01:00
380c0313d9 refactor: Simplify CI/CD pipeline for reliability
- Removed REPO_PATH workaround (use checkout directly)
- Simplified env vars with global definitions
- Fixed network names as env vars
- Updated DATABASE_URL in Gitea secrets
- Cleaner deployment steps
- Better health checks
2025-12-21 15:03:43 +01:00
ddb1a26d47 fix: Implement IANA Bootstrap RDAP for reliable domain checking
Major improvements to domain availability checking:

1. IANA Bootstrap (rdap.org) as universal fallback
   - Works for ALL TLDs without rate limiting
   - Automatically redirects to correct registry
   - Faster than direct endpoints for most TLDs

2. Updated drop_status_checker.py
   - Uses IANA Bootstrap with follow_redirects=True
   - Preferred endpoints for .ch/.li/.de (direct, faster)
   - Better rate limiting (300ms delay, 3 concurrent max)

3. Updated domain_checker.py
   - New _check_rdap_iana() method
   - Removed RDAP_BLOCKED_TLDS (not needed with IANA Bootstrap)
   - Simplified check_domain() priority flow

Priority order:
1. Custom RDAP (.ch/.li/.de) - fastest
2. IANA Bootstrap (all other TLDs) - reliable
3. WHOIS - fallback
4. DNS - final validation

This eliminates RDAP timeouts and bans completely.
2025-12-21 14:54:51 +01:00
5f3856fce6 fix: RDAP ban prevention and DNS fallback
Problem: We are banned from Afilias (.info/.biz) and Google (.dev/.app)
RDAP servers due to too many requests, causing timeouts.

Solution:
1. Added RDAP_BLOCKED_TLDS list in domain_checker.py
2. Skip RDAP for blocked TLDs, use DNS+WHOIS instead
3. Updated drop_status_checker.py to skip blocked TLDs
4. Removed banned endpoints from RDAP_ENDPOINTS

TLDs now using DNS-only: .info, .biz, .org, .dev, .app, .xyz, .online, .com, .net
TLDs still using RDAP: .ch, .li, .de (working fine)

This prevents bans and timeouts while still providing availability checks.
2025-12-21 14:39:40 +01:00
84964ccb84 fix: use correct api.request() method in ZonesTab 2025-12-21 13:30:36 +01:00
f9e6025dc4 feat: Premium infrastructure improvements
1. Parallel Zone Downloads (3x faster)
   - CZDS zones now download in parallel with semaphore
   - Configurable max_concurrent (default: 3)
   - Added timing logs for performance monitoring

2. Email Alerts for Ops
   - New send_ops_alert() in email service
   - Automatic alerts on zone sync failures
   - Critical alerts on complete job crashes
   - Severity levels: info, warning, error, critical

3. Admin Zone Sync Dashboard
   - New "Zone Sync" tab in admin panel
   - Real-time status for all TLDs
   - Manual sync trigger buttons
   - Shows drops today, total drops, last sync time
   - Health status indicators (healthy/stale/never)
   - API endpoint: GET /admin/zone-sync/status
2025-12-21 13:25:08 +01:00
3d25d87415 feat: Premium zone sync improvements
1. Parallel Zone Downloads (CZDS):
   - Downloads up to 3 TLDs concurrently
   - Reduced sync time from 3+ min to ~1 min
   - Semaphore prevents ICANN rate limits

2. Email Alerts:
   - Automatic alerts when sync fails
   - Sends to admin email with error details
   - Includes success/error summary

3. Admin Zone Sync Dashboard:
   - New "Zone Sync" tab in admin panel
   - Shows all TLDs with domain counts
   - Manual "Sync Now" buttons for Switch/CZDS
   - Live stats: drops/24h, total domains

4. Backend Improvements:
   - /admin/zone-stats endpoint
   - Fixed zone-sync endpoints with correct imports
2025-12-21 13:07:03 +01:00
6dca12dc5a fix: Add zone volume permissions to deploy pipeline 2025-12-21 12:47:20 +01:00
622aabf384 fix: Add dig to Docker, fix admin sync endpoints
- Added dnsutils (dig) to backend Dockerfile for DNS zone transfers
- Fixed admin zone sync endpoints with correct imports
- AsyncSessionLocal instead of async_session_maker
2025-12-21 12:41:36 +01:00
bbf6afe2f6 feat: Add admin endpoints for manual zone sync trigger 2025-12-21 12:36:32 +01:00
3bdb005efb feat: Consistent domain status across all pages
Backend:
- Added DROPPING_SOON status to DomainStatus enum
- Added deletion_date field to Domain model
- domain_checker now returns DROPPING_SOON for pending delete
- Track endpoint copies status and deletion_date from drop

Frontend:
- Watchlist shows "TRANSITION" status for dropping_soon domains
- AnalyzePanel shows consistent status from Watchlist
- Status display unified between Drops, Watchlist, and Panel
2025-12-21 12:32:53 +01:00
5df7d5cb96 fix: Consistent domain status across pages + refresh-all timezone fix
Backend:
- Fixed datetime timezone error in refresh-all endpoint
- Added _to_naive_utc() helper for PostgreSQL compatibility

Frontend:
- Watchlist now passes domain status to AnalyzePanel
- Status is consistent between Drops, Watchlist, and Sidepanel
- Shows "Available" or "Taken" status in AnalyzePanel from Watchlist
2025-12-20 23:44:53 +01:00
4995101dd1 fix: Frontend proxy uses pounce-backend in production
- next.config.js now detects NODE_ENV=production
- Uses http://pounce-backend:8000 in Docker instead of localhost
- Logs backend URL during build for debugging
2025-12-20 23:39:43 +01:00
c5a9bd83d5 fix: Track endpoint error handling, improve drops UI with tracked state
Backend:
- Fixed track endpoint duplicate key error with proper rollback
- Returns domain_id for already tracked domains

Frontend DropsTab:
- Added trackedDrops state to show "Tracked" status
- Track button shows checkmark when already in watchlist
- Status button shows "In Transition" with countdown

AnalyzePanel:
- Added dropStatus to store for passing drop info
- Shows Drop Status banner with availability
- "Buy Now" button for available domains in panel
2025-12-20 23:29:31 +01:00
fca54a93e7 fix: Rename GITHUB_CLIENT_SECRET to GH_OAUTH_SECRET (reserved name) 2025-12-20 23:09:58 +01:00
85b1be691a fix: Disable RDAP verification to prevent bans, improve drops UI
- Disabled verify_drops scheduler job (caused RDAP rate limit bans)
- Zone files now saved without RDAP verification (zone diff is reliable)
- Added date-based zone file snapshots with 3-day retention
- Improved DropsTab UI with better status display:
  - "In Transition" with countdown timer for dropping_soon
  - "Available Now" with Buy button
  - "Re-registered" for taken domains
  - Track button for dropping_soon domains
- Added --shm-size=8g to backend container for multiprocessing
- Removed duplicate host cron job (scheduler handles everything)
2025-12-20 22:56:25 +01:00
618eadb433 fix: Auction end_time timezone - add UTC suffix
Some checks failed
Deploy Pounce / build-and-deploy (push) Has been cancelled
The frontend was calculating wrong time remaining because
end_time was sent without timezone suffix (Z).

JavaScript's new Date() interprets "2025-12-20T20:49:20" as
local time, but the server stores it as UTC.

Fix: Add json_encoders to Pydantic models that append "Z"
to all datetime fields, marking them as UTC.

Affected models:
- AuctionListing
- AuctionSearchResponse
- MarketFeedItem
- MarketFeedResponse
- ScrapeStatus
2025-12-20 21:31:30 +01:00
77e3e9dc1f fix: Zone file persistence + .li TSIG key correction
Zone File Storage:
- Persistent storage in /data/pounce/zones/ (not /tmp)
- 3-day retention for historical snapshots
- Volume mounts in CI/CD pipeline
- New zone_retention.py for snapshot management

Bug Fix:
- Fixed wrong TSIG key for .li zone transfer
- Key was corrupted, causing BADSIG errors
- Now using official Switch.ch key

Config Changes:
- Added switch_data_dir setting
- Added zone_retention_days setting (default: 3)
- CZDS path now defaults to /data/czds
2025-12-20 21:21:37 +01:00
a7e1ceaca0 feat: Server performance boost + CI/CD improvements
Some checks failed
Deploy Pounce / build-and-deploy (push) Has been cancelled
- CI/CD: Add Redis URL and job queue env vars to deploy pipeline
- CI/CD: Fix Frontend BACKEND_URL for internal communication
- Multiprocessing: New zone_file_parser.py with parallel chunk processing
- RAM Drive: Extract zone files to /dev/shm for 50x faster I/O
- CZDS Client: Use high-performance parser with all 32 CPU cores

Performance improvements for Ryzen 9 7950X3D server:
- Zone file parsing: Minutes instead of hours
- Uses ProcessPoolExecutor with 75% of cores
- Memory-efficient streaming for 150M+ domain files
2025-12-20 21:07:49 +01:00
b0b1930b7e Security: Move secrets to Gitea Actions secrets
- All sensitive credentials now use ${{ secrets.* }} syntax
- Removed hardcoded API keys, passwords, and tokens
- Repository is now private
2025-12-20 19:55:33 +01:00
9a576f5a90 Trigger CI/CD pipeline build 2025-12-20 19:35:26 +01:00
9302c279df Fix CI/CD pipeline for self-hosted runner
- Single job deployment workflow
- Direct Docker build and deploy on server
- SSL/HTTPS configuration with Let's Encrypt
- Proper Traefik labels for routing
- Health checks and cleanup steps
2025-12-20 19:33:41 +01:00
34d242c614 Add CI/CD pipeline and Docker configuration
- Add Gitea Actions workflow for automatic deployment
- Add production Dockerfile for frontend
- Add docker-compose.prod.yml for easy deployment
- Zero-downtime deployment with health checks
2025-12-20 18:57:31 +01:00
b58b45f412 Deploy: 2025-12-19 13:35
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-19 13:35:06 +01:00
0729c2426a Deploy: 2025-12-19 12:41
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-19 12:41:46 +01:00
c35548c9d4 Deploy: 2025-12-19 12:34
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-19 12:34:31 +01:00
06976674d3 Deploy: 2025-12-19 12:20
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-19 12:20:48 +01:00
2011aae6fa Deploy: 2025-12-19 12:10
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-19 12:10:14 +01:00
93a18820c2 Deploy: 2025-12-19 09:35
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-19 09:35:11 +01:00
8dc6f85fb8 Deploy: 2025-12-19 09:11
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-19 09:11:46 +01:00
108b0ae775 fix(subscription): Cancel subscription in Stripe before local downgrade
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Add StripeService.cancel_subscription() method
- Update /cancel endpoint to call Stripe API
- Set cancelled_at timestamp
- Clean up PostgreSQL reference in server .env
2025-12-19 07:39:24 +01:00
5d81f8d71e fix(yield): Check multiple DNS servers to handle propagation delays
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-18 16:08:36 +01:00
c822517694 feat(yield): Add Verify DNS button for pending domains
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-18 15:57:36 +01:00
aeafb7257e chore: Remove debug logging
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-18 15:49:30 +01:00
186563ffba debug: Add logging to yield dashboard
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-18 15:47:41 +01:00
eb2148080a fix: Eagerly load partner relationship for yield domains
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-18 15:40:58 +01:00
433b0d6ebd fix: Yield dashboard + Forge UI improvements
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Yield:
- Safely handle partner relationship to prevent errors

Forge:
- AI mode now default for Trader/Tycoon users
- AI mode button moved to left (primary position)
- "Describe your..." label now bright purple
- Textarea border brighter for better visibility
2025-12-18 15:37:24 +01:00
81eeceb856 fix: Add missing Clock import
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-18 15:24:23 +01:00
fc40a4784d fix: Improve DNS verification for portfolio domains
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Check TXT records at ROOT domain instead of _pounce subdomain
- Much simpler for users - just add TXT record with verification code
- Update frontend verification modal with clearer instructions
- Add option to verify immediately when adding domain to portfolio
2025-12-18 15:22:17 +01:00
dae4da3f38 feat: Redesign Yield activation wizard with clear 3-step flow
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Step 1: Select domain from verified portfolio
- Step 2: Simple DNS instructions with A-record to 46.235.147.194
- Step 3: Success confirmation

Much cleaner UI with:
- Visual step indicators
- Copy IP button
- Clear explanations
- Preview for non-Tycoon users
2025-12-18 15:13:06 +01:00
800379b581 feat: HTTP-based Yield routing (no DNS server required)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Updated yield_dns.py to support A-record verification (simplest method!)
- A-record pointing to 46.235.147.194 is now the primary verification method
- Added Nginx catch-all config for yield domains
- DNS schema updated with method and actual_a fields
- CoreDNS installed but Port 53 blocked by hosting provider
2025-12-18 14:55:59 +01:00
dad97f951e fix: Scout tier restrictions - no listings, no sniper alerts
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Frontend:
- Scout: Listings and Sniper marked as unavailable
- Updated comparison table

Backend TIER_CONFIG:
- Scout: domain_limit=5, portfolio_limit=5, listing_limit=0, sniper_limit=0
- Trader: domain_limit=50, portfolio_limit=50
2025-12-18 14:47:25 +01:00
86bfcc0e36 fix: Pricing page limits consistency + DropsTab cleanup
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Pricing page:
- Scout: 5 watchlist, 5 portfolio (was 10/3)
- Trader: 50 watchlist, 50 portfolio (was 100/50)
- Yield: Preview for Trader, Active for Tycoon
- Removed Daily Drop Digest (not implemented)

DropsTab:
- Removed zone file analysis hint banner
- Removed TLD flag icons from filter buttons
2025-12-18 14:40:49 +01:00
42e09b46ab feat: Display tier limits with infinity symbol on all pages
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Listing: 1/10/∞ limits, fixed table column alignment
- Watchlist: 5/50/∞ limits with display
- Portfolio: 5/50/∞ limits with display in header
- Sniper: Already had limits (2/10/50)
- Yield: Tycoon-only, no limit needed

Uses formatLimit() helper to show ∞ for Infinity values
2025-12-18 14:31:55 +01:00
8d91caefae style: Yield and Listing tables match Auctions styling
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Both tables now have:
- Same border wrapper (border-white/[0.08] bg-[#020202])
- Same header (px-5 py-3, tracking-[0.12em], bg-white/[0.02])
- Same rows (group-hover:bg-white/[0.02])
- Same divide-y divider
- Same action button styling (w-8 h-8 with borders)
- Same hover opacity transitions
- Same empty state styling (larger icons, centered text)
2025-12-18 14:23:24 +01:00
202e615e2b fix: TLD Matrix shows own domain correctly as taken + Portfolio table responsive
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
1. TLD Matrix fix:
   - Pass original_is_taken flag to run_tld_matrix()
   - If main domain check shows taken, force same TLD to show as taken
   - Fixes incorrect "available" display for owned domains

2. Portfolio table:
   - Use minmax() for flexible column widths
   - Smaller gaps and padding for compact layout
   - Smaller action buttons (w-8 h-8)
   - Shorter column labels (Bought, Exp.)
2025-12-18 14:14:37 +01:00
c41f870040 style: Portfolio table styling like Watchlist/Auctions
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Same border wrapper with bg-[#020202]
- Same grid gap (gap-6) and padding (px-6 py-4)
- Same header styling with accent highlight on active sort
- Same action button styling (w-10 h-10 with borders)
- Same hover opacity transitions
- Same empty state styling
- Table body with divide-y divider
2025-12-18 14:04:20 +01:00
c85f5773fa fix: Currency display in AnalyzePanel + Watchlist styling like Auctions
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
1. AnalyzePanel currency fix:
   - formatValue() now formats USD values with $ symbol
   - Affects: cheapest_registration, cheapest_renewal, cheapest_transfer, etc.

2. Watchlist styling (match Auctions):
   - Same grid layout (1fr_100px_100px_100px_80px_180px)
   - Same padding (px-6 py-4)
   - Same table structure with border wrapper
   - Same action button sizes (w-10 h-10)
   - Same hover opacity transitions
   - Same empty state styling
   - Same mobile layout (h-12 buttons)
2025-12-18 13:52:39 +01:00
9bdb673220 fix: Improve AnalyzePanel contrast and TLD matrix size
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Increase text contrast throughout (white/50 → white, white/30 → white/50)
- Make TLD matrix full-width with larger tiles (h-10, text-sm)
- Larger score display (16x16 → w-16 h-16, text-2xl)
- Bigger header buttons with borders
- Larger labels (text-sm instead of text-[11px])
- Better visual hierarchy
2025-12-18 13:40:03 +01:00
1d0bdb92ca refactor: Simplify AnalyzePanel to match Hunt page style
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Remove colorful section backgrounds and borders
- Use monochrome, minimalist styling throughout
- Remove "Show raw data" toggle from items
- Compact header and score display
- Simplified footer with smaller text
- Consistent with Hunt pages design language
2025-12-18 13:33:20 +01:00
5d382e88a9 fix: Zone file drops now verify availability before storing
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
CRITICAL FIX:
- Tested 22,799 "dropped" domains - 0 (ZERO!) were actually available
- All were immediately re-registered by drop-catching services
- Zone file analysis is useless without availability verification

Changes:
- process_drops() now verifies each domain is actually available
- Only stores domains that pass availability check
- Filters to valuable domains first (short, no numbers, no hyphens)
- Limits to 500 candidates per sync to avoid rate limiting
- Adds progress logging during verification

This ensures the Drops tab only shows domains users can actually register.
2025-12-18 12:42:12 +01:00
29d0760856 fix: Drops cleanup every 10 minutes + clean rebuild
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Changed drops verification from 4 hours to every 10 minutes
- Will remove taken domains much faster from drops list
- Includes clean rebuild to fix UI display issue
2025-12-18 11:32:36 +01:00
52ee772391 feat: Zero-downtime deployment + drops auto-cleanup
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
1. Deploy Pipeline v3.0:
   - Zero-downtime frontend deployment (build while server runs)
   - Atomic switchover only after successful build
   - Server stays up during entire npm install + npm run build

2. Navigation:
   - Removed "Intel" from public navigation (use Discover instead)

3. Drops Auto-Cleanup:
   - New scheduler job every 4 hours to verify drops availability
   - Automatically removes domains that have been re-registered
   - Keeps drops list clean with only actually available domains
2025-12-18 11:20:18 +01:00
f807f2d2bc fix: Remove unsupported TLDs from DropsTab
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-18 11:10:45 +01:00
6001676058 feat: Complete redesign of Hunt tabs with award-winning UI
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- DropsTab: Fixed domain display, added alert banner explaining zone file data
- AuctionsTab: Improved table layout, cleaner action buttons
- Both: Consistent header stats, unified search, better filters
2025-12-18 11:07:57 +01:00
a70439c51a feat: Robust deploy pipeline v2.0
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Multiple server addresses with automatic fallback
- SSH retry logic with exponential backoff
- Health checks before and after deployment
- HTTP-based deployment endpoint as backup
- Better error handling and logging
- Support for partial deployments (backend/frontend only)
2025-12-18 10:43:50 +01:00
871ee3f80e fix: Improve watchlist monitoring to detect both status transitions
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Detect when domains become TAKEN (not just when they become available)
- Send email notifications for both transitions
- Add "Refresh All" button to watchlist page
- Auto-refresh available domains for Tycoon users
- Add bulk refresh endpoint for watchlist
- Add warning toast type for status change alerts
- Better logging for domain status changes
2025-12-18 10:28:47 +01:00
460074d01f Hunt Module UI Optimization: unified award-winning design
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Unified table headers, rows, and action alignments across all tabs
- Enhanced SearchTab: improved result display and global TLD grid
- Refined DropsTab: standardized table layouts and mobile views
- Optimized AuctionsTab: improved column alignment and source badges
- Modernized TrendSurfer: better viral topics grid and keyword builder
- Polished BrandableForge: refined mode selectors and synthesis config
- Standardized all borders, backgrounds, and spacing for Terminal v1.0
- All UI text in English with enhanced tracking and monospaced typography
2025-12-18 09:30:50 +01:00
4c08c92780 Hunt: make Search default tab, reorder tabs
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Search is now first/default tab
- Tab order: Search → Drops → Auctions → Trends → Forge
- AnalyzePanel: improved tooltips and explanations
- Better Fast/Cached mode descriptions
- Enhanced Health Score display with explanations
2025-12-18 07:43:29 +01:00
87310f4fa2 Zone sync: add .biz and .club TLDs, fix get_db_session()
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-18 06:51:10 +01:00
2dbd03db6d Fix: get_db_session() in zone sync script - use get_settings() and handle aiosqlite URL
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-18 06:45:29 +01:00
b31a7f6442 Fix: wrap Info icon in span for title attribute
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 17:23:29 +01:00
f4c355b2cf Redesign AnalyzePanel + VisionSection to match Hunt page style
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 17:19:28 +01:00
7c2d7d0a0e Deploy: fix ssh tty auth + yield landing table
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 16:48:27 +01:00
f711ac23b9 Yield: show landing details in table + Trader preview, Tycoon activation
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 16:47:34 +01:00
f9e1da9ba0 Deploy: 2025-12-17 16:37
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 16:37:27 +01:00
c140d16198 Deploy: 2025-12-17 16:36
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 16:36:56 +01:00
8c499ddccd Deploy: 2025-12-17 16:34
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 16:34:27 +01:00
5a1fcb30dd Forge: prominent AI mode selector, improved contrast throughout Hunt tabs
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 16:28:45 +01:00
c23d3c4b6c Fix AnalyzePanel syntax error, restore working version + Trends & Forge redesign
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 16:15:05 +01:00
129716ad1d Trends & Forge v2: clearer AI integration, unified layout, auto-expand keywords
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 16:05:40 +01:00
0618d8517d Trends & Forge tabs: complete redesign - cleaner UI, mobile-first, progressive disclosure
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 15:56:37 +01:00
e135c3258b Remove chat companion, add LLM naming for Trends & Forge tabs (AI keyword expansion, concept generator)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 15:45:16 +01:00
e75c9bc9ef Hunter Companion v4: Code-First Architecture - no LLM for routing, pure pattern matching
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 15:22:34 +01:00
31a8d62b38 Hunter Companion v3: completely rebuilt - canned responses, auto domain detection, controlled output
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 15:02:54 +01:00
442c1db580 Hunter Companion: fix hallucination (strict no-invent rules), better text formatting with spacing
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 14:56:39 +01:00
b35d5e0ba0 Hunter Companion: pure trading assistant (Scout=teaser, Trader/Tycoon=full), clean formatting, suggestion chips
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 14:49:49 +01:00
aab2a0c3ad Hunter Companion: natural greetings + no invented tasks; lower temperature
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 14:41:17 +01:00
fed5b15378 LLM Agent: prevent tool-output leakage; nicer responses (no Tool Result / no JSON dumps)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 14:38:49 +01:00
01d6d24e59 LLM Agent tools: cover all terminal pages (hunt/portfolio/sniper/listing/inbox/yield/etc.)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 14:35:53 +01:00
8f6e13ffcf LLM Agent: tool-calling endpoint + HunterCompanion uses /llm/agent
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 14:30:25 +01:00
5ffe7092ff Hunter Companion UI-help: cover terminal root/welcome + robust fallback for every screen
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 14:21:45 +01:00
b8afdc812f Hunter Companion: English-only expert + keep input focus + Scout UI-help mode
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 14:00:21 +01:00
fcd36a0a29 Fix Hunter Companion prompt: natural German answers + domain decision mode
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 13:49:03 +01:00
eaa8ad1511 fix: improve logout to clear all cookies and storage, fix subscription tier case
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 13:44:49 +01:00
c832939d5b fix: change placeholder from pounce.io to pounce.ch
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 13:34:26 +01:00
7822cd094f Add Hunter Companion chat widget (Trader/Tycoon live, Scout upsell)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 13:26:27 +01:00
bd3046b782 Add LLM gateway proxy endpoint (Trader/Tycoon)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 13:12:45 +01:00
19cd61f3d3 Remove icons from Auctions table
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 13:11:49 +01:00
a9da2fc265 Remove icons from table rows in Drops, Watchlist, Portfolio, Listing, Yield pages
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 13:04:30 +01:00
aad1a54dfd Deploy: detect systemd via /etc/systemd/system and restart services (no nohup)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 12:59:45 +01:00
815f08dac0 Use systemd restart in deploy.sh + avoid log permission issues
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 12:57:36 +01:00
dd8ce18e93 Fix deploy.sh COMMIT_MSG unbound for frontend-only
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 12:53:09 +01:00
fc708016e2 Deploy frontend with TLD fix
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 12:12:03 +01:00
05e9f59ccf Fix: Show domains with TLD in drops table + restore critical backend fixes
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 12:10:56 +01:00
9f48c401e9 CRITICAL FIX: Robust DB + drops attribute + no duplicate logging (complete rewrite)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 12:05:51 +01:00
e8d23e8a49 CRITICAL FIX: Robust DB connection + drops attribute + no duplicate logging
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 12:03:40 +01:00
52770986cd Fix: Initialize drops list in ZoneSyncResult, fix duplicate logging
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 12:02:07 +01:00
d7eb86b0c0 Fix: Robust DB connection for zone sync - reads DATABASE_URL directly from .env
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 12:00:23 +01:00
b30b8e1ec0 Fix: Use direct DB path for zone sync script (avoids import issues)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 11:54:13 +01:00
22eeb85765 Fix: Use get_settings() instead of settings (correct import)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 11:52:44 +01:00
d96668424f Fix: Read DATABASE_URL directly from .env (avoid import issues)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 10:07:53 +01:00
7885884e45 Fix TypeScript type casting for details object
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 10:00:27 +01:00
2553c7d4c4 Complete AnalyzePanel redesign: Hunt-style, Pounce Score, Buy/Skip, Yield Intent, Trademark Warning
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 09:58:50 +01:00
90ec2648fc Fix: Store drops immediately after each TLD (crash-safe)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Drops are now stored in DB right after each TLD sync completes
- If process crashes, already synced TLDs' drops are preserved
- Temporarily exclude .org (too large, causes OOM)
- Show stored count in summary
2025-12-17 09:39:21 +01:00
6e9c5a1394 Add systemd auto-start services + realistic feature updates
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 09:36:59 +01:00
7f3846934c Add Yield Coming Soon banner, realistic feature review
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 09:30:03 +01:00
4d90b75717 Realistic features: 10min alerts, Daily Drop Digest instead of exclusive drops
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 09:25:18 +01:00
891d17362e Major tier overhaul: Scout gets Portfolio+Listing, new limits, Yield live
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 09:16:34 +01:00
1ceb6bf5a8 Backend: Yield limit Trader 5->10
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 09:07:17 +01:00
ac9ad41d86 Inbox: Hunt-style tabs + Yield: Coming soon banner
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 08:59:08 +01:00
ab27cb1295 Inbox: Unread badges, Seller Inbox, Polling updates
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 08:46:45 +01:00
7594a723c6 Fix: Retry when downloaded CZDS file not found
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Previously, if download succeeded but file wasn't found, function
returned None immediately. Now raises FileNotFoundError to trigger
the retry logic properly.
2025-12-17 08:19:48 +01:00
006407ca1d Add retry logic (3 attempts with backoff) to CZDS zone downloads
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Large zone files (100-200MB) were failing due to connection interruptions.
Now retries up to 3 times with 30s/60s/90s backoff between attempts.
2025-12-17 08:02:59 +01:00
9656d8d028 Public Nav: Yield Link wieder hinzugefügt
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 07:30:13 +01:00
5f5509b7f8 Revert: pricing/nav/footer copy (landing page text only)
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 07:03:48 +01:00
51b7727ed4 Landing Page Copy: weniger Yield, mehr Intelligence/Market/Terminal
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 07:00:15 +01:00
e95fcd5bae Fix landing build: replace missing Key icon
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 06:54:24 +01:00
2b0c3aacf8 Public messaging: Landing & Pricing weniger Yield, mehr Terminal/Intelligence/Market/Watchlist
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-17 06:51:24 +01:00
74b8a12742 Fix tier-based monitoring: Scout users no longer get Tycoon-level checks
Previously, the realtime (10-min) check job would check ALL tiers including
Scout users, giving them free premium service. Now:
- daily: checks ALL tiers (baseline service)
- hourly: checks Trader + Tycoon only
- realtime: checks Tycoon only (premium feature)
2025-12-16 21:24:52 +01:00
103 changed files with 12066 additions and 3661 deletions

159
.gitea/workflows/deploy.yml Normal file
View 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
View File

@ -26,6 +26,7 @@ dist/
.env
.env.local
.env.*.local
.env.deploy
*.log
# Deployment env files (MUST NOT be committed)

View File

@ -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
View 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 910px) → wirkt “pro”, aber ist für längere Sessions anstrengend.
---
## Quick Wins (12 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: “Whats 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 13).
- 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 15 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.

View File

@ -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 |

View File

@ -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;

View File

@ -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

View File

@ -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")

View File

@ -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"])

View File

@ -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()),
}
}

View File

@ -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 ==============

View File

@ -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
View 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"}

View File

@ -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

View File

@ -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))

View File

@ -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
View 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))

View 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)

View 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,
)

View File

@ -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}",
)

View File

@ -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(

View File

@ -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),
}

View File

@ -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),

View File

@ -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)

View File

@ -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"

View File

@ -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
# ---------------------------------------------------

View File

@ -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")

View File

@ -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",
]

View File

@ -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."""

View 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"),
)

View File

@ -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
}
},
}

View File

@ -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(

View File

@ -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)

View File

@ -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'),
)

View File

@ -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():

View File

@ -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

View File

@ -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

View File

@ -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",
)

View File

@ -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",

View File

@ -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

View File

@ -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"
)

View 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

View File

@ -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()

View 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)

View File

@ -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()

View 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

View 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

View 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 []

View 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

View File

@ -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,

View File

@ -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,
)

View 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>
"""

View File

@ -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
}

View 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()

View 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

View File

@ -0,0 +1,2 @@
"""Shared utility helpers (small, dependency-free)."""

View 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")

View File

@ -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.")

View 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 "=========================================="

View 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 "=========================================="

View File

@ -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
View 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
View File

@ -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
View 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

View File

@ -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"]

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -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>

View File

@ -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"

View File

@ -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"

View File

@ -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">

View File

@ -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?',

View File

@ -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"

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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) => (

View File

@ -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>
)

View File

@ -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"
)}
>

View 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>
)
}

View File

@ -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>
)

View 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">
110<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>
)
}

View File

@ -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>
)}
</>

View File

@ -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>
)}

View File

@ -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>
)}

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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'

View File

@ -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

View File

@ -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
View 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
View 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

View File

@ -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
View 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
View 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
View 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