Deploy: referral rewards antifraud + legal contact 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

This commit is contained in:
2025-12-15 13:56:43 +01:00
parent ca8929a916
commit bb7ce97330
107 changed files with 7630 additions and 2514 deletions

View File

@ -4,6 +4,67 @@ Ziel: Pounce von einem starken Produkt (Trust + Inventory + Lead Capture) zu ein
---
## Umsetzungsstatus (Stand: 2025-12-15)
### Wo wir stehen (kurz, ehrlich)
- **Deal-System (Liquidity Loop)**: **fertig & gehärtet** (Inbox → Threading → Sold/GMV → AntiAbuse).
- **Yield (Moat)**: **Connect + Routing + Tracking + Webhooks + Ledger-Basis** ist da. Wir können Domains verbinden, Traffic routen, Clicks/Conversions tracken und Payouts vorbereiten/abschliessen.
- **Flywheel/Distribution**: teilweise (Public Deal Surface + Login Gate ist da), Programmatic SEO & Viral Loop noch nicht systematisch ausgebaut.
- **Telemetry/Ops**: einzelne Events existieren implizit (Audit/Transactions), aber **kein zentrales Event-Schema + KPIs Dashboard**.
### Fortschritt nach Workstream
#### 1) DealSystem
- [x] 1A Inbox Workflow (Status, Close Reason, Audit)
- [x] 1B Threading/Negotiation (Buyer/Seller Threads + Email + Rate Limits + Content Safety)
- [x] 1C Deal Closure + GMV (Mark as Sold, Close open inquiries)
- [x] 1D AntiAbuse (Limits + Safety Checks an den kritischen Stellen)
#### 2) Yield (Moat)
- [x] 2A Connect/Nameserver Flow (PortfolioOnly + DNS Verified + Connect Wizard + `connected_at`)
- [x] 2B Routing → Tracking (Async, Click Tracking, IPHashing, RateLimit, strict partner config)
- [x] 2B Attribution (Webhook kann `click_id` mitschicken)
- [x] 2C Ledger/PayoutBasics (generate payouts + complete payouts; serversafe keys)
- [x] 2C.2 DashboardKorrektheit (monatliche Stats = confirmed/paid, pending payout = confirmed+unpaid)
#### 3) Flywheel / Distribution
- [~] 3B Public Deal Surface + Login Gate (Pounce Direct gated) — **vorhanden**
- [~] 3A Programmatic SEO maximal (Templates + CTA Pfade + Indexation)
- [~] 3C Viral Loop „Powered by Pounce“ (nur wo intent passt, sauberer Referral Loop)
**3C Stand (Viral Loop)**
- **Invite Codes**: jeder User hat jetzt einen eigenen `invite_code` (unique) + `GET /api/v1/auth/referral` liefert den InviteLink.
- **Attribution**: `ref` wird auf Public Pages in Cookie gespeichert (30 Tage) und bei `/register` mitgeschickt → Backend setzt `referred_by_user_id`.
- **Surfaces (intent-fit)**:
- Terminal Settings: “Invite” Panel mit CopyLink
- Public Buy Listing: “Powered by Pounce” → Register mit `?ref=<seller_invite_code>`
- **Telemetry**: Events `user_registered`, `referral_attributed`, `referral_link_viewed`
- **Admin KPIs (3C.2)**: Telemetry Tab zeigt jetzt ReferralKPIs (Link Views + Signups pro Referrer) via `GET /api/v1/telemetry/referrals?days=...`
- **Rewards/Badges (3C.2)**: Deterministische ReferralRewards (abuseresistent) → `subscriptions.referral_bonus_domains` (+5 Slots pro 3 “qualified referrals”), Badge `verified_referrer` / `elite_referrer` wird im TerminalSettings InvitePanel angezeigt.
- **AntiFraud/Cooldown**: Qualified zählt erst nach **Cooldown** (User+Subscription Age) und wird bei **shared IP / duplicate IP / missing IP** disqualifiziert (Telemetry `ip_hash`).
**3A Stand (Programmatic SEO)**
- **Indexation**: `sitemap.xml` ist jetzt dynamisch (DiscoverTLDs aus DB + Blog Slugs + Public Listings) und `robots.txt` blockt Legacy Pfade.
- **Canonical Cleanup**: Legacy Routen (`/tld/*`, `/tld-pricing/*`) redirecten server-seitig nach `/discover/*`.
- **Templates**: `/discover/[tld]` hat jetzt serverseitiges Metadata + JSONLD (aus echten RegistrarCompare Daten). `/buy/[slug]` ist serverseitig (Metadata + JSONLD).
- **Blog Article SEO**: `/blog/[slug]` hat jetzt serverseitiges `generateMetadata` + Article JSONLD, ohne ViewCount SideEffects (MetaEndpoint).
#### 4) Skalierung / Telemetry
- [x] 4A Events (kanonisches Event-Schema + persistente Events in Deal+Yield Funnel)
- [x] 4A.2 KPI Views (Admin KPIs aus Telemetry Events: Rates + Median Times)
- [x] 4B Ops (Backups + Restore-Verification + Monitoring/Alerts + Deliverability)
**4B Stand (Ops)**
- **Backups**: Admin-Endpoint + Scheduler Daily Backup + Restore-Verification (SQLite integrity_check / Postgres pg_restore --list)
- **Monitoring**: `/metrics` exportiert jetzt zusätzlich Business-KPIs (Deal+Yield aus `telemetry_events`, gecached) + Ops-Metriken (Backup enabled + Backup age)
- **Deliverability**: Newsletter Emails mit `List-Unsubscribe` (One-Click) + neue One-Click Unsubscribe Route
- **Alerting (Vorbereitung)**: `ops/prometheus-alerts.yml` mit Alerts (5xx rate, Backup stale, 24h Funnel-Null)
- **Alerting (ohne Docker)**: Scheduler Job `ops_alerting` + Admin Endpoint `POST /api/v1/admin/system/ops-alerts/run`
- **Alert History + Cooldown (persistiert)**: Table `ops_alert_events` + Admin Endpoint `GET /api/v1/admin/system/ops-alerts/history` + Admin UI History Panel
---
## Absicht & holistisches Konzept
### Absicht (warum es Pounce gibt)

View File

@ -0,0 +1,74 @@
"""Add inquiry close fields + audit trail
Revision ID: 007
Revises: 006
Create Date: 2025-12-15
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '007'
down_revision = '006'
branch_labels = None
depends_on = None
def upgrade() -> None:
# listing_inquiries: deal workflow
op.add_column('listing_inquiries', sa.Column('closed_reason', sa.String(200), nullable=True))
op.add_column('listing_inquiries', sa.Column('closed_at', sa.DateTime(), nullable=True))
op.create_index(
'ix_listing_inquiries_listing_created',
'listing_inquiries',
['listing_id', 'created_at'],
unique=False,
)
op.create_index(
'ix_listing_inquiries_listing_status',
'listing_inquiries',
['listing_id', 'status'],
unique=False,
)
# listing_inquiry_events: audit trail
op.create_table(
'listing_inquiry_events',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('inquiry_id', sa.Integer(), sa.ForeignKey('listing_inquiries.id'), nullable=False, index=True),
sa.Column('listing_id', sa.Integer(), sa.ForeignKey('domain_listings.id'), nullable=False, index=True),
sa.Column('actor_user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False, index=True),
sa.Column('old_status', sa.String(20), nullable=True),
sa.Column('new_status', sa.String(20), nullable=False),
sa.Column('reason', sa.String(200), nullable=True),
sa.Column('ip_address', sa.String(45), nullable=True),
sa.Column('user_agent', sa.String(500), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True, index=True),
)
op.create_index(
'ix_listing_inquiry_events_inquiry_created',
'listing_inquiry_events',
['inquiry_id', 'created_at'],
unique=False,
)
op.create_index(
'ix_listing_inquiry_events_listing_created',
'listing_inquiry_events',
['listing_id', 'created_at'],
unique=False,
)
def downgrade() -> None:
op.drop_index('ix_listing_inquiry_events_listing_created', table_name='listing_inquiry_events')
op.drop_index('ix_listing_inquiry_events_inquiry_created', table_name='listing_inquiry_events')
op.drop_table('listing_inquiry_events')
op.drop_index('ix_listing_inquiries_listing_status', table_name='listing_inquiries')
op.drop_index('ix_listing_inquiries_listing_created', table_name='listing_inquiries')
op.drop_column('listing_inquiries', 'closed_at')
op.drop_column('listing_inquiries', 'closed_reason')

View File

@ -0,0 +1,61 @@
"""Add inquiry threading (buyer link + messages)
Revision ID: 008
Revises: 007
Create Date: 2025-12-15
"""
from alembic import op
import sqlalchemy as sa
revision = '008'
down_revision = '007'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Link inquiry to buyer account
op.add_column('listing_inquiries', sa.Column('buyer_user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
op.create_index('ix_listing_inquiries_buyer_user', 'listing_inquiries', ['buyer_user_id'], unique=False)
# Thread messages
op.create_table(
'listing_inquiry_messages',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('inquiry_id', sa.Integer(), sa.ForeignKey('listing_inquiries.id'), nullable=False, index=True),
sa.Column('listing_id', sa.Integer(), sa.ForeignKey('domain_listings.id'), nullable=False, index=True),
sa.Column('sender_user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False, index=True),
sa.Column('body', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True, index=True),
)
op.create_index(
'ix_listing_inquiry_messages_inquiry_created',
'listing_inquiry_messages',
['inquiry_id', 'created_at'],
unique=False,
)
op.create_index(
'ix_listing_inquiry_messages_listing_created',
'listing_inquiry_messages',
['listing_id', 'created_at'],
unique=False,
)
op.create_index(
'ix_listing_inquiry_messages_sender_created',
'listing_inquiry_messages',
['sender_user_id', 'created_at'],
unique=False,
)
def downgrade() -> None:
op.drop_index('ix_listing_inquiry_messages_sender_created', table_name='listing_inquiry_messages')
op.drop_index('ix_listing_inquiry_messages_listing_created', table_name='listing_inquiry_messages')
op.drop_index('ix_listing_inquiry_messages_inquiry_created', table_name='listing_inquiry_messages')
op.drop_table('listing_inquiry_messages')
op.drop_index('ix_listing_inquiries_buyer_user', table_name='listing_inquiries')
op.drop_column('listing_inquiries', 'buyer_user_id')

View File

@ -0,0 +1,31 @@
"""Add listing sold fields (GMV tracking)
Revision ID: 009
Revises: 008
Create Date: 2025-12-15
"""
from alembic import op
import sqlalchemy as sa
revision = '009'
down_revision = '008'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('domain_listings', sa.Column('sold_at', sa.DateTime(), nullable=True))
op.add_column('domain_listings', sa.Column('sold_reason', sa.String(200), nullable=True))
op.add_column('domain_listings', sa.Column('sold_price', sa.Float(), nullable=True))
op.add_column('domain_listings', sa.Column('sold_currency', sa.String(3), nullable=True))
op.create_index('ix_domain_listings_status', 'domain_listings', ['status'], unique=False)
def downgrade() -> None:
op.drop_index('ix_domain_listings_status', table_name='domain_listings')
op.drop_column('domain_listings', 'sold_currency')
op.drop_column('domain_listings', 'sold_price')
op.drop_column('domain_listings', 'sold_reason')
op.drop_column('domain_listings', 'sold_at')

View File

@ -0,0 +1,25 @@
"""Add yield connected_at timestamp.
Revision ID: 010_add_yield_connected_at
Revises: 009_add_listing_sold_fields
Create Date: 2025-12-15
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "010_add_yield_connected_at"
down_revision = "009_add_listing_sold_fields"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("yield_domains", sa.Column("connected_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column("yield_domains", "connected_at")

View File

@ -0,0 +1,28 @@
"""Add click_id + destination_url to yield transactions.
Revision ID: 011_add_yield_transaction_click_id
Revises: 010_add_yield_connected_at
Create Date: 2025-12-15
"""
from alembic import op
import sqlalchemy as sa
revision = "011_add_yield_transaction_click_id"
down_revision = "010_add_yield_connected_at"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("yield_transactions", sa.Column("click_id", sa.String(length=64), nullable=True))
op.add_column("yield_transactions", sa.Column("destination_url", sa.Text(), nullable=True))
op.create_index("ix_yield_transactions_click_id", "yield_transactions", ["click_id"], unique=False)
def downgrade() -> None:
op.drop_index("ix_yield_transactions_click_id", table_name="yield_transactions")
op.drop_column("yield_transactions", "destination_url")
op.drop_column("yield_transactions", "click_id")

View File

@ -0,0 +1,67 @@
"""Add telemetry_events table.
Revision ID: 012_add_telemetry_events
Revises: 011_add_yield_transaction_click_id
Create Date: 2025-12-15
"""
from alembic import op
import sqlalchemy as sa
revision = "012_add_telemetry_events"
down_revision = "011_add_yield_transaction_click_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"telemetry_events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("event_name", sa.String(length=60), nullable=False),
sa.Column("listing_id", sa.Integer(), nullable=True),
sa.Column("inquiry_id", sa.Integer(), nullable=True),
sa.Column("yield_domain_id", sa.Integer(), nullable=True),
sa.Column("click_id", sa.String(length=64), nullable=True),
sa.Column("domain", sa.String(length=255), nullable=True),
sa.Column("source", sa.String(length=30), nullable=True),
sa.Column("ip_hash", sa.String(length=64), nullable=True),
sa.Column("user_agent", sa.String(length=500), nullable=True),
sa.Column("referrer", sa.String(length=500), nullable=True),
sa.Column("metadata_json", sa.Text(), nullable=True),
sa.Column("is_authenticated", sa.Boolean(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
)
op.create_index("ix_telemetry_events_event_name", "telemetry_events", ["event_name"])
op.create_index("ix_telemetry_events_user_id", "telemetry_events", ["user_id"])
op.create_index("ix_telemetry_events_listing_id", "telemetry_events", ["listing_id"])
op.create_index("ix_telemetry_events_inquiry_id", "telemetry_events", ["inquiry_id"])
op.create_index("ix_telemetry_events_yield_domain_id", "telemetry_events", ["yield_domain_id"])
op.create_index("ix_telemetry_events_click_id", "telemetry_events", ["click_id"])
op.create_index("ix_telemetry_events_domain", "telemetry_events", ["domain"])
op.create_index("ix_telemetry_events_created_at", "telemetry_events", ["created_at"])
op.create_index("ix_telemetry_event_name_created", "telemetry_events", ["event_name", "created_at"])
op.create_index("ix_telemetry_user_created", "telemetry_events", ["user_id", "created_at"])
op.create_index("ix_telemetry_listing_created", "telemetry_events", ["listing_id", "created_at"])
op.create_index("ix_telemetry_yield_created", "telemetry_events", ["yield_domain_id", "created_at"])
def downgrade() -> None:
op.drop_index("ix_telemetry_yield_created", table_name="telemetry_events")
op.drop_index("ix_telemetry_listing_created", table_name="telemetry_events")
op.drop_index("ix_telemetry_user_created", table_name="telemetry_events")
op.drop_index("ix_telemetry_event_name_created", table_name="telemetry_events")
op.drop_index("ix_telemetry_events_created_at", table_name="telemetry_events")
op.drop_index("ix_telemetry_events_domain", table_name="telemetry_events")
op.drop_index("ix_telemetry_events_click_id", table_name="telemetry_events")
op.drop_index("ix_telemetry_events_yield_domain_id", table_name="telemetry_events")
op.drop_index("ix_telemetry_events_inquiry_id", table_name="telemetry_events")
op.drop_index("ix_telemetry_events_listing_id", table_name="telemetry_events")
op.drop_index("ix_telemetry_events_user_id", table_name="telemetry_events")
op.drop_index("ix_telemetry_events_event_name", table_name="telemetry_events")
op.drop_table("telemetry_events")

View File

@ -0,0 +1,41 @@
"""add ops alert events
Revision ID: 013_add_ops_alert_events
Revises: 012_add_telemetry_events
Create Date: 2025-12-15
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "013_add_ops_alert_events"
down_revision = "012_add_telemetry_events"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"ops_alert_events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("alert_key", sa.String(length=80), nullable=False),
sa.Column("severity", sa.String(length=10), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("detail", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("recipients", sa.Text(), nullable=True),
sa.Column("send_reason", sa.String(length=60), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
)
op.create_index("ix_ops_alert_key_created", "ops_alert_events", ["alert_key", "created_at"])
op.create_index("ix_ops_alert_status_created", "ops_alert_events", ["status", "created_at"])
def downgrade() -> None:
op.drop_index("ix_ops_alert_status_created", table_name="ops_alert_events")
op.drop_index("ix_ops_alert_key_created", table_name="ops_alert_events")
op.drop_table("ops_alert_events")

View File

@ -0,0 +1,28 @@
"""add users invite_code
Revision ID: 014_add_user_invite_code
Revises: 013_add_ops_alert_events
Create Date: 2025-12-15
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "014_add_user_invite_code"
down_revision = "013_add_ops_alert_events"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("invite_code", sa.String(length=32), nullable=True))
op.create_index("ix_users_invite_code", "users", ["invite_code"], unique=True)
def downgrade() -> None:
op.drop_index("ix_users_invite_code", table_name="users")
op.drop_column("users", "invite_code")

View File

@ -0,0 +1,29 @@
"""add subscription referral bonus domains
Revision ID: 015_add_subscription_referral_bonus_domains
Revises: 014_add_user_invite_code
Create Date: 2025-12-15
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "015_add_subscription_referral_bonus_domains"
down_revision = "014_add_user_invite_code"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"subscriptions",
sa.Column("referral_bonus_domains", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("subscriptions", "referral_bonus_domains")

View File

@ -21,6 +21,8 @@ from app.api.dashboard import router as dashboard_router
from app.api.yield_domains import router as yield_router
from app.api.yield_webhooks import router as yield_webhooks_router
from app.api.yield_routing import router as yield_routing_router
from app.api.yield_payout_admin import router as yield_payout_admin_router
from app.api.telemetry import router as telemetry_router
api_router = APIRouter()
@ -49,6 +51,10 @@ api_router.include_router(seo_router, prefix="/seo", tags=["SEO Data - Tycoon"])
api_router.include_router(yield_router, tags=["Yield - Intent Routing"])
api_router.include_router(yield_webhooks_router, tags=["Yield - Webhooks"])
api_router.include_router(yield_routing_router, tags=["Yield - Routing"])
api_router.include_router(yield_payout_admin_router, tags=["Yield - Admin"])
# Telemetry / KPIs (admin)
api_router.include_router(telemetry_router, tags=["Telemetry"])
# Support & Communication
api_router.include_router(contact_router, prefix="/contact", tags=["Contact & Newsletter"])

View File

@ -25,6 +25,9 @@ from app.models.newsletter import NewsletterSubscriber
from app.models.tld_price import TLDPrice, TLDInfo
from app.models.auction import DomainAuction
from app.models.price_alert import PriceAlert
from app.services.db_backup import create_backup, list_backups
from app.services.ops_alerts import run_ops_alert_checks
from app.models.ops_alert import OpsAlertEvent
router = APIRouter()
settings = get_settings()
@ -525,12 +528,12 @@ async def upgrade_user(
user_id=user.id,
tier=new_tier,
status=SubscriptionStatus.ACTIVE,
domain_limit=config.get("domain_limit", 5),
max_domains=config.get("domain_limit", 5),
)
db.add(subscription)
else:
subscription.tier = new_tier
subscription.domain_limit = config.get("domain_limit", 5)
subscription.max_domains = config.get("domain_limit", 5)
subscription.status = SubscriptionStatus.ACTIVE
await db.commit()
@ -897,6 +900,83 @@ async def get_scheduler_status(
}
# ============== Ops: Backups (4B) ==============
@router.get("/system/backups")
async def get_backups(
admin: User = Depends(require_admin),
limit: int = 20,
):
"""List recent DB backups on the server."""
return {"backups": list_backups(limit=limit)}
@router.post("/system/backups")
async def create_db_backup(
admin: User = Depends(require_admin),
verify: bool = True,
):
"""Create a DB backup on the server (and verify it)."""
if not settings.enable_db_backups:
raise HTTPException(status_code=403, detail="DB backups are disabled (ENABLE_DB_BACKUPS=false).")
try:
result = create_backup(verify=verify)
return {
"status": "ok",
"backup": {
"path": result.path,
"size_bytes": result.size_bytes,
"created_at": result.created_at,
"verified": result.verified,
"verification_detail": result.verification_detail,
},
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Backup failed: {e}")
@router.post("/system/ops-alerts/run")
async def run_ops_alerts_now(
admin: User = Depends(require_admin),
):
"""
Run ops alert checks immediately (and send alerts if enabled).
Useful for server validation without Docker.
"""
return await run_ops_alert_checks()
@router.get("/system/ops-alerts/history")
async def get_ops_alert_history(
db: Database,
admin: User = Depends(require_admin),
limit: int = 100,
):
"""Return recent persisted ops alert events."""
limit = max(1, min(int(limit), 500))
rows = (
await db.execute(
select(OpsAlertEvent).order_by(OpsAlertEvent.created_at.desc()).limit(limit)
)
).scalars().all()
return {
"events": [
{
"id": e.id,
"alert_key": e.alert_key,
"severity": e.severity,
"title": e.title,
"detail": e.detail,
"status": e.status,
"send_reason": e.send_reason,
"recipients": e.recipients,
"created_at": e.created_at.isoformat(),
}
for e in rows
]
}
# ============== Bulk Operations ==============
class BulkUpgradeRequest(BaseModel):

View File

@ -14,6 +14,7 @@ Endpoints:
import os
import secrets
import logging
import re
from datetime import datetime, timedelta
from typing import Optional
@ -25,11 +26,24 @@ from slowapi.util import get_remote_address
from app.api.deps import Database, CurrentUser
from app.config import get_settings
from app.schemas.auth import UserCreate, UserLogin, UserResponse, LoginResponse
from app.schemas.auth import (
LoginResponse,
ReferralLinkResponse,
ReferralStats,
UserCreate,
UserLogin,
UserResponse,
)
from app.services.auth import AuthService
from app.services.email_service import email_service
from app.models.user import User
from app.security import set_auth_cookie, clear_auth_cookie
from app.services.telemetry import track_event
from app.services.referral_rewards import (
QUALIFIED_REFERRAL_BATCH_SIZE,
apply_referral_rewards_for_user,
compute_badge,
)
logger = logging.getLogger(__name__)
@ -72,7 +86,9 @@ class UpdateUserRequest(BaseModel):
# ============== Endpoints ==============
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("5/minute")
async def register(
request: Request,
user_data: UserCreate,
db: Database,
background_tasks: BackgroundTasks,
@ -100,32 +116,61 @@ async def register(
name=user_data.name,
)
# Process yield referral if present
# Format: yield_{user_id}_{domain_id}
if user_data.ref and user_data.ref.startswith("yield_"):
try:
parts = user_data.ref.split("_")
if len(parts) >= 3:
referrer_user_id = int(parts[1])
# Store referral info
user.referred_by_user_id = referrer_user_id
user.referral_code = user_data.ref
# Try to get domain name from yield_domain_id
# Process referral if present.
# Supported formats:
# - yield_{user_id}_{domain_id}
# - invite code (12 hex chars)
referral_applied = False
referrer_user_id: Optional[int] = None
referral_type: Optional[str] = None
if user_data.ref:
ref_raw = user_data.ref.strip()
# Yield referral: yield_{user_id}_{domain_id}
if ref_raw.startswith("yield_"):
try:
parts = ref_raw.split("_")
if len(parts) >= 3:
referrer_user_id = int(parts[1])
user.referred_by_user_id = referrer_user_id
user.referral_code = ref_raw
referral_type = "yield"
# Try to map the yield_domain_id to a domain string
try:
from app.models.yield_domain import YieldDomain
yield_domain_id = int(parts[2])
yd_res = await db.execute(select(YieldDomain).where(YieldDomain.id == yield_domain_id))
yd = yd_res.scalar_one_or_none()
if yd:
user.referred_by_domain = yd.domain
except Exception:
pass
await db.commit()
referral_applied = True
logger.info("User %s referred via yield by user %s", user.email, referrer_user_id)
except Exception as e:
logger.warning("Failed to process yield referral code: %s, error: %s", ref_raw, e)
else:
# Invite code referral (viral loop)
code = ref_raw.lower()
if re.fullmatch(r"[0-9a-f]{12}", code):
try:
from app.models.yield_domain import YieldDomain
yield_domain_id = int(parts[2])
yield_domain = await db.execute(
select(YieldDomain).where(YieldDomain.id == yield_domain_id)
)
yd = yield_domain.scalar_one_or_none()
if yd:
user.referred_by_domain = yd.domain
except Exception:
pass
await db.commit()
logger.info(f"User {user.email} referred by user {referrer_user_id}")
except Exception as e:
logger.warning(f"Failed to process referral code: {user_data.ref}, error: {e}")
ref_user_res = await db.execute(select(User).where(User.invite_code == code))
ref_user = ref_user_res.scalar_one_or_none()
if ref_user and ref_user.id != user.id:
referrer_user_id = ref_user.id
user.referred_by_user_id = ref_user.id
user.referral_code = code
referral_type = "invite"
await db.commit()
referral_applied = True
logger.info("User %s referred via invite_code by user %s", user.email, ref_user.id)
except Exception as e:
logger.warning("Failed to process invite referral code: %s, error: %s", code, e)
# Auto-admin for specific email
ADMIN_EMAILS = ["guggeryves@hotmail.com"]
@ -158,10 +203,40 @@ async def register(
user.email_verification_token = verification_token
user.email_verification_expires = datetime.utcnow() + timedelta(hours=24)
await db.commit()
# Telemetry: registration + referral attribution
try:
await track_event(
db,
event_name="user_registered",
request=request,
user_id=user.id,
is_authenticated=False,
source="public",
metadata={"ref": bool(user_data.ref)},
)
if referral_applied:
await track_event(
db,
event_name="referral_attributed",
request=request,
user_id=user.id,
is_authenticated=False,
source="public",
metadata={
"referral_type": referral_type,
"referrer_user_id": referrer_user_id,
"ref": user_data.ref,
},
)
await db.commit()
except Exception:
# never block registration
pass
# Send verification email in background
if email_service.is_configured():
site_url = os.getenv("SITE_URL", "http://localhost:3000")
site_url = (settings.site_url or "http://localhost:3000").rstrip("/")
verify_url = f"{site_url}/verify-email?token={verification_token}"
background_tasks.add_task(
@ -174,8 +249,104 @@ async def register(
return user
@router.get("/referral", response_model=ReferralLinkResponse)
async def get_referral_link(
request: Request,
current_user: CurrentUser,
db: Database,
days: int = 30,
):
"""Return the authenticated user's invite link."""
if not current_user.invite_code:
# Generate on demand for older users
for _ in range(12):
code = secrets.token_hex(6)
exists = await db.execute(select(User.id).where(User.invite_code == code))
if exists.scalar_one_or_none() is None:
current_user.invite_code = code
await db.commit()
break
if not current_user.invite_code:
raise HTTPException(status_code=500, detail="Failed to generate invite code")
# Apply rewards (idempotent) so UI reflects current state even without scheduler
snapshot = await apply_referral_rewards_for_user(db, current_user.id)
await db.commit()
base = (settings.site_url or "http://localhost:3000").rstrip("/")
url = f"{base}/register?ref={current_user.invite_code}"
try:
await track_event(
db,
event_name="referral_link_viewed",
request=request,
user_id=current_user.id,
is_authenticated=True,
source="terminal",
metadata={"invite_code": current_user.invite_code},
)
await db.commit()
except Exception:
pass
# Count link views in the chosen window
try:
from datetime import timedelta
from sqlalchemy import and_, func
from app.models.telemetry import TelemetryEvent
window_days = max(1, min(int(days), 365))
end = datetime.utcnow()
start = end - timedelta(days=window_days)
views = (
await db.execute(
select(func.count(TelemetryEvent.id)).where(
and_(
TelemetryEvent.event_name == "referral_link_viewed",
TelemetryEvent.user_id == current_user.id,
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
)
)
)
).scalar()
referral_link_views_window = int(views or 0)
except Exception:
window_days = 30
referral_link_views_window = 0
qualified = int(snapshot.qualified_referrals_total)
if qualified < QUALIFIED_REFERRAL_BATCH_SIZE:
next_reward_at = QUALIFIED_REFERRAL_BATCH_SIZE
else:
remainder = qualified % QUALIFIED_REFERRAL_BATCH_SIZE
next_reward_at = qualified + (QUALIFIED_REFERRAL_BATCH_SIZE - remainder) if remainder else qualified + QUALIFIED_REFERRAL_BATCH_SIZE
return ReferralLinkResponse(
invite_code=current_user.invite_code,
url=url,
stats=ReferralStats(
window_days=int(window_days),
referred_users_total=int(snapshot.referred_users_total),
qualified_referrals_total=qualified,
referral_link_views_window=int(referral_link_views_window),
bonus_domains=int(snapshot.bonus_domains),
next_reward_at=int(next_reward_at),
badge=compute_badge(qualified),
cooldown_days=int(getattr(snapshot, "cooldown_days", 7) or 7),
disqualified_cooldown_total=int(getattr(snapshot, "disqualified_cooldown_total", 0) or 0),
disqualified_missing_ip_total=int(getattr(snapshot, "disqualified_missing_ip_total", 0) or 0),
disqualified_shared_ip_total=int(getattr(snapshot, "disqualified_shared_ip_total", 0) or 0),
disqualified_duplicate_ip_total=int(getattr(snapshot, "disqualified_duplicate_ip_total", 0) or 0),
),
)
@router.post("/login", response_model=LoginResponse)
async def login(user_data: UserLogin, db: Database, response: Response):
@limiter.limit("10/minute")
async def login(request: Request, user_data: UserLogin, db: Database, response: Response):
"""
Authenticate user and return JWT token.
@ -280,8 +451,10 @@ async def update_current_user(
@router.post("/forgot-password", response_model=MessageResponse)
@limiter.limit("3/minute")
async def forgot_password(
request: ForgotPasswordRequest,
request: Request,
payload: ForgotPasswordRequest,
db: Database,
background_tasks: BackgroundTasks,
):
@ -296,9 +469,7 @@ async def forgot_password(
success_message = "If an account with this email exists, a password reset link has been sent."
# Look up user
result = await db.execute(
select(User).where(User.email == request.email.lower())
)
result = await db.execute(select(User).where(User.email == payload.email.lower()))
user = result.scalar_one_or_none()
if not user:
@ -313,7 +484,7 @@ async def forgot_password(
# Send reset email in background
if email_service.is_configured():
site_url = os.getenv("SITE_URL", "http://localhost:3000")
site_url = (settings.site_url or "http://localhost:3000").rstrip("/")
reset_url = f"{site_url}/reset-password?token={reset_token}"
background_tasks.add_task(
@ -411,8 +582,10 @@ async def verify_email(
@router.post("/resend-verification", response_model=MessageResponse)
@limiter.limit("3/minute")
async def resend_verification(
request: ForgotPasswordRequest, # Reuse schema - just needs email
request: Request,
payload: ForgotPasswordRequest, # Reuse schema - just needs email
db: Database,
background_tasks: BackgroundTasks,
):
@ -426,7 +599,7 @@ async def resend_verification(
# Look up user
result = await db.execute(
select(User).where(User.email == request.email.lower())
select(User).where(User.email == payload.email.lower())
)
user = result.scalar_one_or_none()
@ -441,7 +614,7 @@ async def resend_verification(
# Send verification email
if email_service.is_configured():
site_url = os.getenv("SITE_URL", "http://localhost:3000")
site_url = (settings.site_url or "http://localhost:3000").rstrip("/")
verify_url = f"{site_url}/verify-email?token={verification_token}"
background_tasks.add_task(

View File

@ -200,6 +200,36 @@ async def get_blog_post(
return data
@router.get("/posts/{slug}/meta")
async def get_blog_post_meta(
slug: str,
db: Database,
):
"""
Get blog post metadata by slug (public).
IMPORTANT: This endpoint does NOT increment view_count.
It's intended for SEO metadata generation (generateMetadata, JSON-LD).
"""
result = await db.execute(
select(BlogPost)
.options(selectinload(BlogPost.author))
.where(
BlogPost.slug == slug,
BlogPost.is_published == True,
)
)
post = result.scalar_one_or_none()
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Blog post not found",
)
return post.to_dict(include_content=False)
# ============== Admin Endpoints ==============
@router.get("/admin/posts")

View File

@ -16,10 +16,12 @@ from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, status, BackgroundTasks, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy import select, delete
from slowapi import Limiter
from slowapi.util import get_remote_address
from urllib.parse import urlencode
from app.api.deps import Database
from app.services.email_service import email_service
@ -32,6 +34,11 @@ router = APIRouter()
# Rate limiter for contact endpoints
limiter = Limiter(key_func=get_remote_address)
def _build_unsubscribe_url(email: str, token: str) -> str:
base = os.getenv("SITE_URL", "https://pounce.ch").rstrip("/")
query = urlencode({"email": email, "token": token})
return f"{base}/api/v1/contact/newsletter/unsubscribe?{query}"
# ============== Schemas ==============
@ -139,6 +146,7 @@ async def subscribe_newsletter(
background_tasks.add_task(
email_service.send_newsletter_welcome,
to_email=email_lower,
unsubscribe_url=_build_unsubscribe_url(email_lower, existing.unsubscribe_token),
)
return MessageResponse(
@ -160,6 +168,7 @@ async def subscribe_newsletter(
background_tasks.add_task(
email_service.send_newsletter_welcome,
to_email=email_lower,
unsubscribe_url=_build_unsubscribe_url(email_lower, subscriber.unsubscribe_token),
)
logger.info(f"Newsletter subscription: {email_lower}")
@ -216,6 +225,50 @@ async def unsubscribe_newsletter(
)
@router.get("/newsletter/unsubscribe")
async def unsubscribe_newsletter_one_click(
email: EmailStr,
token: str,
db: Database,
):
"""
One-click unsubscribe endpoint (for List-Unsubscribe header).
Always returns 200 with a human-readable HTML response.
"""
email_lower = email.lower()
result = await db.execute(
select(NewsletterSubscriber).where(
NewsletterSubscriber.email == email_lower,
NewsletterSubscriber.unsubscribe_token == token,
)
)
subscriber = result.scalar_one_or_none()
if subscriber and subscriber.is_active:
subscriber.is_active = False
subscriber.unsubscribed_at = datetime.utcnow()
await db.commit()
return HTMLResponse(
content="""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Unsubscribed</title>
</head>
<body style="font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; padding: 32px;">
<h1 style="margin: 0 0 12px 0;">You are unsubscribed.</h1>
<p style="margin: 0; color: #555;">
If you were subscribed, you will no longer receive pounce insights emails.
</p>
</body>
</html>
""".strip(),
status_code=200,
)
@router.get("/newsletter/status")
async def check_newsletter_status(
email: EmailStr,

View File

@ -127,7 +127,7 @@ async def add_domain(
await db.refresh(current_user, ["subscription", "domains"])
if current_user.subscription:
limit = current_user.subscription.max_domains
limit = current_user.subscription.domain_limit
else:
limit = TIER_CONFIG[SubscriptionTier.SCOUT]["domain_limit"]

View File

@ -31,7 +31,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.api.deps import get_current_user, get_current_user_optional
from app.models.user import User
from app.models.listing import DomainListing, ListingInquiry, ListingView, ListingStatus, VerificationStatus
from app.models.listing import (
DomainListing,
ListingInquiry,
ListingInquiryEvent,
ListingInquiryMessage,
ListingView,
ListingStatus,
VerificationStatus,
)
from app.services.valuation import valuation_service
@ -104,6 +112,9 @@ class ListingUpdate(BaseModel):
show_valuation: Optional[bool] = None
allow_offers: Optional[bool] = None
status: Optional[str] = None
sold_reason: Optional[str] = Field(None, max_length=200)
sold_price: Optional[float] = Field(None, ge=0)
sold_currency: Optional[str] = Field(None, max_length=3)
class ListingResponse(BaseModel):
@ -129,6 +140,10 @@ class ListingResponse(BaseModel):
public_url: str
created_at: datetime
published_at: Optional[datetime]
sold_at: Optional[datetime] = None
sold_reason: Optional[str] = None
sold_price: Optional[float] = None
sold_currency: Optional[str] = None
# Seller info (minimal for privacy)
seller_verified: bool = False
@ -156,6 +171,7 @@ class ListingPublicResponse(BaseModel):
# Seller trust indicators
seller_verified: bool
seller_member_since: Optional[datetime]
seller_invite_code: Optional[str] = None
class Config:
from_attributes = True
@ -183,6 +199,10 @@ class InquiryResponse(BaseModel):
status: str
created_at: datetime
read_at: Optional[datetime]
replied_at: Optional[datetime] = None
closed_at: Optional[datetime] = None
closed_reason: Optional[str] = None
buyer_user_id: Optional[int] = None
class Config:
from_attributes = True
@ -191,6 +211,23 @@ class InquiryResponse(BaseModel):
class InquiryUpdate(BaseModel):
"""Update inquiry status for listing owner."""
status: str = Field(..., min_length=1, max_length=20) # new, read, replied, spam
reason: Optional[str] = Field(None, max_length=200)
class InquiryMessageCreate(BaseModel):
body: str = Field(..., min_length=1, max_length=4000)
class InquiryMessageResponse(BaseModel):
id: int
inquiry_id: int
listing_id: int
sender_user_id: int
body: str
created_at: datetime
class Config:
from_attributes = True
class VerificationResponse(BaseModel):
@ -309,6 +346,7 @@ async def browse_listings(
public_url=listing.public_url,
seller_verified=listing.is_verified,
seller_member_since=listing.user.created_at if listing.user else None,
seller_invite_code=getattr(listing.user, "invite_code", None) if listing.user else None,
))
await db.commit() # Save any updated pounce_scores
@ -353,6 +391,10 @@ async def get_my_listings(
public_url=listing.public_url,
created_at=listing.created_at,
published_at=listing.published_at,
sold_at=getattr(listing, "sold_at", None),
sold_reason=getattr(listing, "sold_reason", None),
sold_price=getattr(listing, "sold_price", None),
sold_currency=getattr(listing, "sold_currency", None),
seller_verified=current_user.is_verified,
seller_member_since=current_user.created_at,
)
@ -395,6 +437,18 @@ async def get_listing_by_slug(
# Increment view count
listing.view_count += 1
await track_event(
db,
event_name="listing_view",
request=request,
user_id=current_user.id if current_user else None,
is_authenticated=bool(current_user),
source="public",
domain=listing.domain,
listing_id=listing.id,
metadata={"slug": listing.slug},
)
# Calculate pounce_score dynamically if not stored (same as Market Feed)
pounce_score = listing.pounce_score
@ -420,6 +474,7 @@ async def get_listing_by_slug(
public_url=listing.public_url,
seller_verified=listing.is_verified,
seller_member_since=listing.user.created_at if listing.user else None,
seller_invite_code=getattr(listing.user, "invite_code", None) if listing.user else None,
)
@ -461,13 +516,13 @@ async def submit_inquiry(
detail="Message contains blocked content. Please revise."
)
# Rate limiting check (simple: max 3 inquiries per email per listing per day)
# Rate limiting check (simple: max 3 inquiries per user per listing per day)
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
existing_count = await db.execute(
select(func.count(ListingInquiry.id)).where(
and_(
ListingInquiry.listing_id == listing.id,
ListingInquiry.email == inquiry.email.lower(),
ListingInquiry.buyer_user_id == current_user.id,
ListingInquiry.created_at >= today_start,
)
)
@ -481,6 +536,7 @@ async def submit_inquiry(
# Create inquiry
new_inquiry = ListingInquiry(
listing_id=listing.id,
buyer_user_id=current_user.id,
name=inquiry.name,
email=inquiry.email.lower(),
phone=inquiry.phone,
@ -491,6 +547,34 @@ async def submit_inquiry(
user_agent=request.headers.get("user-agent", "")[:500],
)
db.add(new_inquiry)
await db.flush()
await track_event(
db,
event_name="inquiry_created",
request=request,
user_id=current_user.id,
is_authenticated=True,
source="public",
domain=listing.domain,
listing_id=listing.id,
inquiry_id=new_inquiry.id,
metadata={
"offer_amount": inquiry.offer_amount,
"has_phone": bool(inquiry.phone),
"has_company": bool(inquiry.company),
},
)
# Seed thread with the initial message
db.add(
ListingInquiryMessage(
inquiry_id=new_inquiry.id,
listing_id=listing.id,
sender_user_id=current_user.id,
body=inquiry.message,
)
)
# Increment inquiry count
listing.inquiry_count += 1
@ -716,6 +800,10 @@ async def get_listing_inquiries(
status=inq.status,
created_at=inq.created_at,
read_at=inq.read_at,
replied_at=getattr(inq, "replied_at", None),
closed_at=getattr(inq, "closed_at", None),
closed_reason=getattr(inq, "closed_reason", None),
buyer_user_id=getattr(inq, "buyer_user_id", None),
)
for inq in inquiries
]
@ -726,11 +814,12 @@ async def update_listing_inquiry(
id: int,
inquiry_id: int,
data: InquiryUpdate,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update an inquiry status (listing owner only)."""
allowed = {"new", "read", "replied", "spam"}
allowed = {"new", "read", "replied", "closed", "spam"}
status_clean = (data.status or "").strip().lower()
if status_clean not in allowed:
raise HTTPException(status_code=400, detail="Invalid status")
@ -761,11 +850,43 @@ async def update_listing_inquiry(
raise HTTPException(status_code=404, detail="Inquiry not found")
now = datetime.utcnow()
old_status = getattr(inquiry, "status", None)
inquiry.status = status_clean
if status_clean == "read" and inquiry.read_at is None:
inquiry.read_at = now
if status_clean == "replied":
inquiry.replied_at = now
if status_clean == "closed":
inquiry.closed_at = now
inquiry.closed_reason = (data.reason or "").strip() or None
if status_clean == "spam":
inquiry.closed_reason = (data.reason or "").strip() or inquiry.closed_reason
# Audit trail
event = ListingInquiryEvent(
inquiry_id=inquiry.id,
listing_id=listing.id,
actor_user_id=current_user.id,
old_status=old_status,
new_status=status_clean,
reason=(data.reason or "").strip() or None,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", "")[:500],
)
db.add(event)
await track_event(
db,
event_name="inquiry_status_changed",
request=request,
user_id=current_user.id,
is_authenticated=True,
source="terminal",
domain=listing.domain,
listing_id=listing.id,
inquiry_id=inquiry.id,
metadata={"old_status": old_status, "new_status": status_clean, "reason": (data.reason or "").strip() or None},
)
await db.commit()
await db.refresh(inquiry)
@ -781,13 +902,285 @@ async def update_listing_inquiry(
status=inquiry.status,
created_at=inquiry.created_at,
read_at=inquiry.read_at,
replied_at=getattr(inquiry, "replied_at", None),
closed_at=getattr(inquiry, "closed_at", None),
closed_reason=getattr(inquiry, "closed_reason", None),
buyer_user_id=getattr(inquiry, "buyer_user_id", None),
)
@router.get("/{id}/inquiries/{inquiry_id}/messages", response_model=List[InquiryMessageResponse])
async def get_inquiry_messages_for_seller(
id: int,
inquiry_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Seller: fetch thread messages for an inquiry."""
listing_result = await db.execute(
select(DomainListing).where(and_(DomainListing.id == id, DomainListing.user_id == current_user.id))
)
listing = listing_result.scalar_one_or_none()
if not listing:
raise HTTPException(status_code=404, detail="Listing not found")
inquiry_result = await db.execute(
select(ListingInquiry).where(and_(ListingInquiry.id == inquiry_id, ListingInquiry.listing_id == id))
)
inquiry = inquiry_result.scalar_one_or_none()
if not inquiry:
raise HTTPException(status_code=404, detail="Inquiry not found")
msgs = (
await db.execute(
select(ListingInquiryMessage)
.where(and_(ListingInquiryMessage.inquiry_id == inquiry_id, ListingInquiryMessage.listing_id == id))
.order_by(ListingInquiryMessage.created_at.asc())
)
).scalars().all()
return [InquiryMessageResponse.model_validate(m) for m in msgs]
@router.post("/{id}/inquiries/{inquiry_id}/messages", response_model=InquiryMessageResponse)
async def post_inquiry_message_as_seller(
id: int,
inquiry_id: int,
payload: InquiryMessageCreate,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Seller: post a message into an inquiry thread."""
listing_result = await db.execute(
select(DomainListing).where(and_(DomainListing.id == id, DomainListing.user_id == current_user.id))
)
listing = listing_result.scalar_one_or_none()
if not listing:
raise HTTPException(status_code=404, detail="Listing not found")
inquiry_result = await db.execute(
select(ListingInquiry).where(and_(ListingInquiry.id == inquiry_id, ListingInquiry.listing_id == id))
)
inquiry = inquiry_result.scalar_one_or_none()
if not inquiry:
raise HTTPException(status_code=404, detail="Inquiry not found")
if inquiry.status in ["closed", "spam"]:
raise HTTPException(status_code=400, detail="Inquiry is closed")
# Content safety (phishing keywords)
if not _check_content_safety(payload.body):
raise HTTPException(status_code=400, detail="Message contains blocked content. Please revise.")
# Simple rate limit: max 30 messages per hour per inquiry
hour_start = datetime.utcnow() - timedelta(hours=1)
msg_count = (
await db.execute(
select(func.count(ListingInquiryMessage.id)).where(
and_(
ListingInquiryMessage.inquiry_id == inquiry.id,
ListingInquiryMessage.sender_user_id == current_user.id,
ListingInquiryMessage.created_at >= hour_start,
)
)
)
).scalar() or 0
if msg_count >= 30:
raise HTTPException(status_code=429, detail="Too many messages. Please slow down.")
msg = ListingInquiryMessage(
inquiry_id=inquiry.id,
listing_id=listing.id,
sender_user_id=current_user.id,
body=payload.body,
)
db.add(msg)
await db.flush()
await track_event(
db,
event_name="message_sent",
request=request,
user_id=current_user.id,
is_authenticated=True,
source="terminal",
domain=listing.domain,
listing_id=listing.id,
inquiry_id=inquiry.id,
metadata={"role": "buyer"},
)
await track_event(
db,
event_name="message_sent",
request=request,
user_id=current_user.id,
is_authenticated=True,
source="terminal",
domain=listing.domain,
listing_id=listing.id,
inquiry_id=inquiry.id,
metadata={"role": "seller"},
)
# Email buyer (if configured)
try:
from app.services.email_service import email_service
if inquiry.buyer_user_id:
buyer = (
await db.execute(select(User).where(User.id == inquiry.buyer_user_id))
).scalar_one_or_none()
else:
buyer = None
if buyer and buyer.email and email_service.is_configured():
thread_url = f"https://pounce.ch/terminal/inbox?inquiry={inquiry.id}"
await email_service.send_listing_message(
to_email=buyer.email,
domain=listing.domain,
sender_name=current_user.name or current_user.email,
message=payload.body,
thread_url=thread_url,
)
except Exception as e:
logger.error(f"Failed to send listing message notification: {e}")
await db.commit()
await db.refresh(msg)
return InquiryMessageResponse.model_validate(msg)
@router.get("/inquiries/my")
async def get_my_inquiries_as_buyer(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Buyer: list inquiries created from this account."""
result = await db.execute(
select(ListingInquiry, DomainListing)
.join(DomainListing, DomainListing.id == ListingInquiry.listing_id)
.where(ListingInquiry.buyer_user_id == current_user.id)
.order_by(ListingInquiry.created_at.desc())
)
rows = result.all()
return [
{
"id": inq.id,
"listing_id": listing.id,
"domain": listing.domain,
"slug": listing.slug,
"status": inq.status,
"created_at": inq.created_at.isoformat(),
"closed_at": inq.closed_at.isoformat() if getattr(inq, "closed_at", None) else None,
"closed_reason": getattr(inq, "closed_reason", None),
}
for inq, listing in rows
]
@router.get("/inquiries/{inquiry_id}/messages", response_model=List[InquiryMessageResponse])
async def get_inquiry_messages_for_buyer(
inquiry_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Buyer: fetch thread messages for one inquiry."""
inquiry = (
await db.execute(select(ListingInquiry).where(ListingInquiry.id == inquiry_id))
).scalar_one_or_none()
if not inquiry or inquiry.buyer_user_id != current_user.id:
raise HTTPException(status_code=404, detail="Inquiry not found")
msgs = (
await db.execute(
select(ListingInquiryMessage)
.where(ListingInquiryMessage.inquiry_id == inquiry_id)
.order_by(ListingInquiryMessage.created_at.asc())
)
).scalars().all()
return [InquiryMessageResponse.model_validate(m) for m in msgs]
@router.post("/inquiries/{inquiry_id}/messages", response_model=InquiryMessageResponse)
async def post_inquiry_message_as_buyer(
inquiry_id: int,
payload: InquiryMessageCreate,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Buyer: post a message into an inquiry thread."""
inquiry = (
await db.execute(select(ListingInquiry).where(ListingInquiry.id == inquiry_id))
).scalar_one_or_none()
if not inquiry or inquiry.buyer_user_id != current_user.id:
raise HTTPException(status_code=404, detail="Inquiry not found")
if inquiry.status in ["closed", "spam"]:
raise HTTPException(status_code=400, detail="Inquiry is closed")
# Content safety (phishing keywords)
if not _check_content_safety(payload.body):
raise HTTPException(status_code=400, detail="Message contains blocked content. Please revise.")
# Simple rate limit: max 20 messages per hour per inquiry
hour_start = datetime.utcnow() - timedelta(hours=1)
msg_count = (
await db.execute(
select(func.count(ListingInquiryMessage.id)).where(
and_(
ListingInquiryMessage.inquiry_id == inquiry.id,
ListingInquiryMessage.sender_user_id == current_user.id,
ListingInquiryMessage.created_at >= hour_start,
)
)
)
).scalar() or 0
if msg_count >= 20:
raise HTTPException(status_code=429, detail="Too many messages. Please slow down.")
listing = (
await db.execute(select(DomainListing).where(DomainListing.id == inquiry.listing_id))
).scalar_one_or_none()
if not listing:
raise HTTPException(status_code=404, detail="Listing not found")
msg = ListingInquiryMessage(
inquiry_id=inquiry.id,
listing_id=listing.id,
sender_user_id=current_user.id,
body=payload.body,
)
db.add(msg)
await db.flush()
# Email seller (if configured)
try:
from app.services.email_service import email_service
seller = (
await db.execute(select(User).where(User.id == listing.user_id))
).scalar_one_or_none()
if seller and seller.email and email_service.is_configured():
thread_url = f"https://pounce.ch/terminal/listing"
await email_service.send_listing_message(
to_email=seller.email,
domain=listing.domain,
sender_name=current_user.name or current_user.email,
message=payload.body,
thread_url=thread_url,
)
except Exception as e:
logger.error(f"Failed to send listing message notification: {e}")
await db.commit()
await db.refresh(msg)
return InquiryMessageResponse.model_validate(msg)
@router.put("/{id}", response_model=ListingResponse)
async def update_listing(
id: int,
data: ListingUpdate,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@ -832,8 +1225,58 @@ async def update_listing(
)
listing.status = ListingStatus.ACTIVE.value
listing.published_at = datetime.utcnow()
elif data.status in ["draft", "sold", "expired"]:
elif data.status in ["draft", "expired"]:
listing.status = data.status
elif data.status == "sold":
if listing.status != ListingStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Only active listings can be marked as sold.")
listing.status = ListingStatus.SOLD.value
listing.sold_at = datetime.utcnow()
listing.sold_reason = (data.sold_reason or "").strip() or listing.sold_reason
listing.sold_price = data.sold_price if data.sold_price is not None else listing.sold_price
listing.sold_currency = (data.sold_currency or listing.currency or "USD").upper()
# Close all open inquiries on this listing (deal is done).
inqs = (
await db.execute(
select(ListingInquiry).where(ListingInquiry.listing_id == listing.id)
)
).scalars().all()
for inq in inqs:
if inq.status in ["closed", "spam"]:
continue
old = inq.status
inq.status = "closed"
inq.closed_at = datetime.utcnow()
inq.closed_reason = inq.closed_reason or "sold"
db.add(
ListingInquiryEvent(
inquiry_id=inq.id,
listing_id=listing.id,
actor_user_id=current_user.id,
old_status=old,
new_status="closed",
reason="sold",
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", "")[:500],
)
)
await track_event(
db,
event_name="listing_marked_sold",
request=request,
user_id=current_user.id,
is_authenticated=True,
source="terminal",
domain=listing.domain,
listing_id=listing.id,
metadata={
"sold_reason": listing.sold_reason,
"sold_price": float(listing.sold_price) if listing.sold_price is not None else None,
"sold_currency": listing.sold_currency,
},
)
await db.commit()
await db.refresh(listing)
@ -860,6 +1303,10 @@ async def update_listing(
public_url=listing.public_url,
created_at=listing.created_at,
published_at=listing.published_at,
sold_at=getattr(listing, "sold_at", None),
sold_reason=getattr(listing, "sold_reason", None),
sold_price=getattr(listing, "sold_price", None),
sold_currency=getattr(listing, "sold_currency", None),
seller_verified=current_user.is_verified,
seller_member_since=current_user.created_at,
)

View File

@ -84,7 +84,7 @@ async def get_subscription(
tier=subscription.tier.value,
tier_name=config["name"],
status=subscription.status.value,
domain_limit=subscription.max_domains,
domain_limit=subscription.domain_limit,
domains_used=domains_used,
portfolio_limit=config.get("portfolio_limit", 0),
check_frequency=config["check_frequency"],

View File

@ -0,0 +1,365 @@
"""
Telemetry KPIs (4A.2).
Admin-only endpoint to compute funnel KPIs from telemetry_events.
"""
from __future__ import annotations
import json
import statistics
from datetime import datetime, timedelta
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.models.telemetry import TelemetryEvent
from app.models.user import User
from app.schemas.referrals import ReferralKpiWindow, ReferralKpisResponse, ReferralReferrerRow
from app.schemas.telemetry import (
DealFunnelKpis,
TelemetryKpiWindow,
TelemetryKpisResponse,
YieldFunnelKpis,
)
router = APIRouter(prefix="/telemetry", tags=["telemetry"])
def _require_admin(user: User) -> None:
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
def _safe_json(metadata_json: Optional[str]) -> dict[str, Any]:
if not metadata_json:
return {}
try:
value = json.loads(metadata_json)
return value if isinstance(value, dict) else {}
except Exception:
return {}
def _median(values: list[float]) -> Optional[float]:
if not values:
return None
return float(statistics.median(values))
@router.get("/kpis", response_model=TelemetryKpisResponse)
async def get_kpis(
days: int = Query(30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin(current_user)
end = datetime.utcnow()
start = end - timedelta(days=days)
event_names = [
# Deal funnel
"listing_view",
"inquiry_created",
"inquiry_status_changed",
"message_sent",
"listing_marked_sold",
# Yield funnel
"yield_connected",
"yield_click",
"yield_conversion",
"payout_paid",
]
rows = (
await db.execute(
select(
TelemetryEvent.event_name,
TelemetryEvent.created_at,
TelemetryEvent.listing_id,
TelemetryEvent.inquiry_id,
TelemetryEvent.yield_domain_id,
TelemetryEvent.click_id,
TelemetryEvent.metadata_json,
).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name.in_(event_names),
)
)
)
).all()
# -----------------------------
# Deal KPIs
# -----------------------------
listing_views = 0
inquiries_created = 0
inquiry_created_at: dict[int, datetime] = {}
first_seller_reply_at: dict[int, datetime] = {}
listings_with_inquiries: set[int] = set()
sold_listings: set[int] = set()
sold_at_by_listing: dict[int, datetime] = {}
first_inquiry_at_by_listing: dict[int, datetime] = {}
# -----------------------------
# Yield KPIs
# -----------------------------
connected_domains = 0
clicks = 0
conversions = 0
payouts_paid = 0
payouts_paid_amount_total = 0.0
for event_name, created_at, listing_id, inquiry_id, yield_domain_id, click_id, metadata_json in rows:
created_at = created_at # already datetime
if event_name == "listing_view":
listing_views += 1
continue
if event_name == "inquiry_created":
inquiries_created += 1
if inquiry_id:
inquiry_created_at[inquiry_id] = created_at
if listing_id:
listings_with_inquiries.add(listing_id)
prev = first_inquiry_at_by_listing.get(listing_id)
if prev is None or created_at < prev:
first_inquiry_at_by_listing[listing_id] = created_at
continue
if event_name == "message_sent":
if not inquiry_id:
continue
meta = _safe_json(metadata_json)
if meta.get("role") == "seller":
prev = first_seller_reply_at.get(inquiry_id)
if prev is None or created_at < prev:
first_seller_reply_at[inquiry_id] = created_at
continue
if event_name == "listing_marked_sold":
if listing_id:
sold_listings.add(listing_id)
sold_at_by_listing[listing_id] = created_at
continue
if event_name == "yield_connected":
connected_domains += 1
continue
if event_name == "yield_click":
clicks += 1
continue
if event_name == "yield_conversion":
conversions += 1
continue
if event_name == "payout_paid":
payouts_paid += 1
meta = _safe_json(metadata_json)
amount = meta.get("amount")
if isinstance(amount, (int, float)):
payouts_paid_amount_total += float(amount)
continue
seller_replied_inquiries = len(first_seller_reply_at.keys())
inquiry_reply_rate = (seller_replied_inquiries / inquiries_created) if inquiries_created else 0.0
# Inquiry → Sold rate (on listing-level intersection)
sold_from_inquiry = sold_listings.intersection(listings_with_inquiries)
inquiry_to_sold_listing_rate = (len(sold_from_inquiry) / len(listings_with_inquiries)) if listings_with_inquiries else 0.0
# Median reply time (seconds): inquiry_created → first seller message
reply_deltas: list[float] = []
for inq_id, created in inquiry_created_at.items():
replied = first_seller_reply_at.get(inq_id)
if replied:
reply_deltas.append((replied - created).total_seconds())
# Median time-to-sold (seconds): first inquiry on listing → listing sold
sold_deltas: list[float] = []
for listing in sold_from_inquiry:
inq_at = first_inquiry_at_by_listing.get(listing)
sold_at = sold_at_by_listing.get(listing)
if inq_at and sold_at and sold_at >= inq_at:
sold_deltas.append((sold_at - inq_at).total_seconds())
deal = DealFunnelKpis(
listing_views=listing_views,
inquiries_created=inquiries_created,
seller_replied_inquiries=seller_replied_inquiries,
inquiry_reply_rate=float(inquiry_reply_rate),
listings_with_inquiries=len(listings_with_inquiries),
listings_sold=len(sold_listings),
inquiry_to_sold_listing_rate=float(inquiry_to_sold_listing_rate),
median_reply_seconds=_median(reply_deltas),
median_time_to_sold_seconds=_median(sold_deltas),
)
yield_kpis = YieldFunnelKpis(
connected_domains=connected_domains,
clicks=clicks,
conversions=conversions,
conversion_rate=float(conversions / clicks) if clicks else 0.0,
payouts_paid=payouts_paid,
payouts_paid_amount_total=float(payouts_paid_amount_total),
)
return TelemetryKpisResponse(
window=TelemetryKpiWindow(days=days, start=start, end=end),
deal=deal,
yield_=yield_kpis,
)
@router.get("/referrals", response_model=ReferralKpisResponse)
async def get_referral_kpis(
days: int = Query(30, ge=1, le=365),
limit: int = Query(200, ge=1, le=1000),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Admin-only referral KPIs for the viral loop (3C.2).
This is intentionally user-based (users.referred_by_user_id) + telemetry-based (referral_link_viewed),
so it stays robust even if ref codes evolve.
"""
_require_admin(current_user)
end = datetime.utcnow()
start = end - timedelta(days=days)
# Referred user counts per referrer (all-time + window)
referred_counts_subq = (
select(
User.referred_by_user_id.label("referrer_user_id"),
func.count(User.id).label("referred_users_total"),
func.coalesce(
func.sum(case((User.created_at >= start, 1), else_=0)),
0,
).label("referred_users_window"),
)
.where(User.referred_by_user_id.isnot(None))
.group_by(User.referred_by_user_id)
.subquery()
)
# Referral link views in window (telemetry)
link_views_subq = (
select(
TelemetryEvent.user_id.label("referrer_user_id"),
func.count(TelemetryEvent.id).label("referral_link_views_window"),
)
.where(
and_(
TelemetryEvent.event_name == "referral_link_viewed",
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.user_id.isnot(None),
)
)
.group_by(TelemetryEvent.user_id)
.subquery()
)
# Referrers: anyone with an invite_code (we still show even if counts are zero)
rows = (
await db.execute(
select(
User.id,
User.email,
User.invite_code,
User.created_at,
func.coalesce(referred_counts_subq.c.referred_users_total, 0),
func.coalesce(referred_counts_subq.c.referred_users_window, 0),
func.coalesce(link_views_subq.c.referral_link_views_window, 0),
)
.where(User.invite_code.isnot(None))
.outerjoin(referred_counts_subq, referred_counts_subq.c.referrer_user_id == User.id)
.outerjoin(link_views_subq, link_views_subq.c.referrer_user_id == User.id)
.order_by(
func.coalesce(referred_counts_subq.c.referred_users_window, 0).desc(),
func.coalesce(referred_counts_subq.c.referred_users_total, 0).desc(),
User.created_at.desc(),
)
.offset(offset)
.limit(limit)
)
).all()
referrers = [
ReferralReferrerRow(
user_id=int(user_id),
email=str(email),
invite_code=str(invite_code) if invite_code else None,
created_at=created_at,
referred_users_total=int(referred_total or 0),
referred_users_window=int(referred_window or 0),
referral_link_views_window=int(link_views or 0),
)
for user_id, email, invite_code, created_at, referred_total, referred_window, link_views in rows
]
totals = {}
totals["referrers_with_invite_code"] = int(
(
await db.execute(
select(func.count(User.id)).where(User.invite_code.isnot(None))
)
).scalar()
or 0
)
totals["referred_users_total"] = int(
(
await db.execute(
select(func.count(User.id)).where(User.referred_by_user_id.isnot(None))
)
).scalar()
or 0
)
totals["referred_users_window"] = int(
(
await db.execute(
select(func.count(User.id)).where(
and_(
User.referred_by_user_id.isnot(None),
User.created_at >= start,
User.created_at <= end,
)
)
)
).scalar()
or 0
)
totals["referral_link_views_window"] = int(
(
await db.execute(
select(func.count(TelemetryEvent.id)).where(
and_(
TelemetryEvent.event_name == "referral_link_viewed",
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
)
)
)
).scalar()
or 0
)
return ReferralKpisResponse(
window=ReferralKpiWindow(days=days, start=start, end=end),
totals=totals,
referrers=referrers,
)

View File

@ -64,6 +64,38 @@ async def get_db_price_count(db) -> int:
return result.scalar() or 0
@router.get("/tlds")
async def list_tracked_tlds(
db: Database,
limit: int = Query(5000, ge=1, le=20000),
offset: int = Query(0, ge=0),
):
"""
List distinct TLDs tracked in the database (DB-driven).
This endpoint is intentionally database-only (no static fallback),
so callers (e.g. sitemap generation) can rely on real tracked inventory.
"""
rows = (
await db.execute(
select(TLDPrice.tld)
.distinct()
.order_by(TLDPrice.tld)
.offset(offset)
.limit(limit)
)
).scalars().all()
total = (await db.execute(select(func.count(func.distinct(TLDPrice.tld))))).scalar() or 0
latest = (await db.execute(select(func.max(TLDPrice.recorded_at)))).scalar()
return {
"tlds": [str(t).lstrip(".").lower() for t in rows if t],
"total": int(total),
"limit": int(limit),
"offset": int(offset),
"latest_recorded_at": latest.isoformat() if latest else None,
}
# Real TLD price data based on current market research (December 2024)
# Prices in USD, sourced from major registrars: Namecheap, Cloudflare, Porkbun, Google Domains
TLD_DATA = {
@ -655,14 +687,8 @@ async def get_tld_price_history(
):
"""Get price history for a specific TLD.
Returns REAL historical data from database if available (5+ data points),
otherwise generates simulated data based on current price and known trends.
Data Source Priority:
1. Real DB data (from daily scrapes) - marked as source: "database"
2. Simulated data based on trend - marked as source: "simulated"
Returns REAL historical data from database (no simulation).
"""
import math
tld_clean = tld.lower().lstrip(".")
@ -688,81 +714,35 @@ async def get_tld_price_history(
trend = static_data.get("trend", "stable")
trend_reason = static_data.get("trend_reason", "Price tracking available")
# ==========================================================================
# TRY REAL HISTORICAL DATA FROM DATABASE FIRST
# ==========================================================================
real_history = await get_real_price_history(db, tld_clean, days)
# Use real data if we have enough points (at least 5 data points)
if len(real_history) >= 5:
history = real_history
data_source = "database"
# Calculate price changes from real data
price_7d_ago = None
price_30d_ago = None
price_90d_ago = None
now = datetime.utcnow().date()
for h in history:
if not real_history:
raise HTTPException(status_code=404, detail=f"No historical data for '.{tld_clean}' yet")
history = real_history
data_source = "database"
# Use the most recent daily average as current_price when available
if history:
current_price = float(history[-1]["price"])
def _price_at_or_before(days_ago_target: int) -> float:
"""Get the closest historical price at or before the target age."""
target_date = (datetime.utcnow() - timedelta(days=days_ago_target)).date()
best = float(history[0]["price"])
for h in reversed(history):
try:
h_date = datetime.strptime(h["date"], "%Y-%m-%d").date()
days_ago = (now - h_date).days
if days_ago <= 7 and price_7d_ago is None:
price_7d_ago = h["price"]
if days_ago <= 30 and price_30d_ago is None:
price_30d_ago = h["price"]
if days_ago <= 90 and price_90d_ago is None:
price_90d_ago = h["price"]
except (ValueError, TypeError):
except Exception:
continue
# Fallback to earliest available
if price_7d_ago is None and history:
price_7d_ago = history[-1]["price"]
if price_30d_ago is None and history:
price_30d_ago = history[0]["price"]
if price_90d_ago is None and history:
price_90d_ago = history[0]["price"]
else:
# ==========================================================================
# FALLBACK: SIMULATED DATA BASED ON TREND
# ==========================================================================
data_source = "simulated"
history = []
current_date = datetime.utcnow()
# Calculate trend factor based on known trends
trend_factor = 1.0
if trend == "up":
trend_factor = 0.92 # Prices were ~8% lower
elif trend == "down":
trend_factor = 1.05 # Prices were ~5% higher
# Generate weekly data points
for i in range(days, -1, -7):
date = current_date - timedelta(days=i)
progress = 1 - (i / days)
if trend == "up":
price = current_price * (trend_factor + (1 - trend_factor) * progress)
elif trend == "down":
price = current_price * (trend_factor - (trend_factor - 1) * progress)
else:
# Add small fluctuation for stable prices
fluctuation = math.sin(i * 0.1) * 0.02
price = current_price * (1 + fluctuation)
history.append({
"date": date.strftime("%Y-%m-%d"),
"price": round(price, 2),
})
# Calculate price changes from simulated data
price_7d_ago = history[-2]["price"] if len(history) >= 2 else current_price
price_30d_ago = history[-5]["price"] if len(history) >= 5 else current_price
price_90d_ago = history[0]["price"] if history else current_price
if h_date <= target_date:
best = float(h["price"])
break
return best
price_7d_ago = _price_at_or_before(7)
price_30d_ago = _price_at_or_before(30)
price_90d_ago = _price_at_or_before(90)
# Calculate percentage changes safely
change_7d = round((current_price - price_7d_ago) / price_7d_ago * 100, 2) if price_7d_ago and price_7d_ago > 0 else 0
@ -1051,8 +1031,8 @@ async def get_data_quality_stats(db: Database):
},
"chart_readiness": {
"tlds_ready_for_charts": chartable_tlds,
"tlds_using_simulation": total_tlds - chartable_tlds,
"recommendation": "Run daily scrapes for 7+ days to enable real charts" if chartable_tlds < 10 else "Good coverage!",
"tlds_with_insufficient_history": total_tlds - chartable_tlds,
"recommendation": "Run daily scrapes for 7+ days to enable richer charts" if chartable_tlds < 10 else "Good coverage!",
},
"data_sources": {
"static_tlds": len(TLD_DATA),

View File

@ -43,13 +43,11 @@ from app.services.intent_detector import (
estimate_domain_yield,
get_intent_detector,
)
from app.services.yield_dns import verify_yield_dns
from app.services.telemetry import track_event
router = APIRouter(prefix="/yield", tags=["yield"])
# DNS Configuration (would be in config in production)
YIELD_NAMESERVERS = ["ns1.pounce.io", "ns2.pounce.io"]
YIELD_CNAME_TARGET = "yield.pounce.io"
# ============================================================================
# Intent Analysis (Public)
@ -124,13 +122,36 @@ async def get_yield_dashboard(
domain_ids = [d.id for d in domains]
monthly_result = await db.execute(
select(
func.count(YieldTransaction.id).label("count"),
func.coalesce(func.sum(YieldTransaction.net_amount), 0).label("revenue"),
func.sum(case((YieldTransaction.event_type == "click", 1), else_=0)).label("clicks"),
func.sum(case((YieldTransaction.event_type.in_(["lead", "sale"]), 1), else_=0)).label("conversions"),
func.coalesce(
func.sum(
case(
(YieldTransaction.status.in_(["confirmed", "paid"]), YieldTransaction.net_amount),
else_=0,
)
),
0,
).label("revenue"),
func.sum(
case(
(YieldTransaction.event_type == "click", 1),
else_=0,
)
).label("clicks"),
func.sum(
case(
(
and_(
YieldTransaction.event_type.in_(["lead", "sale"]),
YieldTransaction.status.in_(["confirmed", "paid"]),
),
1,
),
else_=0,
)
).label("conversions"),
).where(
YieldTransaction.yield_domain_id.in_(domain_ids),
YieldTransaction.created_at >= month_start,
YieldTransaction.created_at >= month_start,
)
)
monthly_stats = monthly_result.first()
@ -185,7 +206,7 @@ async def get_yield_dashboard(
lifetime_clicks=lifetime_clicks,
lifetime_conversions=lifetime_conversions,
pending_payout=pending_payout,
next_payout_date=month_start + timedelta(days=32), # Approx next month
next_payout_date=(month_start + timedelta(days=32)).replace(day=1),
currency="CHF",
)
@ -283,6 +304,7 @@ async def activate_domain_for_yield(
This creates the yield domain record and returns DNS setup instructions.
"""
from app.models.portfolio import PortfolioDomain
from app.models.subscription import Subscription, SubscriptionTier
domain = request.domain.lower().strip()
@ -314,6 +336,30 @@ async def activate_domain_for_yield(
status_code=400,
detail="Cannot activate Yield for a sold domain.",
)
# SECURITY CHECK 4: Tier gating + limits
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 SubscriptionTier.SCOUT
tier_value = tier.value if hasattr(tier, "value") else str(tier)
if tier_value == "scout":
raise HTTPException(
status_code=403,
detail="Yield is not available on Scout plan. Upgrade to Trader or Tycoon.",
)
max_yield_domains = 5 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)
)
).scalar() or 0
if user_domain_count >= max_yield_domains:
raise HTTPException(
status_code=403,
detail=f"Yield domain limit reached for your plan ({max_yield_domains}).",
)
# Check if domain already exists in yield system
existing_result = await db.execute(
@ -364,12 +410,15 @@ async def activate_domain_for_yield(
await db.refresh(yield_domain)
# Create DNS instructions
yield_nameservers = settings.yield_nameserver_list
if not yield_nameservers:
raise HTTPException(status_code=500, detail="Yield nameservers are not configured on server.")
dns_instructions = DNSSetupInstructions(
domain=domain,
nameservers=YIELD_NAMESERVERS,
nameservers=yield_nameservers,
cname_host="@",
cname_target=YIELD_CNAME_TARGET,
verification_url=f"{settings.site_url}/api/v1/yield/verify/{yield_domain.id}",
cname_target=settings.yield_cname_target,
verification_url=f"{settings.site_url}/api/v1/yield/domains/{yield_domain.id}/verify",
)
return ActivateYieldResponse(
@ -417,59 +466,43 @@ async def verify_domain_dns(
if not domain:
raise HTTPException(status_code=404, detail="Yield domain not found")
# Perform DNS check (simplified - in production use dnspython)
verified = False
actual_ns = []
error = None
try:
import dns.resolver
# Check nameservers
try:
answers = dns.resolver.resolve(domain.domain, 'NS')
actual_ns = [str(rr.target).rstrip('.') for rr in answers]
# Check if our nameservers are set
our_ns_set = set(ns.lower() for ns in YIELD_NAMESERVERS)
actual_ns_set = set(ns.lower() for ns in actual_ns)
if our_ns_set.issubset(actual_ns_set):
verified = True
except dns.resolver.NXDOMAIN:
error = "Domain does not exist"
except dns.resolver.NoAnswer:
# Try CNAME instead
try:
cname_answers = dns.resolver.resolve(domain.domain, 'CNAME')
for rr in cname_answers:
if str(rr.target).rstrip('.').lower() == YIELD_CNAME_TARGET.lower():
verified = True
break
except Exception:
error = "No NS or CNAME records found"
except Exception as e:
error = str(e)
except ImportError:
# dnspython not installed - simulate for development
verified = True # Auto-verify in dev
actual_ns = YIELD_NAMESERVERS
# Production-grade DNS check
check = verify_yield_dns(
domain=domain.domain,
expected_nameservers=settings.yield_nameserver_list,
cname_target=settings.yield_cname_target,
)
verified = check.verified
actual_ns = check.actual_ns
error = check.error
# Update domain status
if verified and not domain.dns_verified:
domain.dns_verified = True
domain.dns_verified_at = datetime.utcnow()
domain.connected_at = domain.dns_verified_at
domain.status = "active"
domain.activated_at = datetime.utcnow()
await track_event(
db,
event_name="yield_connected",
request=None,
user_id=current_user.id,
is_authenticated=True,
source="terminal",
domain=domain.domain,
yield_domain_id=domain.id,
metadata={"method": check.method, "cname_ok": check.cname_ok, "actual_ns": check.actual_ns},
)
await db.commit()
return DNSVerificationResult(
domain=domain.domain,
verified=verified,
expected_ns=YIELD_NAMESERVERS,
expected_ns=settings.yield_nameserver_list,
actual_ns=actual_ns,
cname_ok=verified and not actual_ns,
cname_ok=check.cname_ok if verified else False,
error=error,
checked_at=datetime.utcnow(),
)
@ -722,6 +755,7 @@ def _domain_to_response(domain: YieldDomain) -> YieldDomainResponse:
partner_name=domain.partner.name if domain.partner else None,
dns_verified=domain.dns_verified,
dns_verified_at=domain.dns_verified_at,
connected_at=getattr(domain, "connected_at", None),
total_clicks=domain.total_clicks,
total_conversions=domain.total_conversions,
total_revenue=domain.total_revenue,
@ -737,6 +771,7 @@ def _tx_to_response(tx: YieldTransaction) -> YieldTransactionResponse:
id=tx.id,
event_type=tx.event_type,
partner_slug=tx.partner_slug,
click_id=getattr(tx, "click_id", None),
gross_amount=tx.gross_amount,
net_amount=tx.net_amount,
currency=tx.currency,

View File

@ -0,0 +1,188 @@
"""
Admin endpoints for Yield payouts (ledger).
Premium constraints:
- No placeholder payouts
- No currency mixing
- Idempotent generation per (user, currency, period)
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.models.user import User
from app.models.yield_domain import YieldPayout, YieldTransaction
from app.services.telemetry import track_event
from app.services.yield_payouts import generate_payouts_for_period
router = APIRouter(prefix="/yield", tags=["yield-admin"])
class PayoutGenerateRequest(BaseModel):
period_start: datetime
period_end: datetime
class GeneratedPayout(BaseModel):
id: int
user_id: int
amount: Decimal
currency: str
period_start: datetime
period_end: datetime
transaction_count: int
status: str
created_at: datetime
class PayoutGenerateResponse(BaseModel):
created: list[GeneratedPayout]
skipped_existing: int = 0
class PayoutCompleteRequest(BaseModel):
payment_method: str | None = Field(default=None, max_length=50)
payment_reference: str | None = Field(default=None, max_length=200)
class PayoutCompleteResponse(BaseModel):
payout_id: int
transactions_marked_paid: int
completed_at: datetime
def _require_admin(current_user: User) -> None:
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
@router.post("/payouts/generate", response_model=PayoutGenerateResponse)
async def generate_payouts(
payload: PayoutGenerateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Create YieldPayout rows for confirmed, unpaid transactions in the period.
This does NOT mark payouts as completed. It only assigns transactions to a payout via payout_id.
Completion is a separate step once payment is executed.
"""
_require_admin(current_user)
if payload.period_end <= payload.period_start:
raise HTTPException(status_code=400, detail="period_end must be after period_start")
created_count, skipped_existing = await generate_payouts_for_period(
db,
period_start=payload.period_start,
period_end=payload.period_end,
)
payouts = (
await db.execute(
select(YieldPayout)
.where(
and_(
YieldPayout.period_start == payload.period_start,
YieldPayout.period_end == payload.period_end,
)
)
.order_by(YieldPayout.created_at.desc())
)
).scalars().all()
created = [
GeneratedPayout(
id=p.id,
user_id=p.user_id,
amount=p.amount,
currency=p.currency,
period_start=p.period_start,
period_end=p.period_end,
transaction_count=p.transaction_count,
status=p.status,
created_at=p.created_at,
)
for p in payouts
]
# created_count is still returned implicitly via list length; we keep it for logs later
_ = created_count
return PayoutGenerateResponse(created=created, skipped_existing=skipped_existing)
@router.post("/payouts/{payout_id}/complete", response_model=PayoutCompleteResponse)
async def complete_payout(
payout_id: int,
payload: PayoutCompleteRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Mark a payout as completed and mark assigned transactions as paid.
"""
_require_admin(current_user)
payout = (
await db.execute(select(YieldPayout).where(YieldPayout.id == payout_id))
).scalar_one_or_none()
if not payout:
raise HTTPException(status_code=404, detail="Payout not found")
if payout.status == "completed":
raise HTTPException(status_code=400, detail="Payout already completed")
payout.status = "completed"
payout.completed_at = datetime.utcnow()
payout.payment_method = payload.payment_method
payout.payment_reference = payload.payment_reference
txs = (
await db.execute(
select(YieldTransaction).where(YieldTransaction.payout_id == payout.id)
)
).scalars().all()
marked = 0
for tx in txs:
if tx.status != "paid":
tx.status = "paid"
tx.paid_at = payout.completed_at
marked += 1
await track_event(
db,
event_name="payout_paid",
request=None,
user_id=payout.user_id,
is_authenticated=None,
source="admin",
domain=None,
yield_domain_id=None,
metadata={
"payout_id": payout.id,
"currency": payout.currency,
"amount": float(payout.amount),
"transaction_count": payout.transaction_count,
"payment_method": payout.payment_method,
},
)
await db.commit()
return PayoutCompleteResponse(
payout_id=payout.id,
transactions_marked_paid=marked,
completed_at=payout.completed_at,
)

View File

@ -12,17 +12,21 @@ that yield domains CNAME to.
"""
import logging
from datetime import datetime
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, Request, Response, HTTPException
from fastapi.responses import RedirectResponse, HTMLResponse
from sqlalchemy.orm import Session
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db
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
logger = logging.getLogger(__name__)
settings = get_settings()
@ -30,19 +34,49 @@ settings = get_settings()
router = APIRouter(prefix="/r", tags=["yield-routing"])
# Revenue split
USER_REVENUE_SHARE = 0.70
USER_REVENUE_SHARE = Decimal("0.70")
def hash_ip(ip: str) -> str:
"""Hash IP for privacy-compliant storage."""
import hashlib
return hashlib.sha256(ip.encode()).hexdigest()[:32]
# Salt to prevent trivial rainbow table lookups.
return hashlib.sha256(f"{ip}|{settings.secret_key}".encode()).hexdigest()[:32]
def _get_client_ip(request: Request) -> Optional[str]:
# Prefer proxy headers when behind nginx
xff = request.headers.get("x-forwarded-for")
if xff:
# first IP in list
ip = xff.split(",")[0].strip()
if ip:
return ip
cf_ip = request.headers.get("cf-connecting-ip")
if cf_ip:
return cf_ip.strip()
return request.client.host if request.client else None
def _safe_tracking_url(template: str, *, click_id: str, domain: str, domain_id: int, partner: str) -> str:
try:
return template.format(
click_id=click_id,
domain=domain,
domain_id=domain_id,
partner=partner,
)
except KeyError as e:
raise HTTPException(
status_code=500,
detail=f"Partner tracking_url_template uses unsupported placeholder: {str(e)}",
)
def generate_tracking_url(
partner: AffiliatePartner,
yield_domain: YieldDomain,
click_id: int,
click_id: str,
) -> str:
"""
Generate the tracking URL for a partner.
@ -51,403 +85,27 @@ def generate_tracking_url(
- clickid / subid: Our click tracking ID
- ref: Domain name or user reference
"""
# If partner has a tracking URL template, use it
if partner.tracking_url_template:
return partner.tracking_url_template.format(
click_id=click_id,
domain=yield_domain.domain,
domain_id=yield_domain.id,
partner=partner.slug,
if not partner.tracking_url_template:
raise HTTPException(
status_code=503,
detail=f"Partner routing not configured for {partner.slug}. Missing tracking_url_template.",
)
# Default fallbacks by network
network_urls = {
"comparis_dental": f"https://www.comparis.ch/zahnarzt?subid={click_id}&ref={yield_domain.domain}",
"comparis_health": f"https://www.comparis.ch/krankenkassen?subid={click_id}&ref={yield_domain.domain}",
"comparis_insurance": f"https://www.comparis.ch/versicherungen?subid={click_id}&ref={yield_domain.domain}",
"comparis_hypo": f"https://www.comparis.ch/hypotheken?subid={click_id}&ref={yield_domain.domain}",
"comparis_auto": f"https://www.comparis.ch/autoversicherung?subid={click_id}&ref={yield_domain.domain}",
"comparis_immo": f"https://www.comparis.ch/immobilien?subid={click_id}&ref={yield_domain.domain}",
"homegate": f"https://www.homegate.ch/?ref=pounce&clickid={click_id}",
"immoscout": f"https://www.immoscout24.ch/?ref=pounce&clickid={click_id}",
"autoscout": f"https://www.autoscout24.ch/?ref=pounce&clickid={click_id}",
"jobs_ch": f"https://www.jobs.ch/?ref=pounce&clickid={click_id}",
"booking_com": f"https://www.booking.com/?aid=pounce&clickid={click_id}",
"hostpoint": f"https://www.hostpoint.ch/?ref=pounce&clickid={click_id}",
"infomaniak": f"https://www.infomaniak.com/?ref=pounce&clickid={click_id}",
"galaxus": f"https://www.galaxus.ch/?ref=pounce&clickid={click_id}",
"zalando": f"https://www.zalando.ch/?ref=pounce&clickid={click_id}",
}
if partner.slug in network_urls:
return network_urls[partner.slug]
# Pounce self-promotion fallback with referral tracking
# Domain owner gets lifetime commission on signups via their domain
referral_code = f"yield_{yield_domain.user_id}_{yield_domain.id}"
return f"{settings.site_url}/register?ref={referral_code}&from={yield_domain.domain}&clickid={click_id}"
def is_pounce_affinity_domain(domain: str) -> bool:
"""
Check if a domain has high affinity for Pounce self-promotion.
Tech, investment, and domain-related domains convert better for Pounce.
"""
intent = detect_domain_intent(domain)
# Check if the matched category has pounce_affinity flag
if intent.category in ["investment", "tech"] or intent.subcategory in ["domains", "dev"]:
return True
# Check for specific keywords
pounce_keywords = {
"invest", "domain", "trading", "crypto", "asset", "portfolio",
"startup", "tech", "dev", "saas", "digital", "passive", "income"
}
domain_lower = domain.lower()
return any(kw in domain_lower for kw in pounce_keywords)
def generate_pounce_promo_page(
yield_domain: YieldDomain,
click_id: int,
) -> str:
"""
Generate Pounce self-promotion landing page.
Used as fallback when no high-value partner is available,
or when the domain has high Pounce affinity.
"""
referral_code = f"yield_{yield_domain.user_id}_{yield_domain.id}"
register_url = f"{settings.site_url}/register?ref={referral_code}&from={yield_domain.domain}&clickid={click_id}"
return f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{yield_domain.domain} - Powered by Pounce</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%);
color: #fff;
padding: 2rem;
}}
.container {{
text-align: center;
max-width: 600px;
}}
.badge {{
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 9999px;
color: #10b981;
font-size: 0.875rem;
margin-bottom: 2rem;
}}
.domain {{
font-size: 1.25rem;
color: #6b7280;
margin-bottom: 1rem;
}}
h1 {{
font-size: 2.5rem;
font-weight: 700;
line-height: 1.2;
margin-bottom: 1.5rem;
}}
h1 span {{
background: linear-gradient(90deg, #10b981, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}}
.subtitle {{
font-size: 1.125rem;
color: #9ca3af;
margin-bottom: 2.5rem;
line-height: 1.6;
}}
.cta {{
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 2rem;
background: linear-gradient(90deg, #10b981, #059669);
color: #fff;
font-size: 1.125rem;
font-weight: 600;
border-radius: 0.75rem;
text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.3);
}}
.cta:hover {{
transform: translateY(-2px);
box-shadow: 0 6px 30px rgba(16, 185, 129, 0.4);
}}
.features {{
display: flex;
justify-content: center;
gap: 2rem;
margin-top: 3rem;
flex-wrap: wrap;
}}
.feature {{
display: flex;
align-items: center;
gap: 0.5rem;
color: #6b7280;
font-size: 0.875rem;
}}
.feature svg {{
color: #10b981;
}}
.footer {{
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
color: #4b5563;
}}
.footer a {{
color: #10b981;
text-decoration: none;
}}
.owner-note {{
margin-top: 3rem;
padding: 1rem;
background: rgba(139, 92, 246, 0.1);
border: 1px solid rgba(139, 92, 246, 0.2);
border-radius: 0.5rem;
font-size: 0.875rem;
color: #a78bfa;
}}
</style>
</head>
<body>
<div class="container">
<div class="badge">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
This domain is monetized by Pounce
</div>
<div class="domain">{yield_domain.domain}</div>
<h1>
Turn Your Domains Into<br>
<span>Passive Income</span>
</h1>
<p class="subtitle">
Stop paying renewal fees for idle domains.<br>
Let them earn money for you — automatically.
</p>
<a href="{register_url}" class="cta">
Start Earning Free
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</a>
<div class="features">
<div class="feature">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
Free Forever
</div>
<div class="feature">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
70% Revenue Share
</div>
<div class="feature">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
Swiss Quality
</div>
</div>
<div class="owner-note">
👋 The owner of this domain earns a commission when you sign up!
</div>
</div>
<div class="footer">
<a href="{settings.site_url}">pounce.ch</a> — Domain Intelligence Platform
</div>
</body>
</html>
"""
def generate_landing_page(
yield_domain: YieldDomain,
partner: Optional[AffiliatePartner],
click_id: int,
) -> str:
"""
Generate an interstitial landing page.
Shows for a moment before redirecting, to:
1. Improve user experience
2. Allow for A/B testing
3. Comply with affiliate disclosure requirements
If no partner, shows Pounce self-promotion instead.
"""
# If no partner or partner is pounce_promo, show Pounce promo page
if partner is None or partner.slug == "pounce_promo":
return generate_pounce_promo_page(yield_domain, click_id)
intent = detect_domain_intent(yield_domain.domain)
# Partner info
partner_name = partner.name
partner_desc = partner.description or "Find the best offers"
# Generate redirect URL
redirect_url = generate_tracking_url(partner, yield_domain, click_id)
return f"""
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="3;url={redirect_url}">
<title>{yield_domain.domain} - Redirecting</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%);
color: #fff;
}}
.container {{
text-align: center;
padding: 2rem;
max-width: 500px;
}}
.domain {{
font-size: 1.5rem;
font-weight: 700;
color: #10b981;
margin-bottom: 0.5rem;
}}
.intent {{
font-size: 0.875rem;
color: #6b7280;
text-transform: capitalize;
margin-bottom: 2rem;
}}
.message {{
font-size: 1.125rem;
color: #d1d5db;
margin-bottom: 1rem;
}}
.partner {{
font-size: 0.875rem;
color: #9ca3af;
margin-bottom: 2rem;
}}
.spinner {{
width: 40px;
height: 40px;
border: 3px solid #1f2937;
border-top-color: #10b981;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1.5rem;
}}
@keyframes spin {{
to {{ transform: rotate(360deg); }}
}}
.link {{
color: #10b981;
text-decoration: none;
}}
.link:hover {{
text-decoration: underline;
}}
.footer {{
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
color: #4b5563;
}}
.footer a {{
color: #6b7280;
text-decoration: none;
}}
</style>
</head>
<body>
<div class="container">
<div class="domain">{yield_domain.domain}</div>
<div class="intent">{intent.category.replace('_', ' ')}</div>
<div class="spinner"></div>
<div class="message">Redirecting to {partner_name}...</div>
<div class="partner">{partner_desc}</div>
<p>
<a href="{redirect_url}" class="link">Click here if not redirected</a>
</p>
</div>
<div class="footer">
Monetized by <a href="{settings.site_url}/yield?ref={yield_domain.domain}">Pounce</a> •
Own a domain? <a href="{settings.site_url}/yield?ref={yield_domain.domain}">Start yielding →</a>
</div>
<script>
// Redirect after 2 seconds
setTimeout(function() {{
window.location.href = "{redirect_url}";
}}, 2000);
</script>
</body>
</html>
"""
return _safe_tracking_url(
partner.tracking_url_template,
click_id=click_id,
domain=yield_domain.domain,
domain_id=yield_domain.id,
partner=partner.slug,
)
@router.get("/{domain}")
async def route_yield_domain(
domain: str,
request: Request,
db: Session = Depends(get_db),
direct: bool = False, # Skip landing page if true
db: AsyncSession = Depends(get_db),
direct: bool = Query(True, description="Direct redirect without landing page"),
):
"""
Route traffic for a yield domain.
@ -458,86 +116,133 @@ async def route_yield_domain(
- direct: If true, redirect immediately without landing page
"""
domain = domain.lower().strip()
# Find yield domain
yield_domain = db.query(YieldDomain).filter(
YieldDomain.domain == domain,
YieldDomain.status == "active",
).first()
if not yield_domain:
# Domain not found or not active - show error page
logger.warning(f"Route request for unknown/inactive domain: {domain}")
return HTMLResponse(
content=f"""
<html>
<head><title>Domain Not Active</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>Domain Not Active</h1>
<p>The domain <strong>{domain}</strong> is not currently active for yield routing.</p>
<p><a href="{settings.site_url}">Visit Pounce</a></p>
</body>
</html>
""",
status_code=404
# Find yield domain (must be connected + active)
yield_domain = (
await db.execute(
select(YieldDomain).where(
and_(
YieldDomain.domain == domain,
YieldDomain.status == "active",
YieldDomain.dns_verified == True,
or_(YieldDomain.connected_at.is_not(None), YieldDomain.dns_verified_at.is_not(None)),
)
)
)
# Get partner
partner = None
).scalar_one_or_none()
if not yield_domain:
logger.warning(f"Route request for unknown/inactive/unconnected domain: {domain}")
raise HTTPException(status_code=404, detail="Domain not active for yield routing.")
# Resolve partner
partner: Optional[AffiliatePartner] = None
if yield_domain.partner_id:
partner = db.query(AffiliatePartner).filter(
AffiliatePartner.id == yield_domain.partner_id,
AffiliatePartner.is_active == True,
).first()
# If no partner assigned, try to find one based on intent
partner = (
await db.execute(
select(AffiliatePartner).where(
and_(
AffiliatePartner.id == yield_domain.partner_id,
AffiliatePartner.is_active == True,
)
)
)
).scalar_one_or_none()
if not partner and yield_domain.detected_intent:
partner = db.query(AffiliatePartner).filter(
AffiliatePartner.intent_categories.contains(yield_domain.detected_intent.split('_')[0]),
AffiliatePartner.is_active == True,
).order_by(AffiliatePartner.priority.desc()).first()
# Create click transaction
client_ip = request.client.host if request.client else None
# Match full detected intent first (e.g. medical_dental)
partner = (
await db.execute(
select(AffiliatePartner)
.where(
and_(
AffiliatePartner.is_active == True,
AffiliatePartner.intent_categories.ilike(f"%{yield_domain.detected_intent}%"),
)
)
.order_by(AffiliatePartner.priority.desc())
)
).scalar_one_or_none()
if not partner:
raise HTTPException(status_code=503, detail="No active partner available for this domain intent.")
# 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
if ip_hash:
cutoff = datetime.utcnow() - timedelta(minutes=10)
recent = (
await db.execute(
select(func.count(YieldTransaction.id)).where(
and_(
YieldTransaction.yield_domain_id == yield_domain.id,
YieldTransaction.event_type == "click",
YieldTransaction.ip_hash == ip_hash,
YieldTransaction.created_at >= cutoff,
)
)
)
).scalar() or 0
if recent >= 120:
raise HTTPException(status_code=429, detail="Too many requests. Please slow down.")
# Compute click economics (only CPC can be accounted immediately)
gross = Decimal("0")
net = Decimal("0")
currency = (partner.payout_currency or "CHF").upper()
if (partner.payout_type or "").lower() == "cpc":
gross = partner.payout_amount or Decimal("0")
net = (gross * USER_REVENUE_SHARE).quantize(Decimal("0.01"))
click_id = uuid4().hex
destination_url = generate_tracking_url(partner, yield_domain, click_id)
user_agent = request.headers.get("user-agent")
referrer = request.headers.get("referer")
geo_country = request.headers.get("cf-ipcountry") or request.headers.get("x-country")
geo_country = geo_country.strip().upper() if geo_country else None
transaction = YieldTransaction(
yield_domain_id=yield_domain.id,
event_type="click",
partner_slug=partner.slug if partner else "unknown",
gross_amount=0,
net_amount=0,
currency="CHF",
referrer=referrer,
partner_slug=partner.slug,
click_id=click_id,
destination_url=destination_url[:2000],
gross_amount=gross,
net_amount=net,
currency=currency,
referrer=referrer[:500] if referrer else None,
user_agent=user_agent[:500] if user_agent else None,
ip_hash=hash_ip(client_ip) if client_ip else None,
geo_country=geo_country[:2] if geo_country else None,
ip_hash=ip_hash,
status="confirmed",
confirmed_at=datetime.utcnow(),
)
db.add(transaction)
# Update domain stats
yield_domain.total_clicks += 1
yield_domain.last_click_at = datetime.utcnow()
db.commit()
db.refresh(transaction)
# Generate redirect URL
redirect_url = (
generate_tracking_url(partner, yield_domain, transaction.id)
if partner else f"{settings.site_url}/buy?ref={domain}"
if net > 0:
yield_domain.total_revenue += net
await track_event(
db,
event_name="yield_click",
request=request,
user_id=yield_domain.user_id,
is_authenticated=None,
source="routing",
domain=yield_domain.domain,
yield_domain_id=yield_domain.id,
click_id=click_id,
metadata={"partner": partner.slug, "currency": currency, "net_amount": float(net)},
)
# Direct redirect or show landing page
if direct:
return RedirectResponse(url=redirect_url, status_code=302)
# Show interstitial landing page
html = generate_landing_page(yield_domain, partner, transaction.id)
return HTMLResponse(content=html)
await db.commit()
# Only direct redirect for MVP
return RedirectResponse(url=destination_url, status_code=302)
@router.get("/")
@ -545,7 +250,7 @@ async def yield_routing_info():
"""Info endpoint for yield routing service."""
return {
"service": "Pounce Yield Routing",
"version": "1.0.0",
"version": "2.0.0",
"docs": f"{settings.site_url}/docs#/yield-routing",
"status": "active",
}
@ -558,7 +263,7 @@ async def yield_routing_info():
@router.api_route("/catch-all", methods=["GET", "HEAD"])
async def catch_all_route(
request: Request,
db: Session = Depends(get_db),
db: AsyncSession = Depends(get_db),
):
"""
Catch-all route for host-based routing.
@ -582,21 +287,22 @@ async def catch_all_route(
if any(host.endswith(d) for d in our_domains):
return {"status": "not a yield domain", "host": host}
# Look up yield domain
yield_domain = db.query(YieldDomain).filter(
YieldDomain.domain == host,
YieldDomain.status == "active",
).first()
if not yield_domain:
return HTMLResponse(
content=f"<h1>Domain {host} not configured</h1>",
status_code=404
# If host matches a connected yield domain, route it
_ = (
await db.execute(
select(YieldDomain.id).where(
and_(
YieldDomain.domain == host,
YieldDomain.status == "active",
YieldDomain.dns_verified == True,
or_(YieldDomain.connected_at.is_not(None), YieldDomain.dns_verified_at.is_not(None)),
)
)
)
# Redirect to routing endpoint
return RedirectResponse(
url=f"/api/v1/r/{host}",
status_code=302
)
).scalar_one_or_none()
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)

View File

@ -20,14 +20,15 @@ from datetime import datetime
from decimal import Decimal
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Header, BackgroundTasks
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db
from app.config import get_settings
from app.models.yield_domain import YieldDomain, YieldTransaction, AffiliatePartner
from app.services.telemetry import track_event
logger = logging.getLogger(__name__)
settings = get_settings()
@ -47,6 +48,7 @@ class PartnerEvent(BaseModel):
event_type: str = Field(..., description="click, lead, or sale")
domain: str = Field(..., description="The yield domain that generated this event")
transaction_id: Optional[str] = Field(None, description="Partner's transaction ID")
click_id: Optional[str] = Field(None, description="Pounce click_id for attribution (UUID hex)")
amount: Optional[float] = Field(None, description="Gross commission amount")
currency: Optional[str] = Field("CHF", description="Currency code")
@ -88,7 +90,21 @@ def verify_hmac_signature(
def hash_ip(ip: str) -> str:
"""Hash IP address for privacy-compliant storage."""
return hashlib.sha256(ip.encode()).hexdigest()[:32]
return hashlib.sha256(f"{ip}|{settings.secret_key}".encode()).hexdigest()[:32]
def _get_webhook_secret(partner_slug: str) -> Optional[str]:
"""
Webhook secrets are configured via environment:
- YIELD_WEBHOOK_SECRET (global default)
- YIELD_WEBHOOK_SECRET_<PARTNER_SLUG_UPPER> (partner-specific override)
"""
import os
specific = os.getenv(f"YIELD_WEBHOOK_SECRET_{partner_slug.upper()}")
if specific:
return specific
return os.getenv("YIELD_WEBHOOK_SECRET") or None
# ============================================================================
@ -101,7 +117,7 @@ async def receive_partner_webhook(
event: PartnerEvent,
request: Request,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
db: AsyncSession = Depends(get_db),
x_webhook_signature: Optional[str] = Header(None),
x_api_key: Optional[str] = Header(None),
):
@ -111,25 +127,42 @@ async def receive_partner_webhook(
Partners POST events here when clicks, leads, or sales occur.
"""
# 1. Find partner
partner = db.query(AffiliatePartner).filter(
AffiliatePartner.slug == partner_slug,
AffiliatePartner.is_active == True,
).first()
partner = (
await db.execute(
select(AffiliatePartner).where(
and_(
AffiliatePartner.slug == partner_slug,
AffiliatePartner.is_active == True,
)
)
)
).scalar_one_or_none()
if not partner:
logger.warning(f"Webhook from unknown partner: {partner_slug}")
raise HTTPException(status_code=404, detail="Unknown partner")
# 2. Verify authentication (if configured)
# Note: In production, store partner API keys in a secure location
# For now, we accept webhooks if the partner exists
# TODO: Add proper signature verification per partner
# 2. Verify authentication (strict)
secret = _get_webhook_secret(partner_slug)
if not secret:
raise HTTPException(status_code=503, detail="Webhook secret not configured on server.")
if not x_webhook_signature:
raise HTTPException(status_code=401, detail="Missing webhook signature.")
raw = await request.body()
if not verify_hmac_signature(raw, x_webhook_signature, secret):
raise HTTPException(status_code=401, detail="Invalid webhook signature.")
# 3. Find yield domain
yield_domain = db.query(YieldDomain).filter(
YieldDomain.domain == event.domain.lower(),
YieldDomain.status == "active",
).first()
# 3. Find yield domain (must be active)
yield_domain = (
await db.execute(
select(YieldDomain).where(
and_(
YieldDomain.domain == event.domain.lower(),
YieldDomain.status == "active",
)
)
)
).scalar_one_or_none()
if not yield_domain:
logger.warning(f"Webhook for unknown/inactive domain: {event.domain}")
@ -149,6 +182,7 @@ async def receive_partner_webhook(
event_type=event.event_type,
partner_slug=partner_slug,
partner_transaction_id=event.transaction_id,
click_id=(event.click_id[:64] if event.click_id else None),
gross_amount=gross_amount,
net_amount=net_amount,
currency=event.currency or "CHF",
@ -161,6 +195,25 @@ async def receive_partner_webhook(
)
db.add(transaction)
# Optional: attribute to an existing click transaction (same yield_domain + click_id)
if event.click_id:
click_tx = (
await db.execute(
select(YieldTransaction).where(
and_(
YieldTransaction.yield_domain_id == yield_domain.id,
YieldTransaction.event_type == "click",
YieldTransaction.click_id == event.click_id[:64],
)
)
)
).scalar_one_or_none()
if not click_tx:
logger.warning(
f"Webhook received click_id but no matching click found: partner={partner_slug} "
f"domain={yield_domain.domain} click_id={event.click_id[:64]}"
)
# 7. Update domain aggregates
if event.event_type == "click":
@ -172,9 +225,29 @@ async def receive_partner_webhook(
# Add revenue when confirmed
if transaction.status == "confirmed":
yield_domain.total_revenue += net_amount
await track_event(
db,
event_name="yield_conversion",
request=request,
user_id=yield_domain.user_id,
is_authenticated=None,
source="webhook",
domain=yield_domain.domain,
yield_domain_id=yield_domain.id,
click_id=event.click_id,
metadata={
"partner": partner_slug,
"event_type": event.event_type,
"status": transaction.status,
"currency": transaction.currency,
"net_amount": float(net_amount),
"partner_transaction_id": event.transaction_id,
},
)
db.commit()
db.refresh(transaction)
await db.commit()
await db.refresh(transaction)
logger.info(
f"Webhook processed: {partner_slug} -> {event.domain} "
@ -206,7 +279,7 @@ class AwinEvent(BaseModel):
async def receive_awin_postback(
event: AwinEvent,
request: Request,
db: Session = Depends(get_db),
db: AsyncSession = Depends(get_db),
x_awin_signature: Optional[str] = Header(None),
):
"""
@ -214,18 +287,28 @@ async def receive_awin_postback(
Awin sends postbacks for tracked conversions.
"""
# Verify authentication (strict)
secret = _get_webhook_secret("awin")
if not secret:
raise HTTPException(status_code=503, detail="Webhook secret not configured on server.")
if not x_awin_signature:
raise HTTPException(status_code=401, detail="Missing webhook signature.")
raw = await request.body()
if not verify_hmac_signature(raw, x_awin_signature, secret):
raise HTTPException(status_code=401, detail="Invalid webhook signature.")
# Find domain by click reference
yield_domain = db.query(YieldDomain).filter(
YieldDomain.domain == event.clickRef.lower(),
).first()
yield_domain = (
await db.execute(select(YieldDomain).where(YieldDomain.domain == event.clickRef.lower()))
).scalar_one_or_none()
if not yield_domain:
# Try to find by ID if clickRef is numeric
try:
domain_id = int(event.clickRef)
yield_domain = db.query(YieldDomain).filter(
YieldDomain.id == domain_id,
).first()
yield_domain = (
await db.execute(select(YieldDomain).where(YieldDomain.id == domain_id))
).scalar_one_or_none()
except ValueError:
pass
@ -246,10 +329,16 @@ async def receive_awin_postback(
status = status_map.get(event.status.lower(), "pending")
# Create or update transaction
existing_tx = db.query(YieldTransaction).filter(
YieldTransaction.partner_transaction_id == event.transactionId,
YieldTransaction.partner_slug.like("awin%"),
).first()
existing_tx = (
await db.execute(
select(YieldTransaction).where(
and_(
YieldTransaction.partner_transaction_id == event.transactionId,
YieldTransaction.partner_slug.ilike("awin%"),
)
)
)
).scalar_one_or_none()
if existing_tx:
# Update existing transaction
@ -279,10 +368,10 @@ async def receive_awin_postback(
if status == "confirmed":
yield_domain.total_revenue += net_amount
db.flush()
await db.flush()
transaction_id = transaction.id
db.commit()
await db.commit()
logger.info(f"Awin postback processed: {event.transactionId} -> {status}")
@ -300,7 +389,7 @@ async def receive_awin_postback(
@router.post("/confirm/{transaction_id}", response_model=WebhookResponse)
async def confirm_transaction(
transaction_id: int,
db: Session = Depends(get_db),
db: AsyncSession = Depends(get_db),
x_internal_key: Optional[str] = Header(None),
):
"""
@ -308,15 +397,22 @@ async def confirm_transaction(
Internal endpoint for admin use or automated confirmation.
"""
# Basic auth check - in production, use proper admin auth
internal_key = getattr(settings, 'internal_api_key', None) or settings.secret_key
internal_key = (settings.internal_api_key or "").strip()
if not internal_key:
raise HTTPException(status_code=503, detail="internal_api_key is not configured on server.")
if x_internal_key != internal_key:
raise HTTPException(status_code=401, detail="Unauthorized")
transaction = db.query(YieldTransaction).filter(
YieldTransaction.id == transaction_id,
YieldTransaction.status == "pending",
).first()
transaction = (
await db.execute(
select(YieldTransaction).where(
and_(
YieldTransaction.id == transaction_id,
YieldTransaction.status == "pending",
)
)
)
).scalar_one_or_none()
if not transaction:
raise HTTPException(status_code=404, detail="Transaction not found or not pending")
@ -326,14 +422,14 @@ async def confirm_transaction(
transaction.confirmed_at = datetime.utcnow()
# Update domain revenue
yield_domain = db.query(YieldDomain).filter(
YieldDomain.id == transaction.yield_domain_id
).first()
yield_domain = (
await db.execute(select(YieldDomain).where(YieldDomain.id == transaction.yield_domain_id))
).scalar_one_or_none()
if yield_domain:
yield_domain.total_revenue += transaction.net_amount
db.commit()
await db.commit()
logger.info(f"Transaction {transaction_id} confirmed manually")
@ -354,6 +450,7 @@ class BatchTransactionItem(BaseModel):
event_type: str
partner_slug: str
transaction_id: str
click_id: Optional[str] = None
gross_amount: float
currency: str = "CHF"
status: str = "confirmed"
@ -376,7 +473,7 @@ class BatchImportResponse(BaseModel):
@router.post("/batch-import", response_model=BatchImportResponse)
async def batch_import_transactions(
request_data: BatchImportRequest,
db: Session = Depends(get_db),
db: AsyncSession = Depends(get_db),
x_internal_key: Optional[str] = Header(None),
):
"""
@ -384,7 +481,9 @@ async def batch_import_transactions(
Internal endpoint for importing partner reports.
"""
internal_key = getattr(settings, 'internal_api_key', None) or settings.secret_key
internal_key = (settings.internal_api_key or "").strip()
if not internal_key:
raise HTTPException(status_code=503, detail="internal_api_key is not configured on server.")
if x_internal_key != internal_key:
raise HTTPException(status_code=401, detail="Unauthorized")
@ -395,9 +494,9 @@ async def batch_import_transactions(
for item in request_data.transactions:
try:
# Find domain
yield_domain = db.query(YieldDomain).filter(
YieldDomain.domain == item.domain.lower(),
).first()
yield_domain = (
await db.execute(select(YieldDomain).where(YieldDomain.domain == item.domain.lower()))
).scalar_one_or_none()
if not yield_domain:
errors.append(f"Domain not found: {item.domain}")
@ -405,10 +504,16 @@ async def batch_import_transactions(
continue
# Check for duplicate
existing = db.query(YieldTransaction).filter(
YieldTransaction.partner_transaction_id == item.transaction_id,
YieldTransaction.partner_slug == item.partner_slug,
).first()
existing = (
await db.execute(
select(YieldTransaction).where(
and_(
YieldTransaction.partner_transaction_id == item.transaction_id,
YieldTransaction.partner_slug == item.partner_slug,
)
)
)
).scalar_one_or_none()
if existing:
skipped += 1
@ -423,6 +528,7 @@ async def batch_import_transactions(
event_type=item.event_type,
partner_slug=item.partner_slug,
partner_transaction_id=item.transaction_id,
click_id=(item.click_id[:64] if item.click_id else None),
gross_amount=gross,
net_amount=net,
currency=item.currency,
@ -446,7 +552,7 @@ async def batch_import_transactions(
errors.append(f"Error importing {item.domain}/{item.transaction_id}: {str(e)}")
skipped += 1
db.commit()
await db.commit()
return BatchImportResponse(
success=len(errors) == 0,

View File

@ -18,6 +18,10 @@ class Settings(BaseSettings):
app_name: str = "DomainWatch"
debug: bool = True
site_url: str = "https://pounce.ch" # Base URL for links in emails/API responses
# Internal admin operations (server-to-server / cron)
# MUST be set in production; used for protected internal endpoints.
internal_api_key: str = ""
# Email Settings (optional)
smtp_host: str = ""
@ -43,10 +47,50 @@ class Settings(BaseSettings):
enable_metrics: bool = True
metrics_path: str = "/metrics"
enable_db_query_metrics: bool = False
enable_business_metrics: bool = True
business_metrics_days: int = 30
business_metrics_cache_seconds: int = 60
# Ops / Backups (4B)
enable_db_backups: bool = False
backup_dir: str = "backups"
backup_retention_days: int = 14
# Ops / Alerting (4B) - no Docker required
ops_alerts_enabled: bool = False
ops_alert_recipients: str = "" # comma-separated emails; if empty -> CONTACT_EMAIL env fallback
ops_alert_cooldown_minutes: int = 180
ops_alert_backup_stale_seconds: int = 93600 # ~26h
# Rate limiting storage (SlowAPI / limits). Use Redis in production.
rate_limit_storage_uri: str = "memory://"
# =================================
# Referral rewards / Anti-fraud (3C.2)
# =================================
referral_rewards_enabled: bool = True
referral_rewards_cooldown_days: int = 7
referral_rewards_ip_window_days: int = 30
referral_rewards_require_ip_hash: bool = True
# =================================
# 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"
# CNAME/ALIAS target for simpler DNS setup (provider-dependent).
# Example: "yield.pounce.io"
yield_cname_target: str = "yield.pounce.io"
@property
def yield_nameserver_list(self) -> list[str]:
return [
ns.strip().lower()
for ns in (self.yield_nameservers or "").split(",")
if ns.strip()
]
# Database pooling (PostgreSQL)
db_pool_size: int = 5
db_max_overflow: int = 10

View File

@ -120,12 +120,100 @@ async def apply_migrations(conn: AsyncConnection) -> None:
# 4) domain_listings pounce_score index (market sorting)
# ----------------------------------------------------
if await _table_exists(conn, "domain_listings"):
if not await _has_column(conn, "domain_listings", "sold_at"):
logger.info("DB migrations: adding column domain_listings.sold_at")
await conn.execute(text("ALTER TABLE domain_listings ADD COLUMN sold_at DATETIME"))
if not await _has_column(conn, "domain_listings", "sold_reason"):
logger.info("DB migrations: adding column domain_listings.sold_reason")
await conn.execute(text("ALTER TABLE domain_listings ADD COLUMN sold_reason VARCHAR(200)"))
if not await _has_column(conn, "domain_listings", "sold_price"):
logger.info("DB migrations: adding column domain_listings.sold_price")
await conn.execute(text("ALTER TABLE domain_listings ADD COLUMN sold_price FLOAT"))
if not await _has_column(conn, "domain_listings", "sold_currency"):
logger.info("DB migrations: adding column domain_listings.sold_currency")
await conn.execute(text("ALTER TABLE domain_listings ADD COLUMN sold_currency VARCHAR(3)"))
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_domain_listings_pounce_score "
"ON domain_listings(pounce_score)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_domain_listings_status "
"ON domain_listings(status)"
)
)
# ----------------------------------------------------
# 4b) listing_inquiries: deal workflow + audit trail
# ----------------------------------------------------
if await _table_exists(conn, "listing_inquiries"):
if not await _has_column(conn, "listing_inquiries", "buyer_user_id"):
logger.info("DB migrations: adding column listing_inquiries.buyer_user_id")
await conn.execute(text("ALTER TABLE listing_inquiries ADD COLUMN buyer_user_id INTEGER"))
if not await _has_column(conn, "listing_inquiries", "closed_at"):
logger.info("DB migrations: adding column listing_inquiries.closed_at")
await conn.execute(text("ALTER TABLE listing_inquiries ADD COLUMN closed_at DATETIME"))
if not await _has_column(conn, "listing_inquiries", "closed_reason"):
logger.info("DB migrations: adding column listing_inquiries.closed_reason")
await conn.execute(text("ALTER TABLE listing_inquiries ADD COLUMN closed_reason VARCHAR(200)"))
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_listing_inquiries_listing_created "
"ON listing_inquiries(listing_id, created_at)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_listing_inquiries_listing_status "
"ON listing_inquiries(listing_id, status)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_listing_inquiries_buyer_user "
"ON listing_inquiries(buyer_user_id)"
)
)
# The table itself is created by `Base.metadata.create_all()` on startup.
# Here we only add indexes (idempotent) for existing DBs.
if await _table_exists(conn, "listing_inquiry_events"):
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_listing_inquiry_events_inquiry_created "
"ON listing_inquiry_events(inquiry_id, created_at)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_listing_inquiry_events_listing_created "
"ON listing_inquiry_events(listing_id, created_at)"
)
)
if await _table_exists(conn, "listing_inquiry_messages"):
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_listing_inquiry_messages_inquiry_created "
"ON listing_inquiry_messages(inquiry_id, created_at)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_listing_inquiry_messages_listing_created "
"ON listing_inquiry_messages(listing_id, created_at)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_listing_inquiry_messages_sender_created "
"ON listing_inquiry_messages(sender_user_id, created_at)"
)
)
# ----------------------------------------------------
# 5) Yield tables indexes
@ -144,7 +232,18 @@ async def apply_migrations(conn: AsyncConnection) -> None:
)
)
if not await _has_column(conn, "yield_domains", "connected_at"):
logger.info("DB migrations: adding column yield_domains.connected_at")
await conn.execute(text("ALTER TABLE yield_domains ADD COLUMN connected_at DATETIME"))
if await _table_exists(conn, "yield_transactions"):
if not await _has_column(conn, "yield_transactions", "click_id"):
logger.info("DB migrations: adding column yield_transactions.click_id")
await conn.execute(text("ALTER TABLE yield_transactions ADD COLUMN click_id VARCHAR(64)"))
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_yield_transactions_click_id ON yield_transactions(click_id)"))
if not await _has_column(conn, "yield_transactions", "destination_url"):
logger.info("DB migrations: adding column yield_transactions.destination_url")
await conn.execute(text("ALTER TABLE yield_transactions ADD COLUMN destination_url TEXT"))
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_yield_tx_domain_created "
@ -167,7 +266,68 @@ async def apply_migrations(conn: AsyncConnection) -> None:
)
# ----------------------------------------------------
# 6) User referral tracking columns
# 6) Referral rewards: subscriptions.referral_bonus_domains (3C.2)
# ----------------------------------------------------
if await _table_exists(conn, "subscriptions"):
if not await _has_column(conn, "subscriptions", "referral_bonus_domains"):
logger.info("DB migrations: adding column subscriptions.referral_bonus_domains")
await conn.execute(
text(
"ALTER TABLE subscriptions "
"ADD COLUMN referral_bonus_domains INTEGER NOT NULL DEFAULT 0"
)
)
# ----------------------------------------------------
# 6) Telemetry events indexes
# ----------------------------------------------------
if await _table_exists(conn, "telemetry_events"):
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_telemetry_event_name_created "
"ON telemetry_events(event_name, created_at)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_telemetry_user_created "
"ON telemetry_events(user_id, created_at)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_telemetry_listing_created "
"ON telemetry_events(listing_id, created_at)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_telemetry_yield_created "
"ON telemetry_events(yield_domain_id, created_at)"
)
)
# ----------------------------------------------------
# 6b) Ops alert events (persisted cooldown + history)
# ----------------------------------------------------
# NOTE: Table is created by Base.metadata.create_all() for new installs.
# Here we ensure indexes exist for older DBs.
if await _table_exists(conn, "ops_alert_events"):
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_ops_alert_key_created "
"ON ops_alert_events(alert_key, created_at)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_ops_alert_status_created "
"ON ops_alert_events(status, created_at)"
)
)
# ----------------------------------------------------
# 7) User referral tracking columns
# ----------------------------------------------------
if await _table_exists(conn, "users"):
if not await _has_column(conn, "users", "referred_by_user_id"):
@ -179,6 +339,12 @@ async def apply_migrations(conn: AsyncConnection) -> None:
if not await _has_column(conn, "users", "referral_code"):
logger.info("DB migrations: adding column users.referral_code")
await conn.execute(text("ALTER TABLE users ADD COLUMN referral_code VARCHAR(100)"))
if not await _has_column(conn, "users", "invite_code"):
logger.info("DB migrations: adding column users.invite_code")
await conn.execute(text("ALTER TABLE users ADD COLUMN invite_code VARCHAR(32)"))
# Unique index for invite_code (SQLite + Postgres)
await conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_users_invite_code ON users(invite_code)"))
# ----------------------------------------------------
# 7) Portfolio DNS verification columns

View File

@ -13,6 +13,8 @@ from app.models.listing import DomainListing, ListingInquiry, ListingView
from app.models.sniper_alert import SniperAlert, SniperAlertMatch
from app.models.seo_data import DomainSEOData
from app.models.yield_domain import YieldDomain, YieldTransaction, YieldPayout, AffiliatePartner
from app.models.telemetry import TelemetryEvent
from app.models.ops_alert import OpsAlertEvent
__all__ = [
"User",
@ -43,4 +45,7 @@ __all__ = [
"YieldTransaction",
"YieldPayout",
"AffiliatePartner",
# New: Telemetry (events)
"TelemetryEvent",
"OpsAlertEvent",
]

View File

@ -91,6 +91,10 @@ class DomainListing(Base):
# Status
status: Mapped[str] = mapped_column(String(30), default=ListingStatus.DRAFT.value, index=True)
sold_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
sold_reason: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
sold_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
sold_currency: Mapped[Optional[str]] = mapped_column(String(3), nullable=True)
# Features
show_valuation: Mapped[bool] = mapped_column(Boolean, default=True)
@ -147,6 +151,7 @@ class ListingInquiry(Base):
id: Mapped[int] = mapped_column(primary_key=True, index=True)
listing_id: Mapped[int] = mapped_column(ForeignKey("domain_listings.id"), index=True, nullable=False)
buyer_user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), index=True, nullable=True)
# Inquirer info
name: Mapped[str] = mapped_column(String(100), nullable=False)
@ -159,7 +164,8 @@ class ListingInquiry(Base):
offer_amount: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
# Status
status: Mapped[str] = mapped_column(String(20), default="new") # new, read, replied, spam
status: Mapped[str] = mapped_column(String(20), default="new") # new, read, replied, closed, spam
closed_reason: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
# Tracking
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
@ -169,14 +175,72 @@ class ListingInquiry(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
replied_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Relationships
listing: Mapped["DomainListing"] = relationship("DomainListing", back_populates="inquiries")
messages: Mapped[List["ListingInquiryMessage"]] = relationship(
"ListingInquiryMessage", back_populates="inquiry", cascade="all, delete-orphan"
)
events: Mapped[List["ListingInquiryEvent"]] = relationship(
"ListingInquiryEvent", back_populates="inquiry", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<ListingInquiry from {self.email} for listing #{self.listing_id}>"
class ListingInquiryEvent(Base):
"""
Audit trail for inquiry status changes.
This is the minimal “deal system” log:
- who changed what status
- when it happened
- optional reason (close/spam)
"""
__tablename__ = "listing_inquiry_events"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
inquiry_id: Mapped[int] = mapped_column(ForeignKey("listing_inquiries.id"), index=True, nullable=False)
listing_id: Mapped[int] = mapped_column(ForeignKey("domain_listings.id"), index=True, nullable=False)
actor_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
old_status: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
new_status: Mapped[str] = mapped_column(String(20), nullable=False)
reason: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
inquiry: Mapped["ListingInquiry"] = relationship("ListingInquiry", back_populates="events")
class ListingInquiryMessage(Base):
"""
Thread messages for listing inquiries (in-product negotiation).
- Buyer sends messages from their account
- Seller replies from Terminal
"""
__tablename__ = "listing_inquiry_messages"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
inquiry_id: Mapped[int] = mapped_column(ForeignKey("listing_inquiries.id"), index=True, nullable=False)
listing_id: Mapped[int] = mapped_column(ForeignKey("domain_listings.id"), index=True, nullable=False)
sender_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
inquiry: Mapped["ListingInquiry"] = relationship("ListingInquiry", back_populates="messages")
class ListingView(Base):
"""
Track listing page views for analytics.

View File

@ -0,0 +1,40 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class OpsAlertEvent(Base):
"""
Persisted ops alert events.
Used for:
- cooldown across process restarts
- audit/history in admin UI
"""
__tablename__ = "ops_alert_events"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
alert_key: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
severity: Mapped[str] = mapped_column(String(10), nullable=False, index=True) # "warn" | "page"
title: Mapped[str] = mapped_column(String(200), nullable=False)
detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# "sent" | "skipped" | "error"
status: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
recipients: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # comma-separated
send_reason: Mapped[Optional[str]] = mapped_column(String(60), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
__table_args__ = (
Index("ix_ops_alert_key_created", "alert_key", "created_at"),
Index("ix_ops_alert_status_created", "status", "created_at"),
)

View File

@ -123,6 +123,8 @@ class Subscription(Base):
# Limits (can be overridden)
max_domains: Mapped[int] = mapped_column(Integer, default=5)
# Referral reward bonus (3C.2): additive, computed deterministically from qualified referrals
referral_bonus_domains: Mapped[int] = mapped_column(Integer, default=0)
check_frequency: Mapped[str] = mapped_column(String(50), default="daily")
# Stripe integration
@ -167,7 +169,9 @@ class Subscription(Base):
@property
def domain_limit(self) -> int:
"""Get maximum allowed domains for this subscription."""
return self.max_domains or self.config["domain_limit"]
base = int(self.max_domains or self.config["domain_limit"] or 0)
bonus = int(self.referral_bonus_domains or 0)
return max(0, base + bonus)
@property
def portfolio_limit(self) -> int:

View File

@ -0,0 +1,56 @@
"""
Telemetry events (4A).
Store canonical product events for funnel KPIs:
- Deal funnel: listing_view → inquiry_created → message_sent → listing_marked_sold
- Yield funnel: yield_connected → yield_click → yield_conversion → payout_paid
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class TelemetryEvent(Base):
__tablename__ = "telemetry_events"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
# Who
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
# What
event_name: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
# Entity links (optional)
listing_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
inquiry_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
yield_domain_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
click_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
domain: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
# Context
source: Mapped[Optional[str]] = mapped_column(String(30), nullable=True) # "public" | "terminal" | "webhook" | "scheduler" | "admin"
ip_hash: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
referrer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
metadata_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON string
# Flags
is_authenticated: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
__table_args__ = (
Index("ix_telemetry_event_name_created", "event_name", "created_at"),
Index("ix_telemetry_user_created", "user_id", "created_at"),
Index("ix_telemetry_listing_created", "listing_id", "created_at"),
Index("ix_telemetry_yield_created", "yield_domain_id", "created_at"),
)

View File

@ -44,6 +44,7 @@ class User(Base):
referred_by_user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # User who referred this user
referred_by_domain: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # Domain that referred
referral_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # Original referral code
invite_code: Mapped[Optional[str]] = mapped_column(String(32), nullable=True, unique=True, index=True) # user's own code
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@ -105,6 +105,8 @@ class YieldDomain(Base):
dns_verified: Mapped[bool] = mapped_column(Boolean, default=False)
dns_verified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# "Connect" timestamp for Yield (nameserver/CNAME verified)
connected_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
activated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
@ -142,13 +144,6 @@ class YieldDomain(Base):
"""Check if domain is actively earning."""
return self.status == "active" and self.dns_verified
@property
def monthly_revenue(self) -> Decimal:
"""Estimate monthly revenue (placeholder - should compute from transactions)."""
# In production: calculate from last 30 days of transactions
return self.total_revenue
class YieldTransaction(Base):
"""
Revenue events from affiliate partners.
@ -170,6 +165,9 @@ class YieldTransaction(Base):
# Partner info
partner_slug: Mapped[str] = mapped_column(String(50), nullable=False)
partner_transaction_id: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
# Our click id for attribution across systems (UUID string)
click_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
destination_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Amount
gross_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=0) # Full commission
@ -200,6 +198,7 @@ class YieldTransaction(Base):
__table_args__ = (
Index("ix_yield_tx_domain_created", "yield_domain_id", "created_at"),
Index("ix_yield_tx_status_created", "status", "created_at"),
Index("ix_yield_tx_click_id", "click_id"),
)
def __repr__(self) -> str:

View File

@ -0,0 +1,304 @@
"""
Business KPIs exported as Prometheus metrics (4B Ops).
These KPIs are derived from real telemetry events in the database.
We cache computations to avoid putting load on the DB on every scrape.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Optional
from sqlalchemy import and_, func, select
from app.config import get_settings
from app.database import AsyncSessionLocal
from app.models.telemetry import TelemetryEvent
settings = get_settings()
try:
from prometheus_client import Gauge
except Exception: # pragma: no cover
Gauge = None # type: ignore
@dataclass(frozen=True)
class TelemetryWindowKpis:
window_days: int
start: datetime
end: datetime
# Deal
listing_views: int
inquiries_created: int
seller_replied_inquiries: int
inquiry_reply_rate: float
listings_with_inquiries: int
listings_sold: int
inquiry_to_sold_listing_rate: float
# Yield
connected_domains: int
clicks: int
conversions: int
conversion_rate: float
payouts_paid: int
payouts_paid_amount_total: float
_cache_until_by_days: dict[int, datetime] = {}
_cache_value_by_days: dict[int, TelemetryWindowKpis] = {}
def _safe_json(metadata_json: Optional[str]) -> dict[str, Any]:
if not metadata_json:
return {}
try:
value = json.loads(metadata_json)
return value if isinstance(value, dict) else {}
except Exception:
return {}
async def _compute_window_kpis(days: int) -> TelemetryWindowKpis:
end = datetime.utcnow()
start = end - timedelta(days=days)
async with AsyncSessionLocal() as db:
# Fast path: grouped counts for pure counter events
count_events = [
"listing_view",
"inquiry_created",
"yield_connected",
"yield_click",
"yield_conversion",
"payout_paid",
]
grouped = (
await db.execute(
select(TelemetryEvent.event_name, func.count(TelemetryEvent.id))
.where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name.in_(count_events),
)
)
.group_by(TelemetryEvent.event_name)
)
).all()
counts = {name: int(cnt) for name, cnt in grouped}
listing_views = counts.get("listing_view", 0)
inquiries_created = counts.get("inquiry_created", 0)
connected_domains = counts.get("yield_connected", 0)
clicks = counts.get("yield_click", 0)
conversions = counts.get("yield_conversion", 0)
payouts_paid = counts.get("payout_paid", 0)
# Distinct listing counts (deal)
listings_with_inquiries = (
await db.execute(
select(func.count(func.distinct(TelemetryEvent.listing_id))).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name == "inquiry_created",
TelemetryEvent.listing_id.isnot(None),
)
)
)
).scalar() or 0
listings_sold = (
await db.execute(
select(func.count(func.distinct(TelemetryEvent.listing_id))).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name == "listing_marked_sold",
TelemetryEvent.listing_id.isnot(None),
)
)
)
).scalar() or 0
# For rates we need intersections/uniques; keep it exact via minimal event fetch
inquiry_listing_ids = (
await db.execute(
select(func.distinct(TelemetryEvent.listing_id)).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name == "inquiry_created",
TelemetryEvent.listing_id.isnot(None),
)
)
)
).scalars().all()
sold_listing_ids = (
await db.execute(
select(func.distinct(TelemetryEvent.listing_id)).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name == "listing_marked_sold",
TelemetryEvent.listing_id.isnot(None),
)
)
)
).scalars().all()
inquiry_set = {int(x) for x in inquiry_listing_ids if x is not None}
sold_set = {int(x) for x in sold_listing_ids if x is not None}
sold_from_inquiry = inquiry_set.intersection(sold_set)
inquiry_to_sold_listing_rate = (len(sold_from_inquiry) / len(inquiry_set)) if inquiry_set else 0.0
# Seller reply rate: unique inquiries with at least one seller message
msg_rows = (
await db.execute(
select(TelemetryEvent.inquiry_id, TelemetryEvent.metadata_json).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name == "message_sent",
TelemetryEvent.inquiry_id.isnot(None),
)
)
)
).all()
seller_replied_inquiries_set: set[int] = set()
for inquiry_id, metadata_json in msg_rows:
if inquiry_id is None:
continue
meta = _safe_json(metadata_json)
if meta.get("role") == "seller":
seller_replied_inquiries_set.add(int(inquiry_id))
seller_replied_inquiries = len(seller_replied_inquiries_set)
inquiry_reply_rate = (seller_replied_inquiries / inquiries_created) if inquiries_created else 0.0
# Payout amounts (sum of metadata amounts)
payout_rows = (
await db.execute(
select(TelemetryEvent.metadata_json).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name == "payout_paid",
TelemetryEvent.metadata_json.isnot(None),
)
)
)
).scalars().all()
payouts_paid_amount_total = 0.0
for metadata_json in payout_rows:
meta = _safe_json(metadata_json)
amount = meta.get("amount")
if isinstance(amount, (int, float)):
payouts_paid_amount_total += float(amount)
conversion_rate = (conversions / clicks) if clicks else 0.0
return TelemetryWindowKpis(
window_days=days,
start=start,
end=end,
listing_views=int(listing_views),
inquiries_created=int(inquiries_created),
seller_replied_inquiries=int(seller_replied_inquiries),
inquiry_reply_rate=float(inquiry_reply_rate),
listings_with_inquiries=int(listings_with_inquiries),
listings_sold=int(listings_sold),
inquiry_to_sold_listing_rate=float(inquiry_to_sold_listing_rate),
connected_domains=int(connected_domains),
clicks=int(clicks),
conversions=int(conversions),
conversion_rate=float(conversion_rate),
payouts_paid=int(payouts_paid),
payouts_paid_amount_total=float(payouts_paid_amount_total),
)
async def get_cached_window_kpis(days: int) -> Optional[TelemetryWindowKpis]:
"""Return cached KPIs for a window (recompute if TTL expired)."""
if not settings.enable_business_metrics:
return None
now = datetime.utcnow()
until = _cache_until_by_days.get(days)
cached = _cache_value_by_days.get(days)
if until is not None and cached is not None and now < until:
return cached
value = await _compute_window_kpis(int(days))
ttl_seconds = max(5, int(settings.business_metrics_cache_seconds))
_cache_until_by_days[int(days)] = now + timedelta(seconds=ttl_seconds)
_cache_value_by_days[int(days)] = value
return value
# -----------------------------
# Prometheus Gauges
# -----------------------------
if Gauge is not None:
_g = {
"deal_listing_views": Gauge("pounce_deal_listing_views", "Deal: listing views in window", ["window_days"]),
"deal_inquiries_created": Gauge("pounce_deal_inquiries_created", "Deal: inquiries created in window", ["window_days"]),
"deal_seller_replied_inquiries": Gauge(
"pounce_deal_seller_replied_inquiries", "Deal: inquiries with seller reply in window", ["window_days"]
),
"deal_inquiry_reply_rate": Gauge("pounce_deal_inquiry_reply_rate", "Deal: inquiry reply rate in window", ["window_days"]),
"deal_listings_with_inquiries": Gauge(
"pounce_deal_listings_with_inquiries", "Deal: distinct listings with inquiries in window", ["window_days"]
),
"deal_listings_sold": Gauge("pounce_deal_listings_sold", "Deal: distinct listings marked sold in window", ["window_days"]),
"deal_inquiry_to_sold_listing_rate": Gauge(
"pounce_deal_inquiry_to_sold_listing_rate", "Deal: (listings with inquiry) -> sold rate in window", ["window_days"]
),
"yield_connected_domains": Gauge("pounce_yield_connected_domains", "Yield: connected domains in window", ["window_days"]),
"yield_clicks": Gauge("pounce_yield_clicks", "Yield: clicks in window", ["window_days"]),
"yield_conversions": Gauge("pounce_yield_conversions", "Yield: conversions in window", ["window_days"]),
"yield_conversion_rate": Gauge("pounce_yield_conversion_rate", "Yield: conversion rate in window", ["window_days"]),
"yield_payouts_paid": Gauge("pounce_yield_payouts_paid", "Yield: payouts paid in window", ["window_days"]),
"yield_payouts_paid_amount_total": Gauge(
"pounce_yield_payouts_paid_amount_total", "Yield: total amount paid out in window", ["window_days"]
),
}
else: # pragma: no cover
_g = {}
async def update_prometheus_business_metrics() -> None:
"""Compute KPIs and set Prometheus gauges (no-op when disabled)."""
if Gauge is None or not _g:
return
if not settings.enable_business_metrics:
return
windows = {1, int(settings.business_metrics_days)}
for days in sorted(windows):
kpis = await get_cached_window_kpis(days)
if kpis is None:
continue
w = str(int(kpis.window_days))
_g["deal_listing_views"].labels(window_days=w).set(kpis.listing_views)
_g["deal_inquiries_created"].labels(window_days=w).set(kpis.inquiries_created)
_g["deal_seller_replied_inquiries"].labels(window_days=w).set(kpis.seller_replied_inquiries)
_g["deal_inquiry_reply_rate"].labels(window_days=w).set(kpis.inquiry_reply_rate)
_g["deal_listings_with_inquiries"].labels(window_days=w).set(kpis.listings_with_inquiries)
_g["deal_listings_sold"].labels(window_days=w).set(kpis.listings_sold)
_g["deal_inquiry_to_sold_listing_rate"].labels(window_days=w).set(kpis.inquiry_to_sold_listing_rate)
_g["yield_connected_domains"].labels(window_days=w).set(kpis.connected_domains)
_g["yield_clicks"].labels(window_days=w).set(kpis.clicks)
_g["yield_conversions"].labels(window_days=w).set(kpis.conversions)
_g["yield_conversion_rate"].labels(window_days=w).set(kpis.conversion_rate)
_g["yield_payouts_paid"].labels(window_days=w).set(kpis.payouts_paid)
_g["yield_payouts_paid_amount_total"].labels(window_days=w).set(kpis.payouts_paid_amount_total)

View File

@ -72,6 +72,21 @@ def instrument_app(app: FastAPI, *, metrics_path: str = "/metrics", enable_db_me
@app.get(metrics_path, include_in_schema=False)
async def _metrics_endpoint():
# Optional: export business KPIs derived from telemetry (cached).
try:
from app.observability.business_metrics import update_prometheus_business_metrics
await update_prometheus_business_metrics()
except Exception:
# Never break metrics scrape due to KPI computation issues.
pass
# Optional: export ops metrics (e.g. backup age).
try:
from app.observability.ops_metrics import update_prometheus_ops_metrics
await update_prometheus_ops_metrics()
except Exception:
pass
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
if enable_db_metrics:

View File

@ -0,0 +1,65 @@
"""
Ops/health metrics exported as Prometheus metrics (4B Ops).
These are low-frequency filesystem-based metrics (safe on scrape).
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from app.config import get_settings
settings = get_settings()
try:
from prometheus_client import Gauge
except Exception: # pragma: no cover
Gauge = None # type: ignore
if Gauge is not None:
db_backups_enabled = Gauge("pounce_db_backups_enabled", "DB backups enabled (1/0)")
db_backup_latest_unixtime = Gauge("pounce_db_backup_latest_unixtime", "Unix time of latest backup file (0 if none)")
db_backup_latest_age_seconds = Gauge("pounce_db_backup_latest_age_seconds", "Age of latest backup file (seconds)")
else: # pragma: no cover
db_backups_enabled = None # type: ignore
db_backup_latest_unixtime = None # type: ignore
db_backup_latest_age_seconds = None # type: ignore
def _backup_root() -> Path:
root = Path(settings.backup_dir)
if not root.is_absolute():
root = (Path.cwd() / root).resolve()
return root
async def update_prometheus_ops_metrics() -> None:
if Gauge is None:
return
db_backups_enabled.set(1 if settings.enable_db_backups else 0)
root = _backup_root()
if not root.exists() or not root.is_dir():
db_backup_latest_unixtime.set(0)
db_backup_latest_age_seconds.set(0)
return
files = [p for p in root.glob("*") if p.is_file()]
if not files:
db_backup_latest_unixtime.set(0)
db_backup_latest_age_seconds.set(0)
return
latest = max(files, key=lambda p: p.stat().st_mtime)
mtime = float(latest.stat().st_mtime)
now = datetime.utcnow().timestamp()
age = max(0.0, now - mtime)
db_backup_latest_unixtime.set(mtime)
db_backup_latest_age_seconds.set(age)

View File

@ -16,6 +16,10 @@ from app.models.subscription import Subscription, SubscriptionTier, TIER_CONFIG
from app.services.domain_checker import domain_checker
from app.services.email_service import email_service
from app.services.price_tracker import price_tracker
from app.services.yield_payouts import generate_payouts_for_previous_month
from app.services.db_backup import create_backup
from app.services.ops_alerts import run_ops_alert_checks
from app.services.referral_rewards import apply_referral_rewards_all
if TYPE_CHECKING:
from app.models.sniper_alert import SniperAlert
@ -450,6 +454,53 @@ async def send_health_change_alerts(db, changes: list):
logger.error(f"Failed to send health alert: {e}")
async def prepare_monthly_yield_payouts():
"""
Prepare Yield payouts for previous month (admin automation).
Safety:
- Only runs when `internal_api_key` is configured.
- Idempotent: generation skips existing payouts for the same period.
"""
if not (settings.internal_api_key or "").strip():
return
try:
async with AsyncSessionLocal() as db:
await generate_payouts_for_previous_month(db)
except Exception as e:
logger.exception(f"Yield payout preparation failed: {e}")
async def run_db_backup():
"""Create a verified DB backup (4B Ops)."""
if not settings.enable_db_backups:
return
try:
# backup is filesystem / subprocess based; no DB session needed here
create_backup(verify=True)
except Exception as e:
logger.exception(f"DB backup failed: {e}")
async def run_ops_alerting():
"""Evaluate and (optionally) send ops alerts (4B)."""
try:
await run_ops_alert_checks()
except Exception as e:
logger.exception(f"Ops alerting failed: {e}")
async def run_referral_rewards():
"""Recompute and apply referral reward bonuses (3C.2)."""
try:
async with AsyncSessionLocal() as db:
res = await apply_referral_rewards_all(db)
await db.commit()
logger.info("Referral rewards applied: processed=%s updated=%s", res.get("processed"), res.get("updated"))
except Exception as e:
logger.exception(f"Referral rewards job failed: {e}")
def setup_scheduler():
"""Configure and start the scheduler."""
# Daily domain check for Scout users at configured hour
@ -505,6 +556,42 @@ def setup_scheduler():
name="Weekly Digest Email",
replace_existing=True,
)
# Yield payout preparation: run on 2nd day of month at 02:10 UTC
scheduler.add_job(
prepare_monthly_yield_payouts,
CronTrigger(day=2, hour=2, minute=10),
id="yield_payout_prepare",
name="Yield Payout Preparation (Monthly)",
replace_existing=True,
)
# DB backup: daily at 01:30 UTC
scheduler.add_job(
run_db_backup,
CronTrigger(hour=1, minute=30),
id="db_backup",
name="DB Backup (Daily)",
replace_existing=True,
)
# Ops alerting: hourly at :12 (staggered)
scheduler.add_job(
run_ops_alerting,
CronTrigger(minute=12),
id="ops_alerting",
name="Ops Alerting (Hourly)",
replace_existing=True,
)
# Referral rewards: daily at 00:22 UTC (staggered)
scheduler.add_job(
run_referral_rewards,
CronTrigger(hour=0, minute=22),
id="referral_rewards",
name="Referral Rewards (Daily)",
replace_existing=True,
)
# TLD price scrape 2x daily for better historical data
# Morning scrape at 03:00 UTC

View File

@ -51,3 +51,26 @@ class TokenData(BaseModel):
user_id: Optional[int] = None
email: Optional[str] = None
class ReferralStats(BaseModel):
"""Referral reward snapshot for the current user (3C.2)."""
window_days: int = 30
referred_users_total: int = 0
qualified_referrals_total: int = 0
referral_link_views_window: int = 0
bonus_domains: int = 0
next_reward_at: int = 0
badge: Optional[str] = None # "verified_referrer" | "elite_referrer"
cooldown_days: int = 7
disqualified_cooldown_total: int = 0
disqualified_missing_ip_total: int = 0
disqualified_shared_ip_total: int = 0
disqualified_duplicate_ip_total: int = 0
class ReferralLinkResponse(BaseModel):
invite_code: str
url: str
stats: ReferralStats

View File

@ -0,0 +1,33 @@
"""
Referral schemas (3C.2).
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class ReferralKpiWindow(BaseModel):
days: int = Field(ge=1, le=365)
start: datetime
end: datetime
class ReferralReferrerRow(BaseModel):
user_id: int
email: str
invite_code: Optional[str] = None
created_at: datetime
referred_users_total: int = 0
referred_users_window: int = 0
referral_link_views_window: int = 0
class ReferralKpisResponse(BaseModel):
window: ReferralKpiWindow
totals: dict[str, int]
referrers: list[ReferralReferrerRow]

View File

@ -0,0 +1,47 @@
"""
Telemetry schemas (4A.2).
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class TelemetryKpiWindow(BaseModel):
days: int = Field(ge=1, le=365)
start: datetime
end: datetime
class DealFunnelKpis(BaseModel):
listing_views: int = 0
inquiries_created: int = 0
seller_replied_inquiries: int = 0
inquiry_reply_rate: float = 0.0
listings_with_inquiries: int = 0
listings_sold: int = 0
inquiry_to_sold_listing_rate: float = 0.0
median_reply_seconds: Optional[float] = None
median_time_to_sold_seconds: Optional[float] = None
class YieldFunnelKpis(BaseModel):
connected_domains: int = 0
clicks: int = 0
conversions: int = 0
conversion_rate: float = 0.0
payouts_paid: int = 0
payouts_paid_amount_total: float = 0.0
class TelemetryKpisResponse(BaseModel):
window: TelemetryKpiWindow
deal: DealFunnelKpis
yield_: YieldFunnelKpis = Field(alias="yield")

View File

@ -73,6 +73,7 @@ class YieldDomainResponse(BaseModel):
# DNS
dns_verified: bool = False
dns_verified_at: Optional[datetime] = None
connected_at: Optional[datetime] = None
# Stats
total_clicks: int = 0
@ -108,6 +109,7 @@ class YieldTransactionResponse(BaseModel):
id: int
event_type: str
partner_slug: str
click_id: Optional[str] = None
gross_amount: Decimal
net_amount: Decimal

View File

@ -3,6 +3,7 @@ from datetime import datetime, timedelta
from typing import Optional
import bcrypt
import secrets
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -92,11 +93,21 @@ class AuthService:
name: Optional[str] = None
) -> User:
"""Create a new user with default subscription."""
async def _generate_unique_invite_code() -> str:
# 12 hex chars; easy to validate + share + embed in URLs.
for _ in range(12):
code = secrets.token_hex(6)
exists = await db.execute(select(User.id).where(User.invite_code == code))
if exists.scalar_one_or_none() is None:
return code
raise RuntimeError("Failed to generate unique invite code")
# Create user (normalize email to lowercase)
user = User(
email=email.lower().strip(),
hashed_password=AuthService.hash_password(password),
name=name,
invite_code=await _generate_unique_invite_code(),
)
db.add(user)
await db.flush()

View File

@ -0,0 +1,201 @@
"""
DB backup utilities (4B Ops).
Supports:
- SQLite: file copy + integrity_check verification
- Postgres: pg_dump custom format + pg_restore --list verification
This is real ops code: it will fail loudly if the platform tooling isn't available.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from sqlalchemy.engine.url import make_url
from app.config import get_settings
settings = get_settings()
@dataclass(frozen=True)
class BackupResult:
path: str
size_bytes: int
created_at: str
verified: bool
verification_detail: Optional[str] = None
def _backup_root() -> Path:
root = Path(settings.backup_dir)
if not root.is_absolute():
# Keep backups next to backend working dir by default
root = (Path.cwd() / root).resolve()
root.mkdir(parents=True, exist_ok=True)
return root
def _timestamp() -> str:
return datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
def _cleanup_old_backups(root: Path, retention_days: int) -> int:
if retention_days <= 0:
return 0
cutoff = datetime.utcnow() - timedelta(days=retention_days)
removed = 0
for p in root.glob("*"):
if not p.is_file():
continue
try:
mtime = datetime.utcfromtimestamp(p.stat().st_mtime)
if mtime < cutoff:
p.unlink()
removed += 1
except Exception:
continue
return removed
def _sqlite_path_from_url(database_url: str) -> Path:
url = make_url(database_url)
db_path = url.database
if not db_path:
raise RuntimeError("SQLite database path missing in DATABASE_URL")
p = Path(db_path)
if not p.is_absolute():
p = (Path.cwd() / p).resolve()
return p
def _verify_sqlite(path: Path) -> tuple[bool, str]:
import sqlite3
conn = sqlite3.connect(str(path))
try:
row = conn.execute("PRAGMA integrity_check;").fetchone()
ok = bool(row and str(row[0]).lower() == "ok")
return ok, str(row[0]) if row else "no result"
finally:
conn.close()
def _pg_dump_backup(database_url: str, out_file: Path) -> None:
url = make_url(database_url)
if not url.database:
raise RuntimeError("Postgres database name missing in DATABASE_URL")
env = os.environ.copy()
if url.password:
env["PGPASSWORD"] = str(url.password)
cmd = [
"pg_dump",
"--format=custom",
"--no-owner",
"--no-privileges",
"--file",
str(out_file),
]
if url.host:
cmd += ["--host", str(url.host)]
if url.port:
cmd += ["--port", str(url.port)]
if url.username:
cmd += ["--username", str(url.username)]
cmd += [str(url.database)]
proc = subprocess.run(cmd, env=env, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(f"pg_dump failed: {proc.stderr.strip() or proc.stdout.strip()}")
def _verify_pg_dump(out_file: Path) -> tuple[bool, str]:
# Basic size check
if out_file.stat().st_size < 1024:
return False, "backup file too small"
proc = subprocess.run(
["pg_restore", "--list", str(out_file)],
capture_output=True,
text=True,
)
if proc.returncode != 0:
return False, proc.stderr.strip() or proc.stdout.strip() or "pg_restore failed"
return True, "pg_restore --list OK"
def create_backup(*, verify: bool = True) -> BackupResult:
root = _backup_root()
_cleanup_old_backups(root, settings.backup_retention_days)
db_url = settings.database_url
driver = make_url(db_url).drivername
created_at = datetime.utcnow().isoformat() + "Z"
if driver.startswith("sqlite"):
src = _sqlite_path_from_url(db_url)
if not src.exists():
raise RuntimeError(f"SQLite DB file not found: {src}")
out = root / f"sqlite-backup-{_timestamp()}{src.suffix or '.db'}"
shutil.copy2(src, out)
ok = True
detail = None
if verify:
ok, detail = _verify_sqlite(out)
if not ok:
raise RuntimeError(f"SQLite backup verification failed: {detail}")
return BackupResult(
path=str(out),
size_bytes=out.stat().st_size,
created_at=created_at,
verified=ok,
verification_detail=detail,
)
if driver.startswith("postgresql"):
out = root / f"pg-backup-{_timestamp()}.dump"
_pg_dump_backup(db_url, out)
ok = True
detail = None
if verify:
ok, detail = _verify_pg_dump(out)
if not ok:
raise RuntimeError(f"Postgres backup verification failed: {detail}")
return BackupResult(
path=str(out),
size_bytes=out.stat().st_size,
created_at=created_at,
verified=ok,
verification_detail=detail,
)
raise RuntimeError(f"Unsupported database driver for backups: {driver}")
def list_backups(limit: int = 20) -> list[dict]:
root = _backup_root()
files = [p for p in root.glob("*") if p.is_file()]
files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
out: list[dict] = []
for p in files[: max(1, limit)]:
st = p.stat()
out.append(
{
"name": p.name,
"path": str(p),
"size_bytes": st.st_size,
"modified_at": datetime.utcfromtimestamp(st.st_mtime).isoformat() + "Z",
}
)
return out

View File

@ -22,10 +22,12 @@ Environment Variables Required:
"""
import logging
import os
import uuid
from typing import Optional, List
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
from email.utils import formatdate
import aiosmtplib
from jinja2 import Template
@ -273,6 +275,11 @@ TEMPLATES = {
Visit pounce.ch
</a>
</div>
{% if unsubscribe_url %}
<p style="margin: 32px 0 0 0; font-size: 12px; color: #999999; line-height: 1.6;">
<a href="{{ unsubscribe_url }}" style="color: #666666; text-decoration: none;">Unsubscribe</a>
</p>
{% endif %}
""",
"listing_inquiry": """
@ -303,6 +310,26 @@ TEMPLATES = {
<p style="margin: 24px 0 0 0; font-size: 13px; color: #999999;">
<a href="https://pounce.ch/terminal/listing" style="color: #666666;">Manage your listings →</a>
</p>
""",
"listing_message": """
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
New message on {{ domain }}
</h2>
<p style="margin: 0 0 16px 0; font-size: 15px; color: #333333; line-height: 1.6;">
From: <strong style="color:#000000;">{{ sender_name }}</strong>
</p>
<div style="margin: 16px 0; padding: 20px; background: #fafafa; border-radius: 6px; border-left: 3px solid #000000;">
<p style="margin: 0; font-size: 14px; color: #333333; line-height: 1.6; white-space: pre-wrap;">{{ message }}</p>
</div>
<div style="margin: 24px 0 0 0;">
<a href="{{ thread_url }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;">
Open thread
</a>
</div>
<p style="margin: 16px 0 0 0; font-size: 13px; color: #999999;">
Sent: {{ timestamp }}
</p>
""",
}
@ -341,6 +368,7 @@ class EmailService:
subject: str,
html_content: str,
text_content: Optional[str] = None,
headers: Optional[dict[str, str]] = None,
) -> bool:
"""
Send an email via SMTP.
@ -364,6 +392,15 @@ class EmailService:
msg["Subject"] = subject
msg["From"] = f"{SMTP_CONFIG['from_name']} <{SMTP_CONFIG['from_email']}>"
msg["To"] = to_email
msg["Date"] = formatdate(localtime=False)
msg["Message-ID"] = EmailService._make_message_id()
msg["Reply-To"] = SMTP_CONFIG["from_email"]
# Optional extra headers (deliverability + RFC 8058 List-Unsubscribe)
if headers:
for k, v in headers.items():
if v:
msg[k] = v
# Add text part (fallback)
if text_content:
@ -400,6 +437,16 @@ class EmailService:
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {e}")
return False
@staticmethod
def _make_message_id() -> str:
"""
Generate a stable Message-ID with the sender domain.
Helps deliverability and threading in some clients.
"""
from_email = str(SMTP_CONFIG.get("from_email") or "hello@pounce.ch")
domain = from_email.split("@")[-1] if "@" in from_email else "pounce.ch"
return f"<{uuid.uuid4().hex}@{domain}>"
# ============== Domain Alerts ==============
@ -601,15 +648,22 @@ class EmailService:
@staticmethod
async def send_newsletter_welcome(
to_email: str,
unsubscribe_url: Optional[str] = None,
) -> bool:
"""Send newsletter subscription welcome email."""
html = EmailService._render_email("newsletter_welcome")
html = EmailService._render_email("newsletter_welcome", unsubscribe_url=unsubscribe_url)
extra_headers: dict[str, str] = {}
if unsubscribe_url:
extra_headers["List-Unsubscribe"] = f"<{unsubscribe_url}>"
extra_headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
return await EmailService.send_email(
to_email=to_email,
subject="You're on the list. Welcome to POUNCE.",
html_content=html,
text_content="Welcome to POUNCE Insights. Expect market moves, strategies, and feature drops. No spam.",
headers=extra_headers or None,
)
# ============== Listing Inquiries ==============
@ -646,6 +700,32 @@ class EmailService:
text_content=f"New inquiry from {name} ({email}) for {domain}. Message: {message}",
)
@staticmethod
async def send_listing_message(
to_email: str,
domain: str,
sender_name: str,
message: str,
thread_url: str,
) -> bool:
"""Send notification when a new in-product message is posted."""
html = EmailService._render_email(
"listing_message",
domain=domain,
sender_name=sender_name,
message=message,
thread_url=thread_url,
timestamp=datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
)
subject = f"New message on {domain}"
return await EmailService.send_email(
to_email=to_email,
subject=subject,
html_content=html,
text_content=f"New message on {domain} from {sender_name}: {message}",
)
# Global instance
email_service = EmailService()

View File

@ -0,0 +1,256 @@
"""
Ops alerting (4B) without external monitoring stack.
Runs in the scheduler process:
- checks backup freshness (if backups enabled)
- checks basic 24h business signals from telemetry (deal inquiries / yield clicks)
- sends an aggregated email alert with cooldown to avoid spam
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from sqlalchemy import and_, func, select
from app.config import get_settings
from app.database import AsyncSessionLocal
from app.models.ops_alert import OpsAlertEvent
from app.models.telemetry import TelemetryEvent
from app.services.email_service import CONTACT_EMAIL, email_service
logger = logging.getLogger(__name__)
settings = get_settings()
@dataclass(frozen=True)
class OpsFinding:
key: str
severity: str # "warn" | "page"
title: str
detail: str
def _parse_recipients(raw: str) -> list[str]:
emails = [e.strip() for e in (raw or "").split(",") if e.strip()]
if emails:
return emails
fallback = (CONTACT_EMAIL or os.getenv("CONTACT_EMAIL", "")).strip()
return [fallback] if fallback else []
def _backup_root() -> Path:
root = Path(settings.backup_dir)
if not root.is_absolute():
root = (Path.cwd() / root).resolve()
return root
def _latest_backup_age_seconds() -> float | None:
root = _backup_root()
if not root.exists() or not root.is_dir():
return None
files = [p for p in root.glob("*") if p.is_file()]
if not files:
return None
latest = max(files, key=lambda p: p.stat().st_mtime)
now = datetime.utcnow().timestamp()
return max(0.0, now - float(latest.stat().st_mtime))
async def evaluate_ops_findings() -> list[OpsFinding]:
findings: list[OpsFinding] = []
# Backup stale check
if settings.enable_db_backups:
age = _latest_backup_age_seconds()
if age is None:
findings.append(
OpsFinding(
key="backup_missing",
severity="page",
title="DB backups enabled but no backup file found",
detail=f"backup_dir={_backup_root()}",
)
)
elif age > float(settings.ops_alert_backup_stale_seconds):
findings.append(
OpsFinding(
key="backup_stale",
severity="page",
title="DB backup is stale",
detail=f"latest_backup_age_seconds={int(age)} threshold={int(settings.ops_alert_backup_stale_seconds)}",
)
)
# 24h telemetry signal checks (business sanity)
end = datetime.utcnow()
start = end - timedelta(days=1)
async with AsyncSessionLocal() as db:
inquiries_24h = (
await db.execute(
select(func.count(TelemetryEvent.id)).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name == "inquiry_created",
)
)
)
).scalar() or 0
yield_clicks_24h = (
await db.execute(
select(func.count(TelemetryEvent.id)).where(
and_(
TelemetryEvent.created_at >= start,
TelemetryEvent.created_at <= end,
TelemetryEvent.event_name == "yield_click",
)
)
)
).scalar() or 0
if int(inquiries_24h) == 0:
findings.append(
OpsFinding(
key="deal_inquiries_zero_24h",
severity="warn",
title="No inquiries created in last 24h",
detail="Deal funnel might be broken or traffic is zero.",
)
)
if int(yield_clicks_24h) == 0:
findings.append(
OpsFinding(
key="yield_clicks_zero_24h",
severity="warn",
title="No yield clicks in last 24h",
detail="Yield routing might be misconfigured or traffic is zero.",
)
)
return findings
async def _cooldown_ok(db, key: str) -> bool:
cooldown = max(5, int(settings.ops_alert_cooldown_minutes))
cutoff = datetime.utcnow() - timedelta(minutes=cooldown)
last_sent = (
await db.execute(
select(OpsAlertEvent.created_at)
.where(
OpsAlertEvent.alert_key == key,
OpsAlertEvent.status == "sent",
OpsAlertEvent.created_at >= cutoff,
)
.order_by(OpsAlertEvent.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
return last_sent is None
async def send_ops_alerts(findings: list[OpsFinding]) -> dict:
recipients = _parse_recipients(settings.ops_alert_recipients)
if not recipients:
logger.warning("Ops alerts enabled but no recipients configured (OPS_ALERT_RECIPIENTS/CONTACT_EMAIL).")
return {"sent": 0, "skipped": len(findings), "reason": "no_recipients"}
if not email_service.is_configured():
return {"sent": 0, "skipped": len(findings), "reason": "smtp_not_configured"}
async with AsyncSessionLocal() as db:
actionable: list[OpsFinding] = []
skipped = 0
for f in findings:
if await _cooldown_ok(db, f.key):
actionable.append(f)
else:
skipped += 1
db.add(
OpsAlertEvent(
alert_key=f.key,
severity=f.severity,
title=f.title,
detail=f.detail,
status="skipped",
recipients=",".join(recipients) if recipients else None,
send_reason="cooldown",
)
)
if not actionable:
await db.commit()
return {"sent": 0, "skipped": len(findings), "reason": "cooldown"}
sev = "PAGE" if any(f.severity == "page" for f in actionable) else "WARN"
subject = f"[pounce][{sev}] Ops alerts ({len(actionable)})"
items_html = "".join(
f"""
<div style="padding: 12px 14px; background: #fafafa; border-radius: 8px; border-left: 3px solid {'#ef4444' if f.severity=='page' else '#f59e0b'}; margin: 10px 0;">
<div style="font-weight:600; color:#000;">{f.title}</div>
<div style="margin-top:6px; font-size:13px; color:#444; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;">
{f.key}: {f.detail}
</div>
</div>
""".strip()
for f in actionable
)
html = f"""
<h2 style="margin: 0 0 16px 0; font-size: 18px; font-weight: 700; color: #000000;">
Ops alerts
</h2>
<p style="margin: 0 0 16px 0; color:#333; line-height:1.6;">
Detected {len(actionable)} issue(s). (Cooldown: {int(settings.ops_alert_cooldown_minutes)} min)
</p>
{items_html}
<p style="margin: 18px 0 0 0; font-size: 12px; color:#777;">
Timestamp: {datetime.utcnow().isoformat()}Z
</p>
""".strip()
text = "\n".join([f"- [{f.severity.upper()}] {f.title} ({f.key}) :: {f.detail}" for f in actionable])
sent = 0
for to in recipients:
ok = await email_service.send_email(to_email=to, subject=subject, html_content=html, text_content=text)
sent += 1 if ok else 0
# Persist sent events for cooldown + history
async with AsyncSessionLocal() as db:
for f in actionable:
db.add(
OpsAlertEvent(
alert_key=f.key,
severity=f.severity,
title=f.title,
detail=f.detail,
status="sent" if sent else "error",
recipients=",".join(recipients) if recipients else None,
send_reason=None if sent else "send_failed",
)
)
await db.commit()
return {"sent": sent, "actionable": len(actionable), "recipients": recipients}
async def run_ops_alert_checks() -> dict:
"""
Entry point for scheduler/admin.
Returns findings + send status (if enabled).
"""
findings = await evaluate_ops_findings()
if not settings.ops_alerts_enabled:
return {"enabled": False, "findings": [f.__dict__ for f in findings]}
send_status = await send_ops_alerts(findings)
return {"enabled": True, "findings": [f.__dict__ for f in findings], "send": send_status}

View File

@ -0,0 +1,245 @@
"""
Referral rewards (3C.2).
Goals:
- Deterministic, abuse-resistant rewards
- No manual state tracking per referral; we compute from authoritative DB state
- Idempotent updates (can be run via scheduler and on-demand)
Current reward:
- For every N qualified referrals, grant +M bonus watchlist domain slots.
Qualified referral definition:
- referred user has `users.referred_by_user_id = referrer.id`
- referred user is_active AND is_verified
- referred user has an active subscription that is NOT Scout (Trader/Tycoon), and is currently active
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models.subscription import Subscription, SubscriptionStatus, SubscriptionTier
from app.models.telemetry import TelemetryEvent
from app.models.user import User
QUALIFIED_REFERRAL_BATCH_SIZE = 3
BONUS_DOMAINS_PER_BATCH = 5
settings = get_settings()
def compute_bonus_domains(qualified_referrals: int) -> int:
if qualified_referrals <= 0:
return 0
batches = qualified_referrals // QUALIFIED_REFERRAL_BATCH_SIZE
return int(batches * BONUS_DOMAINS_PER_BATCH)
def compute_badge(qualified_referrals: int) -> Optional[str]:
if qualified_referrals >= 10:
return "elite_referrer"
if qualified_referrals >= 3:
return "verified_referrer"
return None
@dataclass(frozen=True)
class ReferralRewardSnapshot:
referrer_user_id: int
referred_users_total: int
qualified_referrals_total: int
cooldown_days: int
disqualified_cooldown_total: int
disqualified_missing_ip_total: int
disqualified_shared_ip_total: int
disqualified_duplicate_ip_total: int
bonus_domains: int
badge: Optional[str]
computed_at: datetime
async def get_referral_reward_snapshot(db: AsyncSession, referrer_user_id: int) -> ReferralRewardSnapshot:
# Total referred users (all-time)
referred_users_total = int(
(
await db.execute(
select(func.count(User.id)).where(User.referred_by_user_id == referrer_user_id)
)
).scalar()
or 0
)
now = datetime.utcnow()
cooldown_days = max(0, int(getattr(settings, "referral_rewards_cooldown_days", 7) or 0))
cooldown_cutoff = now - timedelta(days=cooldown_days) if cooldown_days else None
# Referrer IP hashes (window) for self-ref/shared-ip checks
ip_window_days = max(1, int(getattr(settings, "referral_rewards_ip_window_days", 30) or 30))
ip_window_start = now - timedelta(days=ip_window_days)
referrer_ip_rows = (
await db.execute(
select(TelemetryEvent.ip_hash)
.where(
and_(
TelemetryEvent.user_id == referrer_user_id,
TelemetryEvent.ip_hash.isnot(None),
TelemetryEvent.created_at >= ip_window_start,
TelemetryEvent.created_at <= now,
)
)
.distinct()
)
).all()
referrer_ip_hashes = {str(r[0]) for r in referrer_ip_rows if r and r[0]}
# Referred user's registration IP hash (from telemetry) as subquery
reg_ip_subq = (
select(
TelemetryEvent.user_id.label("user_id"),
func.max(TelemetryEvent.ip_hash).label("signup_ip_hash"),
)
.where(
and_(
TelemetryEvent.event_name == "user_registered",
TelemetryEvent.user_id.isnot(None),
)
)
.group_by(TelemetryEvent.user_id)
.subquery()
)
# Candidate referred users (paid + verified + active)
rows = (
await db.execute(
select(
User.id,
User.created_at,
Subscription.started_at,
reg_ip_subq.c.signup_ip_hash,
)
.select_from(User)
.join(Subscription, Subscription.user_id == User.id)
.outerjoin(reg_ip_subq, reg_ip_subq.c.user_id == User.id)
.where(
and_(
User.referred_by_user_id == referrer_user_id,
User.is_active == True,
User.is_verified == True,
Subscription.tier.in_([SubscriptionTier.TRADER, SubscriptionTier.TYCOON]),
Subscription.status.in_([SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE]),
or_(Subscription.expires_at.is_(None), Subscription.expires_at >= now),
)
)
)
).all()
require_ip = bool(getattr(settings, "referral_rewards_require_ip_hash", True))
disqualified_cooldown_total = 0
disqualified_missing_ip_total = 0
disqualified_shared_ip_total = 0
disqualified_duplicate_ip_total = 0
qualified_ip_hashes: set[str] = set()
qualified_referrals_total = 0
for _user_id, user_created_at, sub_started_at, signup_ip_hash in rows:
# Cooldown: user account age AND subscription age must pass cooldown
if cooldown_cutoff is not None:
if (user_created_at and user_created_at > cooldown_cutoff) or (
sub_started_at and sub_started_at > cooldown_cutoff
):
disqualified_cooldown_total += 1
continue
ip_hash = str(signup_ip_hash) if signup_ip_hash else None
if require_ip and not ip_hash:
disqualified_missing_ip_total += 1
continue
if ip_hash and referrer_ip_hashes and ip_hash in referrer_ip_hashes:
disqualified_shared_ip_total += 1
continue
if ip_hash and ip_hash in qualified_ip_hashes:
disqualified_duplicate_ip_total += 1
continue
if ip_hash:
qualified_ip_hashes.add(ip_hash)
qualified_referrals_total += 1
bonus_domains = compute_bonus_domains(qualified_referrals_total)
badge = compute_badge(qualified_referrals_total)
return ReferralRewardSnapshot(
referrer_user_id=referrer_user_id,
referred_users_total=referred_users_total,
qualified_referrals_total=qualified_referrals_total,
cooldown_days=cooldown_days,
disqualified_cooldown_total=disqualified_cooldown_total,
disqualified_missing_ip_total=disqualified_missing_ip_total,
disqualified_shared_ip_total=disqualified_shared_ip_total,
disqualified_duplicate_ip_total=disqualified_duplicate_ip_total,
bonus_domains=bonus_domains,
badge=badge,
computed_at=datetime.utcnow(),
)
async def apply_referral_rewards_for_user(db: AsyncSession, referrer_user_id: int) -> ReferralRewardSnapshot:
"""
Apply rewards to the referrer's subscription row, based on current qualified referrals.
This is idempotent: it sets the bonus to the computed value.
"""
snapshot = await get_referral_reward_snapshot(db, referrer_user_id)
sub_res = await db.execute(select(Subscription).where(Subscription.user_id == referrer_user_id))
sub = sub_res.scalar_one_or_none()
if not sub:
# Create default subscription so bonus can be stored
sub = Subscription(user_id=referrer_user_id, tier=SubscriptionTier.SCOUT, max_domains=5)
db.add(sub)
await db.flush()
desired = int(snapshot.bonus_domains)
current = int(getattr(sub, "referral_bonus_domains", 0) or 0)
if current != desired:
sub.referral_bonus_domains = desired
await db.flush()
return snapshot
async def apply_referral_rewards_all(db: AsyncSession) -> dict[str, int]:
"""
Apply rewards for all users that have an invite_code.
"""
res = await db.execute(select(User.id).where(User.invite_code.isnot(None)))
user_ids = [int(r[0]) for r in res.all()]
updated = 0
processed = 0
for user_id in user_ids:
processed += 1
snap = await get_referral_reward_snapshot(db, user_id)
sub_res = await db.execute(select(Subscription).where(Subscription.user_id == user_id))
sub = sub_res.scalar_one_or_none()
if not sub:
sub = Subscription(user_id=user_id, tier=SubscriptionTier.SCOUT, max_domains=5)
db.add(sub)
await db.flush()
desired = int(snap.bonus_domains)
current = int(getattr(sub, "referral_bonus_domains", 0) or 0)
if current != desired:
sub.referral_bonus_domains = desired
updated += 1
return {"processed": processed, "updated": updated}

View File

@ -0,0 +1,79 @@
"""
Telemetry service (4A).
Single entry-point for writing canonical product events.
"""
from __future__ import annotations
import hashlib
import json
from typing import Any, Optional
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models.telemetry import TelemetryEvent
settings = get_settings()
def _hash_ip(ip: str) -> str:
return hashlib.sha256(f"{ip}|{settings.secret_key}".encode()).hexdigest()[:32]
def _get_client_ip(request: Request) -> Optional[str]:
xff = request.headers.get("x-forwarded-for")
if xff:
ip = xff.split(",")[0].strip()
if ip:
return ip
cf_ip = request.headers.get("cf-connecting-ip")
if cf_ip:
return cf_ip.strip()
return request.client.host if request.client else None
async def track_event(
db: AsyncSession,
*,
event_name: str,
request: Optional[Request] = None,
user_id: Optional[int] = None,
is_authenticated: Optional[bool] = None,
source: Optional[str] = None,
domain: Optional[str] = None,
listing_id: Optional[int] = None,
inquiry_id: Optional[int] = None,
yield_domain_id: Optional[int] = None,
click_id: Optional[str] = None,
referrer: Optional[str] = None,
user_agent: Optional[str] = None,
metadata: Optional[dict[str, Any]] = None,
) -> None:
ip_hash = None
if request is not None:
ip = _get_client_ip(request)
ip_hash = _hash_ip(ip) if ip else None
user_agent = user_agent or request.headers.get("user-agent")
referrer = referrer or request.headers.get("referer")
row = TelemetryEvent(
user_id=user_id,
event_name=event_name,
listing_id=listing_id,
inquiry_id=inquiry_id,
yield_domain_id=yield_domain_id,
click_id=click_id[:64] if click_id else None,
domain=domain,
source=source,
ip_hash=ip_hash,
user_agent=user_agent[:500] if user_agent else None,
referrer=referrer[:500] if referrer else None,
metadata_json=json.dumps(metadata or {}, ensure_ascii=False) if metadata else None,
is_authenticated=is_authenticated,
)
db.add(row)

View File

@ -0,0 +1,169 @@
"""
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
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import dns.resolver
@dataclass(frozen=True)
class YieldDNSCheckResult:
verified: bool
method: Optional[str] # "nameserver" | "cname" | None
actual_ns: list[str]
cname_ok: bool
error: Optional[str]
def _resolver() -> dns.resolver.Resolver:
r = dns.resolver.Resolver()
r.timeout = 3
r.lifetime = 5
return r
def _normalize_host(host: str) -> str:
return host.rstrip(".").lower().strip()
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})
def _resolve_cname(domain: str) -> list[str]:
r = _resolver()
answers = r.resolve(domain, "CNAME")
return sorted({_normalize_host(str(rr.target)) for rr in answers})
def _resolve_a(host: str) -> list[str]:
r = _resolver()
answers = r.resolve(host, "A")
return sorted({str(rr) for rr in answers})
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)
"""
domain = _normalize_host(domain)
expected_ns = sorted({_normalize_host(ns) for ns in expected_nameservers if ns})
target = _normalize_host(cname_target)
if not domain:
return YieldDNSCheckResult(
verified=False,
method=None,
actual_ns=[],
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
try:
actual_ns = _resolve_ns(domain)
if expected_ns and set(expected_ns).issubset(set(actual_ns)):
return YieldDNSCheckResult(
verified=True,
method="nameserver",
actual_ns=actual_ns,
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=[],
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",
)
# 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),
)
return YieldDNSCheckResult(
verified=False,
method=None,
actual_ns=actual_ns,
cname_ok=False,
error=None,
)

View File

@ -0,0 +1,132 @@
"""
Yield payout generation helpers (ledger).
Used by:
- Admin endpoints (manual ops)
- Scheduler (automatic monthly preparation)
"""
from __future__ import annotations
from datetime import datetime, timedelta
from decimal import Decimal
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.yield_domain import YieldDomain, YieldPayout, YieldTransaction
async def generate_payouts_for_period(
db: AsyncSession,
*,
period_start: datetime,
period_end: datetime,
) -> tuple[int, int]:
"""
Create payouts for confirmed, unpaid transactions and assign payout_id.
Returns: (created_count, skipped_existing_count)
"""
if period_end <= period_start:
raise ValueError("period_end must be after period_start")
aggregates = (
await db.execute(
select(
YieldDomain.user_id.label("user_id"),
YieldTransaction.currency.label("currency"),
func.count(YieldTransaction.id).label("tx_count"),
func.coalesce(func.sum(YieldTransaction.net_amount), 0).label("amount"),
)
.join(YieldDomain, YieldDomain.id == YieldTransaction.yield_domain_id)
.where(
and_(
YieldTransaction.status == "confirmed",
YieldTransaction.payout_id.is_(None),
YieldTransaction.created_at >= period_start,
YieldTransaction.created_at < period_end,
)
)
.group_by(YieldDomain.user_id, YieldTransaction.currency)
)
).all()
created = 0
skipped = 0
for row in aggregates:
user_id = int(row.user_id)
currency = (row.currency or "CHF").upper()
tx_count = int(row.tx_count or 0)
amount = Decimal(str(row.amount or 0))
if tx_count <= 0 or amount <= 0:
continue
existing = (
await db.execute(
select(YieldPayout).where(
and_(
YieldPayout.user_id == user_id,
YieldPayout.currency == currency,
YieldPayout.period_start == period_start,
YieldPayout.period_end == period_end,
)
)
)
).scalar_one_or_none()
if existing:
skipped += 1
continue
payout = YieldPayout(
user_id=user_id,
amount=amount,
currency=currency,
period_start=period_start,
period_end=period_end,
transaction_count=tx_count,
status="pending",
payment_method=None,
payment_reference=None,
)
db.add(payout)
await db.flush()
tx_ids = (
await db.execute(
select(YieldTransaction.id)
.join(YieldDomain, YieldDomain.id == YieldTransaction.yield_domain_id)
.where(
and_(
YieldDomain.user_id == user_id,
YieldTransaction.currency == currency,
YieldTransaction.status == "confirmed",
YieldTransaction.payout_id.is_(None),
YieldTransaction.created_at >= period_start,
YieldTransaction.created_at < period_end,
)
)
)
).scalars().all()
for tx_id in tx_ids:
tx = (
await db.execute(select(YieldTransaction).where(YieldTransaction.id == tx_id))
).scalar_one()
tx.payout_id = payout.id
created += 1
await db.commit()
return created, skipped
async def generate_payouts_for_previous_month(db: AsyncSession) -> tuple[int, int]:
now = datetime.utcnow()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
prev_month_end = month_start
prev_month_start = (month_start - timedelta(days=1)).replace(day=1)
return await generate_payouts_for_period(db, period_start=prev_month_start, period_end=prev_month_end)

View File

@ -23,6 +23,7 @@ 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"
# Parse flags
QUICK_MODE=false
@ -85,16 +86,16 @@ RSYNC_OPTS="-avz --delete"
if ! $BACKEND_ONLY; then
echo " Frontend:"
sshpass -p "$SERVER_PASS" rsync $RSYNC_OPTS \
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/ 2>&1 | sed 's/^/ /'
frontend/ $SERVER_USER@$SERVER_HOST:$SERVER_PATH/frontend/
fi
if ! $FRONTEND_ONLY; then
echo " Backend:"
sshpass -p "$SERVER_PASS" rsync $RSYNC_OPTS \
sshpass -p "$SERVER_PASS" rsync -e "ssh $SSH_OPTS" $RSYNC_OPTS \
--exclude '__pycache__' \
--exclude '.pytest_cache' \
--exclude 'venv' \
@ -102,25 +103,41 @@ if ! $FRONTEND_ONLY; then
--exclude '*.pyc' \
--exclude '.env' \
--exclude '*.db' \
backend/ $SERVER_USER@$SERVER_HOST:$SERVER_PATH/backend/ 2>&1 | sed 's/^/ /'
backend/ $SERVER_USER@$SERVER_HOST:$SERVER_PATH/backend/
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 $SERVER_USER@$SERVER_HOST << 'BACKEND_EOF'
# Signal uvicorn to reload (if running with --reload)
# Otherwise, just check it's running
BACKEND_PID=$(pgrep -f 'uvicorn app.main:app' | head -1)
sshpass -p "$SERVER_PASS" ssh $SSH_OPTS $SERVER_USER@$SERVER_HOST << 'BACKEND_EOF'
set -e
cd ~/pounce/backend
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)"
exit 1
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
# Touch a file to trigger auto-reload if uvicorn has --reload
touch ~/pounce/backend/app/main.py
echo " ✓ Backend reload triggered (PID: $BACKEND_PID)"
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 &
sleep 2
echo " ✓ Backend restarted"
else
echo " ⚠ Backend not running, starting..."
cd ~/pounce/backend
source ../venv/bin/activate
nohup uvicorn app.main:app --host 0.0.0.0 --port 8000 > backend.log 2>&1 &
sleep 2
echo " ✓ Backend started"
@ -133,18 +150,17 @@ 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 $SERVER_USER@$SERVER_HOST << 'FRONTEND_EOF'
sshpass -p "$SERVER_PASS" ssh $SSH_OPTS $SERVER_USER@$SERVER_HOST << 'FRONTEND_EOF'
cd ~/pounce/frontend
# Build new version
echo " Building..."
npm run build 2>&1 | grep -E '(✓|○|λ|Error|error)' | head -10 | sed 's/^/ /'
npm run build
BUILD_EXIT=$?
if [ $BUILD_EXIT -eq 0 ]; then
# Gracefully restart Next.js
NEXT_PID=$(pgrep -f 'next start' | head -1)
NEXT_PID=$(pgrep -f 'next start' | awk 'NR==1{print; exit}')
if [ -n "$NEXT_PID" ]; then
echo " Restarting Next.js (PID: $NEXT_PID)..."
@ -157,7 +173,7 @@ if ! $BACKEND_ONLY; then
sleep 2
# Verify
NEW_PID=$(pgrep -f 'next start' | head -1)
NEW_PID=$(pgrep -f 'next start' | awk 'NR==1{print; exit}')
if [ -n "$NEW_PID" ]; then
echo " ✓ Frontend running (PID: $NEW_PID)"
else

View File

@ -1,9 +1,16 @@
// Public pages layout - inherits from root layout
import ReferralCapture from '@/components/ReferralCapture'
export default function PublicLayout({
children,
}: {
children: React.ReactNode
}) {
return children
return (
<>
<ReferralCapture />
{children}
</>
)
}

View File

@ -0,0 +1,45 @@
import type { Metadata } from 'next'
import Script from 'next/script'
import { SITE_URL } from '@/lib/seo'
export const metadata: Metadata = {
title: 'About | Pounce',
description: 'What Pounce is building: domain intelligence, verified listings, and monetization workflows for serious operators.',
alternates: { canonical: `${SITE_URL}/about` },
openGraph: {
title: 'About | Pounce',
description: 'Domain intelligence, verified inventory, and operator-grade workflows.',
url: `${SITE_URL}/about`,
type: 'website',
},
}
export default function AboutLayout({ children }: { children: React.ReactNode }) {
const schema = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
{ '@type': 'ListItem', position: 2, name: 'About', item: `${SITE_URL}/about` },
],
},
{
'@type': 'WebPage',
name: 'About Pounce',
description: 'Pounce builds domain intelligence and verified workflows for operators.',
url: `${SITE_URL}/about`,
isPartOf: { '@type': 'WebSite', name: 'Pounce', url: SITE_URL },
},
],
}
return (
<>
<Script id="about-schema" type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />
{children}
</>
)
}

View File

@ -6,10 +6,10 @@ import { Target, Shield, Zap, Users, Globe, ArrowRight } from 'lucide-react'
import Link from 'next/link'
const stats = [
{ value: '500K+', label: 'ASSETS TRACKED' },
{ value: '99.9%', label: 'SYSTEM UPTIME' },
{ value: '50ms', label: 'SCAN LATENCY' },
{ value: '24/7', label: 'LIVE MONITOR' },
{ value: 'Verified', label: 'DNS-VERIFIED LISTINGS' },
{ value: 'Real data', label: 'NO SIMULATED INVENTORY' },
{ value: 'Operator-grade', label: 'WORKFLOWS' },
{ value: 'Secure', label: 'COOKIE AUTH + RATE LIMITS' },
]
export default function AboutPage() {

View File

@ -1,111 +0,0 @@
import type { Metadata } from 'next'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.com'
export const marketMetadata: Metadata = {
title: 'Live Domain Market - Auctions, Drops & Premium Domains',
description: 'Live domain marketplace aggregating auctions and Pounce Direct listings. Cut noise with filters. Pounce Direct owners are DNS-verified and sell with 0% commission.',
keywords: [
'domain marketplace',
'domain auctions',
'expired domains',
'domain drops',
'premium domains for sale',
'buy domains',
'domain backorder',
'GoDaddy auctions',
'Sedo marketplace',
'domain investing',
'domain flipping',
'brandable domains',
],
openGraph: {
title: 'Live Domain Market - Pounce',
description: 'Live domain marketplace. Auctions and Pounce Direct listings. Filters, pricing intel, and verified sellers.',
url: `${siteUrl}/market`,
type: 'website',
images: [
{
url: `${siteUrl}/og-market.png`,
width: 1200,
height: 630,
alt: 'Pounce Domain Market',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Live Domain Market - Pounce',
description: 'Live domain marketplace. Auctions and Pounce Direct listings. Filters and verified sellers.',
images: [`${siteUrl}/og-market.png`],
},
alternates: {
canonical: `${siteUrl}/market`,
},
}
/**
* Structured data for market page
*/
export function getMarketStructuredData() {
return {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Live Domain Market',
description: 'Live domain marketplace aggregating auctions and verified listings',
url: `${siteUrl}/market`,
breadcrumb: {
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: siteUrl,
},
{
'@type': 'ListItem',
position: 2,
name: 'Market',
item: `${siteUrl}/market`,
},
],
},
mainEntity: {
'@type': 'ItemList',
name: 'Domain Auctions and Listings',
description: 'Live feed of domain auctions and premium domain listings',
numberOfItems: 100000,
},
}
}
/**
* Generate structured data for a specific domain auction
*/
export function getDomainAuctionStructuredData(domain: string, price: number, endTime: string, platform: string) {
return {
'@context': 'https://schema.org',
'@type': 'Product',
name: domain,
description: `Premium domain name ${domain} available at auction`,
brand: {
'@type': 'Brand',
name: platform,
},
offers: {
'@type': 'Offer',
price: price.toFixed(2),
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
priceValidUntil: endTime,
seller: {
'@type': 'Organization',
name: platform,
},
url: `${siteUrl}/market?domain=${encodeURIComponent(domain)}`,
},
category: 'Domain Names',
}
}

View File

@ -14,6 +14,7 @@ import {
Crown,
Zap,
Activity,
BarChart3,
Globe,
Bell,
AlertCircle,
@ -33,10 +34,14 @@ import {
Edit2,
ExternalLink,
Database,
Tag,
Target,
Wallet,
Link,
} from 'lucide-react'
import clsx from 'clsx'
type TabType = 'overview' | 'users' | 'alerts' | 'newsletter' | 'tld' | 'auctions' | 'blog' | 'system' | 'activity'
type TabType = 'overview' | 'telemetry' | 'users' | 'alerts' | 'newsletter' | 'tld' | 'auctions' | 'blog' | 'system' | 'activity'
interface AdminStats {
users: { total: number; active: number; verified: number; new_this_week: number }
@ -64,6 +69,9 @@ interface AdminUser {
export default function AdminPage() {
const [activeTab, setActiveTab] = useState<TabType>('overview')
const [stats, setStats] = useState<AdminStats | null>(null)
const [telemetryDays, setTelemetryDays] = useState(30)
const [telemetry, setTelemetry] = useState<any>(null)
const [referrals, setReferrals] = useState<any>(null)
const [users, setUsers] = useState<AdminUser[]>([])
const [usersTotal, setUsersTotal] = useState(0)
// Selection removed - PremiumTable doesn't support it
@ -77,6 +85,10 @@ export default function AdminPage() {
const [blogPostsTotal, setBlogPostsTotal] = useState(0)
const [schedulerStatus, setSchedulerStatus] = useState<any>(null)
const [systemHealth, setSystemHealth] = useState<any>(null)
const [backups, setBackups] = useState<any[]>([])
const [backingUp, setBackingUp] = useState(false)
const [runningOpsAlerts, setRunningOpsAlerts] = useState(false)
const [opsAlertsHistory, setOpsAlertsHistory] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
@ -99,6 +111,13 @@ export default function AdminPage() {
if (activeTab === 'overview') {
const statsData = await api.getAdminStats()
setStats(statsData)
} else if (activeTab === 'telemetry') {
const [kpis, refKpis] = await Promise.all([
api.getTelemetryKpis(telemetryDays),
api.getReferralKpis(telemetryDays).catch(() => null),
])
setTelemetry(kpis)
setReferrals(refKpis)
} else if (activeTab === 'users') {
const usersData = await api.getAdminUsers(50, 0, searchQuery || undefined)
setUsers(usersData.users)
@ -118,6 +137,10 @@ export default function AdminPage() {
])
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)
@ -183,6 +206,36 @@ export default function AdminPage() {
}
}
const handleCreateBackup = async () => {
setBackingUp(true)
try {
const result = await api.createDbBackup(true)
setSuccess(`Backup created (${Math.round((result.backup.size_bytes || 0) / 1024 / 1024)} MB)`)
const backupData = await api.listDbBackups(20).catch(() => ({ backups: [] }))
setBackups(backupData.backups || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Backup failed')
} finally {
setBackingUp(false)
}
}
const handleRunOpsAlerts = async () => {
setRunningOpsAlerts(true)
try {
const res = await api.runOpsAlertsNow()
const findings = Array.isArray(res?.findings) ? res.findings.length : 0
const sent = res?.send?.sent ?? 0
setSuccess(`Ops alert check done: ${findings} finding(s), emails sent: ${sent}`)
const opsHistory = await api.getOpsAlertsHistory(50).catch(() => ({ events: [] }))
setOpsAlertsHistory(opsHistory.events || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Ops alert check failed')
} finally {
setRunningOpsAlerts(false)
}
}
const handleUpgradeUser = async (userId: number, tier: string) => {
try {
await api.upgradeUser(userId, tier)
@ -232,6 +285,7 @@ export default function AdminPage() {
return (
<AdminLayout
title={activeTab === 'overview' ? 'Overview' :
activeTab === 'telemetry' ? 'Telemetry' :
activeTab === 'users' ? 'User Management' :
activeTab === 'alerts' ? 'Price Alerts' :
activeTab === 'newsletter' ? 'Newsletter' :
@ -307,6 +361,154 @@ export default function AdminPage() {
</div>
)}
{/* Telemetry Tab */}
{activeTab === 'telemetry' && telemetry && (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-accent" />
<span className="text-sm text-foreground-muted">Window</span>
<select
value={telemetryDays}
onChange={(e) => setTelemetryDays(Number(e.target.value))}
className="px-2 py-1.5 bg-background-secondary border border-border/30 rounded-lg text-xs"
>
<option value={7}>7 days</option>
<option value={30}>30 days</option>
<option value={90}>90 days</option>
</select>
<button
onClick={loadAdminData}
className="flex items-center gap-2 px-3 py-1.5 bg-foreground/10 border border-border/30 rounded-lg text-xs hover:bg-foreground/5"
>
<RefreshCw className="w-3 h-3" /> Refresh
</button>
</div>
<div className="text-xs text-foreground-subtle font-mono">
{telemetry.window?.start ? new Date(telemetry.window.start).toLocaleDateString() : '—'} {' '}
{telemetry.window?.end ? new Date(telemetry.window.end).toLocaleDateString() : '—'}
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="Listing Views" value={telemetry.deal?.listing_views || 0} icon={Eye} />
<StatCard title="Inquiries" value={telemetry.deal?.inquiries_created || 0} icon={Mail} />
<StatCard
title="Reply Rate"
value={`${Math.round(((telemetry.deal?.inquiry_reply_rate || 0) * 100))}%`}
subtitle={`${telemetry.deal?.seller_replied_inquiries || 0} replied`}
icon={Send}
accent
/>
<StatCard
title="Inquiry→Sold"
value={`${Math.round(((telemetry.deal?.inquiry_to_sold_listing_rate || 0) * 100))}%`}
subtitle={`${telemetry.deal?.listings_sold || 0} sold`}
icon={Tag}
/>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="Yield Connected" value={telemetry.yield?.connected_domains || 0} icon={Link} />
<StatCard title="Yield Clicks" value={telemetry.yield?.clicks || 0} icon={Activity} />
<StatCard
title="Yield Conv. Rate"
value={`${Math.round(((telemetry.yield?.conversion_rate || 0) * 100))}%`}
subtitle={`${telemetry.yield?.conversions || 0} conversions`}
icon={Target}
accent
/>
<StatCard
title="Payouts Paid"
value={telemetry.yield?.payouts_paid || 0}
subtitle={`${(telemetry.yield?.payouts_paid_amount_total || 0).toFixed(2)} total`}
icon={Wallet}
/>
</div>
{referrals ? (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs text-foreground-subtle font-mono uppercase tracking-wider mb-1">Viral Loop</p>
<p className="text-lg font-medium text-foreground">Referrals</p>
</div>
<div className="text-xs text-foreground-subtle font-mono">
{referrals.window?.start ? new Date(referrals.window.start).toLocaleDateString() : '—'} {' '}
{referrals.window?.end ? new Date(referrals.window.end).toLocaleDateString() : '—'}
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard title="Invite users" value={referrals.totals?.referrers_with_invite_code || 0} icon={Users} />
<StatCard title="Link views" value={referrals.totals?.referral_link_views_window || 0} icon={Eye} />
<StatCard title="Referred signups" value={referrals.totals?.referred_users_window || 0} icon={TrendingUp} accent />
<StatCard title="All-time referred" value={referrals.totals?.referred_users_total || 0} icon={Activity} />
</div>
<PremiumTable
data={referrals.referrers || []}
keyExtractor={(r: any) => r.user_id}
compact
columns={[
{
key: 'email',
header: 'Referrer',
render: (r: any) => (
<div className="space-y-0.5">
<div className="font-medium text-foreground truncate">{r.email}</div>
<div className="text-[11px] text-foreground-subtle font-mono truncate">{r.invite_code || '—'}</div>
</div>
),
},
{
key: 'referred_users_window',
header: `Signups (${telemetryDays}d)`,
align: 'right',
render: (r: any) => <span className="font-mono text-sm text-foreground">{r.referred_users_window || 0}</span>,
},
{
key: 'referred_users_total',
header: 'All-time',
align: 'right',
render: (r: any) => <span className="font-mono text-sm text-foreground">{r.referred_users_total || 0}</span>,
},
{
key: 'referral_link_views_window',
header: 'Link views',
align: 'right',
hideOnMobile: true,
render: (r: any) => (
<span className="font-mono text-sm text-foreground">{r.referral_link_views_window || 0}</span>
),
},
]}
emptyTitle="No referral data"
emptyDescription="Invite codes exist, but no signups were attributed in this window."
/>
</div>
) : null}
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
<h3 className="text-lg font-medium text-foreground mb-4">Latency (Median)</h3>
<div className="grid sm:grid-cols-2 gap-4">
<div className="p-4 bg-background/50 rounded-xl border border-border/20">
<p className="text-xs text-foreground-subtle font-mono uppercase tracking-wider mb-1">Inquiry Seller Reply</p>
<p className="text-2xl font-display text-foreground">
{telemetry.deal?.median_reply_seconds ? `${Math.round(telemetry.deal.median_reply_seconds / 60)}m` : '—'}
</p>
</div>
<div className="p-4 bg-background/50 rounded-xl border border-border/20">
<p className="text-xs text-foreground-subtle font-mono uppercase tracking-wider mb-1">Inquiry Sold</p>
<p className="text-2xl font-display text-foreground">
{telemetry.deal?.median_time_to_sold_seconds ? `${Math.round(telemetry.deal.median_time_to_sold_seconds / 3600)}h` : '—'}
</p>
</div>
</div>
</div>
</div>
)}
{/* Users Tab */}
{activeTab === 'users' && (
<div className="space-y-6">
@ -462,6 +664,14 @@ export default function AdminPage() {
{sendingEmail ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
{sendingEmail ? 'Sending...' : 'Send Test Email'}
</button>
<button onClick={handleCreateBackup} disabled={backingUp} className="w-full flex items-center justify-center gap-2 px-5 py-3 bg-foreground/10 border border-border/30 rounded-xl font-medium disabled:opacity-50">
{backingUp ? <Loader2 className="w-4 h-4 animate-spin" /> : <Database className="w-4 h-4" />}
{backingUp ? 'Backing up...' : 'Create DB Backup'}
</button>
<button onClick={handleRunOpsAlerts} disabled={runningOpsAlerts} className="w-full flex items-center justify-center gap-2 px-5 py-3 bg-foreground/10 border border-border/30 rounded-xl font-medium disabled:opacity-50">
{runningOpsAlerts ? <Loader2 className="w-4 h-4 animate-spin" /> : <Bell className="w-4 h-4" />}
{runningOpsAlerts ? 'Running...' : 'Run Ops Alerts Now'}
</button>
<button onClick={handleTriggerScrape} disabled={scraping} className="w-full flex items-center justify-center gap-2 px-5 py-3 bg-foreground/10 border border-border/30 rounded-xl font-medium disabled:opacity-50">
{scraping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Globe className="w-4 h-4" />}
{scraping ? 'Scraping...' : 'Scrape TLD Prices'}
@ -473,6 +683,61 @@ export default function AdminPage() {
</div>
</div>
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
<h3 className="text-lg font-medium text-foreground mb-4">Backups</h3>
{!backups?.length ? (
<p className="text-sm text-foreground-subtle">No backups found.</p>
) : (
<div className="space-y-2">
{backups.slice(0, 6).map((b: any) => (
<div key={b.path} className="flex items-center justify-between p-3 bg-background/50 rounded-xl border border-border/20">
<div className="min-w-0">
<p className="text-sm text-foreground font-mono truncate">{b.name}</p>
<p className="text-xs text-foreground-subtle">{new Date(b.modified_at).toLocaleString()}</p>
</div>
<div className="text-xs text-foreground-subtle font-mono">
{Math.round((b.size_bytes || 0) / 1024 / 1024)} MB
</div>
</div>
))}
</div>
)}
<p className="text-[11px] text-foreground-subtle mt-3">
Backups run daily if enabled on server. Manual backup requires ENABLE_DB_BACKUPS=true.
</p>
</div>
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
<h3 className="text-lg font-medium text-foreground mb-4">Ops Alerts History</h3>
{!opsAlertsHistory?.length ? (
<p className="text-sm text-foreground-subtle">No ops alert events yet.</p>
) : (
<div className="space-y-2">
{opsAlertsHistory.slice(0, 8).map((e: any) => (
<div key={e.id} className="p-3 bg-background/50 rounded-xl border border-border/20">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm text-foreground truncate">{e.title}</p>
<p className="text-xs text-foreground-subtle font-mono truncate">
{e.alert_key} · {e.status}{e.send_reason ? ` (${e.send_reason})` : ''}
</p>
</div>
<div className="text-xs text-foreground-subtle">
{new Date(e.created_at).toLocaleString()}
</div>
</div>
{e.detail ? (
<p className="mt-2 text-[11px] text-foreground-subtle font-mono break-words">{e.detail}</p>
) : null}
</div>
))}
</div>
)}
<p className="text-[11px] text-foreground-subtle mt-3">
Enable OPS_ALERTS_ENABLED=true and set OPS_ALERT_RECIPIENTS for email delivery.
</p>
</div>
{schedulerStatus && (
<div className="p-6 bg-gradient-to-br from-background-secondary/50 to-background-secondary/20 border border-border/30 rounded-2xl">
<h3 className="text-lg font-medium text-foreground mb-4">Scheduled Jobs</h3>

View File

@ -0,0 +1,209 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { api } from '@/lib/api'
import {
Calendar,
Clock,
Eye,
ArrowLeft,
Tag,
Loader2,
Twitter,
Linkedin,
Copy,
Check,
} from 'lucide-react'
import type { BlogPost } from './types'
export default function BlogPostClient({ initialPost }: { initialPost: BlogPost }) {
const params = useParams()
const slug = params.slug as string
const [post, setPost] = useState<BlogPost | null>(initialPost)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!slug) return
// Always load the full post once to render content and to increment view_count server-side.
// If initialPost already includes content (future-proof), we still refresh once for up-to-date view_count.
void loadPost()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slug])
const loadPost = async () => {
setLoading(true)
setError(null)
try {
const data = await api.getBlogPost(slug)
setPost(data)
} catch {
setError('Blog post not found')
setPost(null)
} finally {
setLoading(false)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const estimateReadTime = (content: string) => {
const words = content.split(/\s+/).length
const minutes = Math.ceil(words / 200)
return `${minutes} min read`
}
const handleCopyLink = async () => {
await navigator.clipboard.writeText(window.location.href)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const shareOnTwitter = () => {
const url = encodeURIComponent(window.location.href)
const text = encodeURIComponent(post?.title || '')
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank')
}
const shareOnLinkedIn = () => {
const url = encodeURIComponent(window.location.href)
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank')
}
if (loading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-10 h-10 text-accent animate-spin mx-auto mb-4" />
<p className="text-foreground-muted">Loading article...</p>
</div>
</div>
)
}
if (error || !post) {
return (
<div className="min-h-screen bg-background relative overflow-hidden flex flex-col">
<Header />
<main className="flex-1 flex items-center justify-center px-4">
<div className="text-center max-w-md">
<h1 className="text-2xl font-bold text-foreground mb-2">Article not found</h1>
<p className="text-foreground-muted mb-6">This post may have been removed or is not published.</p>
<Link href="/blog" className="inline-flex items-center gap-2 text-accent hover:underline">
<ArrowLeft className="w-4 h-4" />
Back to Blog
</Link>
</div>
</main>
<Footer />
</div>
)
}
const content = post.content || ''
return (
<div className="min-h-screen bg-background relative overflow-hidden flex flex-col">
<Header />
<main className="flex-1 pt-28 pb-16 px-4 sm:px-6">
<div className="max-w-4xl mx-auto">
<Link href="/blog" className="inline-flex items-center gap-2 text-foreground-muted hover:text-foreground transition-colors mb-8">
<ArrowLeft className="w-4 h-4" />
Back to Blog
</Link>
<article>
<header className="mb-10">
<h1 className="text-4xl sm:text-5xl font-bold text-foreground mb-4 leading-tight">{post.title}</h1>
{post.excerpt ? <p className="text-lg text-foreground-muted">{post.excerpt}</p> : null}
<div className="flex flex-wrap items-center gap-4 text-sm text-foreground-muted mt-6">
{post.published_at ? (
<span className="inline-flex items-center gap-2">
<Calendar className="w-4 h-4" />
{formatDate(post.published_at)}
</span>
) : null}
{content ? (
<span className="inline-flex items-center gap-2">
<Clock className="w-4 h-4" />
{estimateReadTime(content)}
</span>
) : null}
<span className="inline-flex items-center gap-2">
<Eye className="w-4 h-4" />
{post.view_count.toLocaleString()}
</span>
</div>
{post.cover_image ? (
<div className="mt-8 relative aspect-[16/9] rounded-2xl overflow-hidden border border-white/10">
<Image src={post.cover_image} alt={post.title} fill className="object-cover" />
</div>
) : null}
</header>
<div className="prose prose-invert max-w-none">
{/* backend already sanitizes; we render trusted HTML */}
<div dangerouslySetInnerHTML={{ __html: content }} />
</div>
<footer className="mt-12 pt-8 border-t border-white/10">
{post.tags?.length ? (
<div className="flex flex-wrap gap-2 mb-6">
{post.tags.map(t => (
<span key={t} className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-white/5 border border-white/10 text-xs text-foreground-muted">
<Tag className="w-3 h-3" />
{t}
</span>
))}
</div>
) : null}
<div className="flex flex-wrap items-center gap-3">
<button
onClick={shareOnTwitter}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-white/5 border border-white/10 text-sm hover:bg-white/10 transition-colors"
>
<Twitter className="w-4 h-4" />
Share
</button>
<button
onClick={shareOnLinkedIn}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-white/5 border border-white/10 text-sm hover:bg-white/10 transition-colors"
>
<Linkedin className="w-4 h-4" />
LinkedIn
</button>
<button
onClick={handleCopyLink}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-white/5 border border-white/10 text-sm hover:bg-white/10 transition-colors"
>
{copied ? <Check className="w-4 h-4 text-accent" /> : <Copy className="w-4 h-4" />}
{copied ? 'Copied' : 'Copy link'}
</button>
</div>
</footer>
</article>
</div>
</main>
<Footer />
</div>
)
}

View File

@ -1,356 +1,113 @@
'use client'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Script from 'next/script'
import { SITE_URL } from '@/lib/seo'
import BlogPostClient from './BlogPostClient'
import type { BlogPost } from './types'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { api } from '@/lib/api'
import {
Calendar,
Clock,
Eye,
ArrowLeft,
Tag,
Loader2,
User,
BookOpen,
Twitter,
Linkedin,
Copy,
Check,
} from 'lucide-react'
async function fetchPostMeta(slug: string): Promise<BlogPost | null> {
const baseUrl = (process.env.BACKEND_URL || process.env.NEXT_PUBLIC_SITE_URL || SITE_URL).replace(/\/$/, '')
const res = await fetch(`${baseUrl}/api/v1/blog/posts/${encodeURIComponent(slug)}/meta`, {
next: { revalidate: 3600 },
})
if (res.status === 404) return null
if (!res.ok) throw new Error(`Failed to load blog post meta: ${res.status}`)
return (await res.json()) as BlogPost
}
interface BlogPost {
id: number
title: string
slug: string
excerpt: string | null
content: string
cover_image: string | null
category: string | null
tags: string[]
meta_title: string | null
meta_description: string | null
is_published: boolean
published_at: string | null
created_at: string
updated_at: string
view_count: number
author: {
id: number
name: string | null
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const meta = await fetchPostMeta(slug)
if (!meta) {
return {
title: 'Article not found | Pounce',
robots: { index: false, follow: false },
}
}
const title = meta.meta_title || meta.title
const description = meta.meta_description || meta.excerpt || 'Pounce blog article.'
return {
title,
description,
alternates: { canonical: `${SITE_URL}/blog/${encodeURIComponent(meta.slug)}` },
openGraph: {
title,
description,
url: `${SITE_URL}/blog/${encodeURIComponent(meta.slug)}`,
type: 'article',
images: meta.cover_image ? [{ url: meta.cover_image }] : undefined,
},
}
}
export default function BlogPostPage() {
const params = useParams()
const slug = params.slug as string
const [post, setPost] = useState<BlogPost | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const meta = await fetchPostMeta(slug)
if (!meta) notFound()
useEffect(() => {
if (slug) {
loadPost()
}
}, [slug])
const publishedAt = meta.published_at || meta.created_at
const modifiedAt = meta.updated_at || meta.created_at
const loadPost = async () => {
setLoading(true)
setError(null)
try {
const data = await api.getBlogPost(slug)
setPost(data)
} catch (err) {
setError('Blog post not found')
} finally {
setLoading(false)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const estimateReadTime = (content: string) => {
const words = content.split(/\s+/).length
const minutes = Math.ceil(words / 200)
return `${minutes} min read`
}
const handleCopyLink = async () => {
await navigator.clipboard.writeText(window.location.href)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const shareOnTwitter = () => {
const url = encodeURIComponent(window.location.href)
const text = encodeURIComponent(post?.title || '')
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank')
}
const shareOnLinkedIn = () => {
const url = encodeURIComponent(window.location.href)
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank')
}
if (loading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-10 h-10 text-accent animate-spin mx-auto mb-4" />
<p className="text-foreground-muted">Loading article...</p>
</div>
</div>
)
}
if (error || !post) {
return (
<div className="min-h-screen bg-background relative overflow-hidden flex flex-col">
{/* Background */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
</div>
<Header />
<main className="flex-1 flex items-center justify-center px-4 relative">
<div className="text-center max-w-md">
<div className="w-20 h-20 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-8">
<BookOpen className="w-10 h-10 text-accent" />
</div>
<h1 className="text-3xl font-display text-foreground mb-4">Article Not Found</h1>
<p className="text-foreground-muted mb-8 leading-relaxed">
The article you&apos;re looking for doesn&apos;t exist or has been removed from our collection.
</p>
<Link
href="/blog"
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-all"
>
<ArrowLeft className="w-4 h-4" />
Back to Briefings
</Link>
</div>
</main>
<Footer />
</div>
)
const schema = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
{ '@type': 'ListItem', position: 2, name: 'Blog', item: `${SITE_URL}/blog` },
{
'@type': 'ListItem',
position: 3,
name: meta.title,
item: `${SITE_URL}/blog/${encodeURIComponent(meta.slug)}`,
},
],
},
{
'@type': 'Article',
headline: meta.title,
description: meta.meta_description || meta.excerpt || undefined,
datePublished: publishedAt,
dateModified: modifiedAt,
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `${SITE_URL}/blog/${encodeURIComponent(meta.slug)}`,
},
author: {
'@type': 'Person',
name: meta.author?.name || 'Pounce Team',
},
publisher: {
'@type': 'Organization',
name: 'Pounce',
url: SITE_URL,
},
image: meta.cover_image ? [meta.cover_image] : undefined,
keywords: meta.tags?.length ? meta.tags.join(', ') : undefined,
},
],
}
return (
<div className="min-h-screen bg-background relative overflow-hidden">
{/* Background Effects */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
<div
className="absolute inset-0 opacity-[0.015]"
style={{
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
backgroundSize: '64px 64px',
}}
/>
</div>
<Header />
<main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
<article className="max-w-4xl mx-auto">
{/* Back Link */}
<Link
href="/blog"
className="inline-flex items-center gap-2 text-foreground-muted hover:text-accent transition-colors mb-10 group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
<span className="text-sm font-medium">Back to Briefings</span>
</Link>
{/* Hero Header */}
<header className="mb-12 animate-fade-in">
<div className="flex flex-wrap items-center gap-4 mb-6">
{post.category && (
<span className="text-sm font-semibold text-accent uppercase tracking-wider">
{post.category}
</span>
)}
<span className="flex items-center gap-1.5 text-sm text-foreground-subtle">
<Clock className="w-4 h-4" />
{estimateReadTime(post.content)}
</span>
</div>
<h1 className="font-display text-[2.25rem] sm:text-[3rem] md:text-[3.75rem] leading-[1.05] tracking-[-0.03em] text-foreground mb-8">
{post.title}
</h1>
{post.excerpt && (
<p className="text-xl text-foreground-muted leading-relaxed mb-8 max-w-3xl">
{post.excerpt}
</p>
)}
{/* Meta Row */}
<div className="flex flex-wrap items-center gap-6 pb-8 border-b border-border">
<div className="flex items-center gap-4">
{post.author.name && (
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-accent/10 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-accent" />
</div>
<div>
<p className="text-sm font-medium text-foreground">{post.author.name}</p>
<p className="text-xs text-foreground-subtle">Author</p>
</div>
</div>
)}
</div>
<div className="flex items-center gap-4 text-sm text-foreground-subtle">
<span className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
{post.published_at ? formatDate(post.published_at) : formatDate(post.created_at)}
</span>
<span className="flex items-center gap-2">
<Eye className="w-4 h-4" />
{post.view_count} views
</span>
</div>
{/* Share */}
<div className="flex items-center gap-2 ml-auto">
<button
onClick={shareOnTwitter}
className="p-2.5 text-foreground-subtle hover:text-foreground hover:bg-background-secondary rounded-lg transition-all"
title="Share on Twitter"
>
<Twitter className="w-4 h-4" />
</button>
<button
onClick={shareOnLinkedIn}
className="p-2.5 text-foreground-subtle hover:text-foreground hover:bg-background-secondary rounded-lg transition-all"
title="Share on LinkedIn"
>
<Linkedin className="w-4 h-4" />
</button>
<button
onClick={handleCopyLink}
className="p-2.5 text-foreground-subtle hover:text-foreground hover:bg-background-secondary rounded-lg transition-all"
title="Copy link"
>
{copied ? <Check className="w-4 h-4 text-accent" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
</header>
{/* Cover Image */}
{post.cover_image && (
<div className="relative aspect-[21/9] rounded-2xl overflow-hidden mb-14 bg-background-secondary ring-1 ring-border">
<Image
src={post.cover_image}
alt={post.title}
fill
className="object-cover"
priority
/>
</div>
)}
{/* Content */}
<div
className="prose prose-invert prose-lg max-w-none
prose-headings:font-display prose-headings:tracking-tight prose-headings:text-foreground
prose-h2:text-2xl sm:prose-h2:text-3xl prose-h2:mt-14 prose-h2:mb-6
prose-h3:text-xl sm:prose-h3:text-2xl prose-h3:mt-10 prose-h3:mb-4
prose-p:text-foreground-muted prose-p:leading-[1.8] prose-p:mb-6
prose-a:text-accent prose-a:no-underline hover:prose-a:underline prose-a:font-medium
prose-strong:text-foreground prose-strong:font-semibold
prose-code:text-accent prose-code:bg-accent/10 prose-code:px-2 prose-code:py-1 prose-code:rounded-md prose-code:text-sm prose-code:font-mono
prose-pre:bg-background-secondary prose-pre:border prose-pre:border-border prose-pre:rounded-xl
prose-blockquote:border-l-4 prose-blockquote:border-accent prose-blockquote:bg-accent/5 prose-blockquote:py-4 prose-blockquote:px-8 prose-blockquote:rounded-r-xl prose-blockquote:not-italic prose-blockquote:text-foreground-muted
prose-ul:text-foreground-muted prose-ol:text-foreground-muted
prose-li:marker:text-accent prose-li:mb-2
prose-img:rounded-xl prose-img:ring-1 prose-img:ring-border
"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
{/* Tags */}
{post.tags.length > 0 && (
<div className="mt-16 pt-8 border-t border-border">
<div className="flex items-center gap-3 flex-wrap">
<Tag className="w-4 h-4 text-foreground-subtle" />
{post.tags.map((tag) => (
<Link
key={tag}
href={`/blog?tag=${encodeURIComponent(tag)}`}
className="px-4 py-2 bg-background-secondary/50 border border-border rounded-full text-sm text-foreground-muted hover:text-accent hover:border-accent/30 transition-all"
>
{tag}
</Link>
))}
</div>
</div>
)}
{/* Author Box */}
{post.author.name && (
<div className="mt-16 p-8 bg-background-secondary/50 border border-border rounded-2xl">
<div className="flex items-start gap-5">
<div className="w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center flex-shrink-0">
<User className="w-8 h-8 text-accent" />
</div>
<div>
<p className="text-xs text-foreground-subtle uppercase tracking-wider mb-1">Written by</p>
<h4 className="text-xl font-display text-foreground mb-2">{post.author.name}</h4>
<p className="text-foreground-muted text-sm leading-relaxed">
Expert domain hunter sharing insights and strategies to help you find premium domains.
</p>
</div>
</div>
</div>
)}
{/* CTA */}
<div className="mt-20 relative">
<div className="absolute inset-0 bg-gradient-to-r from-accent/10 via-accent/5 to-accent/10 rounded-3xl blur-xl" />
<div className="relative p-10 sm:p-14 bg-background-secondary/50 backdrop-blur-sm border border-accent/20 rounded-3xl text-center">
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Ready to Hunt?</span>
<h3 className="mt-4 text-2xl sm:text-3xl font-display text-foreground mb-4">
Start finding premium domains today
</h3>
<p className="text-foreground-muted mb-8 max-w-xl mx-auto">
Join thousands of domain hunters using pounce to discover, monitor, and secure valuable domains.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/register"
className="inline-flex items-center justify-center gap-2 px-8 py-4 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-all"
>
Join the Hunt
</Link>
<Link
href="/blog"
className="inline-flex items-center justify-center gap-2 px-8 py-4 bg-background border border-border rounded-xl font-medium text-foreground hover:border-accent/50 transition-all"
>
More Articles
</Link>
</div>
</div>
</div>
</article>
</main>
<Footer />
</div>
<>
<Script
id="blog-article-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
<BlogPostClient initialPost={meta} />
</>
)
}

View File

@ -0,0 +1,22 @@
export interface BlogPost {
id: number
title: string
slug: string
excerpt: string | null
content?: string
cover_image: string | null
category: string | null
tags: string[]
meta_title: string | null
meta_description: string | null
is_published: boolean
published_at: string | null
created_at: string
updated_at: string
view_count: number
author: {
id: number
name: string | null
}
}

View File

@ -0,0 +1,45 @@
import type { Metadata } from 'next'
import Script from 'next/script'
import { SITE_URL } from '@/lib/seo'
export const metadata: Metadata = {
title: 'Blog | Pounce',
description: 'Operator-grade notes on domains: market mechanics, workflows, verification, and monetization.',
alternates: { canonical: `${SITE_URL}/blog` },
openGraph: {
title: 'Blog | Pounce',
description: 'Operator-grade notes on domains: market mechanics, workflows, verification, and monetization.',
url: `${SITE_URL}/blog`,
type: 'website',
},
}
export default function BlogLayout({ children }: { children: React.ReactNode }) {
const schema = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
{ '@type': 'ListItem', position: 2, name: 'Blog', item: `${SITE_URL}/blog` },
],
},
{
'@type': 'CollectionPage',
name: 'Pounce Blog',
description: 'Operator-grade notes on domains.',
url: `${SITE_URL}/blog`,
isPartOf: { '@type': 'WebSite', name: 'Pounce', url: SITE_URL },
},
],
}
return (
<>
<Script id="blog-schema" type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />
{children}
</>
)
}

View File

@ -0,0 +1,474 @@
'use client'
import { useEffect, useState, memo } from 'react'
import { useParams } from 'next/navigation'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import {
Shield,
Clock,
Send,
Loader2,
AlertCircle,
Sparkles,
TrendingUp,
ShieldCheck,
Lock,
ArrowRight,
Check,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
import type { Listing } from './types'
// Tooltip Component
const Tooltip = memo(({ children, content }: { children: React.ReactNode; content: string }) => (
<div className="relative flex items-center group/tooltip w-fit">
{children}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-zinc-900 border border-zinc-800 rounded text-[10px] text-zinc-300 whitespace-nowrap opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none z-50 shadow-xl">
{content}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-zinc-800" />
</div>
</div>
))
Tooltip.displayName = 'Tooltip'
export default function BuyDomainClient({ initialListing }: { initialListing: Listing }) {
const params = useParams()
const slug = params.slug as string
const { isAuthenticated, checkAuth, user, isLoading: authLoading } = useStore()
const [listing, setListing] = useState<Listing | null>(initialListing)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Inquiry form state
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
company: '',
message: '',
offer_amount: '',
})
useEffect(() => {
checkAuth()
}, [checkAuth])
useEffect(() => {
// If the route changes client-side, refetch
if (!initialListing || initialListing.slug !== slug) {
void loadListing()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slug])
// Prefill inquiry identity from authenticated user (no overrides once typed)
useEffect(() => {
if (!isAuthenticated || !user) return
setFormData(prev => ({
...prev,
name: prev.name || (user.name || ''),
email: prev.email || user.email || '',
}))
}, [isAuthenticated, user])
const loadListing = async () => {
setLoading(true)
setError(null)
try {
const data = await api.request<Listing>(`/listings/${slug}`)
setListing(data)
} catch (err: any) {
setError(err.message || 'Listing not found')
setListing(null)
} finally {
setLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
try {
await api.request(`/listings/${slug}/inquire`, {
method: 'POST',
body: JSON.stringify({
...formData,
offer_amount: formData.offer_amount ? parseFloat(formData.offer_amount) : null,
}),
})
setSubmitted(true)
} catch (err: any) {
setError(err.message || 'Failed to submit inquiry')
} finally {
setSubmitting(false)
}
}
const formatPrice = (price: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(price)
}
const getScoreColor = (score: number) => {
if (score >= 80) return 'text-emerald-400'
if (score >= 60) return 'text-amber-400'
return 'text-zinc-500'
}
if (loading) {
return (
<div className="min-h-screen bg-black flex flex-col items-center justify-center relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-zinc-900/50 via-black to-black" />
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin relative z-10" />
</div>
)
}
if (error || !listing) {
return (
<div className="min-h-screen bg-black text-white font-sans selection:bg-emerald-500/30">
<Header />
<main className="min-h-[70vh] flex items-center justify-center relative px-4">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] pointer-events-none" />
<div className="max-w-md w-full text-center relative z-10">
<div className="w-20 h-20 bg-zinc-900 rounded-2xl flex items-center justify-center mx-auto mb-6 border border-zinc-800 shadow-2xl rotate-3">
<AlertCircle className="w-10 h-10 text-zinc-500" />
</div>
<h1 className="text-3xl font-bold text-white mb-3 tracking-tight">Domain Unavailable</h1>
<p className="text-zinc-500 mb-8 leading-relaxed">
The domain you are looking for has been sold, removed, or is temporarily unavailable.
</p>
<Link
href="/buy"
className="inline-flex items-center gap-2 px-8 py-4 bg-white text-black font-bold rounded-full hover:bg-zinc-200 transition-all hover:scale-105"
>
Browse Marketplace
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</main>
<Footer />
</div>
)
}
return (
<div className="min-h-screen bg-black text-white font-sans selection:bg-emerald-500/30">
<Header />
<main className="relative pt-32 pb-20 px-4 sm:px-6 lg:px-8">
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-[1000px] bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-zinc-900/50 via-black to-black" />
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-emerald-500/30 to-transparent" />
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808008_1px,transparent_1px),linear-gradient(to_bottom,#80808008_1px,transparent_1px)] bg-[size:48px_48px] mask-image-gradient-to-b" />
</div>
<div className="max-w-7xl mx-auto relative z-10">
<div className="flex justify-center mb-8 sm:mb-10">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-emerald-500/20 bg-emerald-500/5 text-emerald-400 text-sm font-bold uppercase tracking-widest shadow-[0_0_20px_rgba(16,185,129,0.2)]">
<ShieldCheck className="w-4 h-4" />
Verified Listing
</div>
</div>
<div className="text-center mb-16 sm:mb-24 relative max-w-5xl mx-auto">
<h1 className="font-display text-[2.5rem] sm:text-[4rem] md:text-[5rem] lg:text-[7rem] leading-[0.9] tracking-[-0.03em] text-white drop-shadow-2xl break-words">
{listing.domain}
</h1>
{listing.title && (
<p className="mt-6 sm:mt-8 text-xl sm:text-2xl md:text-3xl text-zinc-400 max-w-3xl mx-auto font-light leading-relaxed">
{listing.title}
</p>
)}
</div>
<div className="grid lg:grid-cols-12 gap-12 lg:gap-24 items-start">
<div className="lg:col-span-7 space-y-12">
{listing.description && (
<div className="prose prose-invert prose-lg max-w-none">
<h3 className="text-2xl font-bold text-white mb-4">About this Asset</h3>
<p className="text-zinc-400 leading-relaxed text-lg whitespace-pre-line">{listing.description}</p>
</div>
)}
<div className="grid sm:grid-cols-2 gap-4">
<div className="p-6 rounded-2xl bg-zinc-900/30 border border-white/5 backdrop-blur-sm relative overflow-hidden group">
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative z-10">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-emerald-500/10 flex items-center justify-center text-emerald-400">
<Sparkles className="w-5 h-5" />
</div>
<span className="text-sm font-bold text-zinc-500 uppercase tracking-wider">Pounce Score</span>
</div>
<div className="flex items-baseline gap-2">
<span className={clsx('text-4xl font-bold', getScoreColor(listing.pounce_score || 0))}>
{listing.pounce_score || 'N/A'}
</span>
<span className="text-lg text-zinc-600">/100</span>
</div>
<p className="mt-2 text-xs text-zinc-500">Based on length, TLD, and market demand.</p>
</div>
</div>
<div className="p-6 rounded-2xl bg-zinc-900/30 border border-white/5 backdrop-blur-sm flex flex-col justify-center relative overflow-hidden group">
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative z-10">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-400">
<TrendingUp className="w-5 h-5" />
</div>
<span className="text-sm font-bold text-zinc-500 uppercase tracking-wider">Est. Value</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold text-white">
{listing.estimated_value ? formatPrice(listing.estimated_value, listing.currency) : '—'}
</span>
</div>
<p className="mt-2 text-xs text-zinc-500">Automated valuation estimate.</p>
</div>
</div>
</div>
<div className="pt-8 border-t border-white/5">
<h3 className="text-lg font-bold text-white mb-6">Secure Transfer Guarantee</h3>
<div className="grid sm:grid-cols-3 gap-6">
<div className="flex flex-col gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-900 flex items-center justify-center text-zinc-400 border border-white/5">
<Lock className="w-4 h-4" />
</div>
<div>
<h4 className="font-bold text-white text-sm">Escrow Service</h4>
<p className="text-xs text-zinc-500 mt-1">Funds held until transfer is complete.</p>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-900 flex items-center justify-center text-zinc-400 border border-white/5">
<Shield className="w-4 h-4" />
</div>
<div>
<h4 className="font-bold text-white text-sm">Verified Owner</h4>
<p className="text-xs text-zinc-500 mt-1">Ownership verified via DNS validation.</p>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-900 flex items-center justify-center text-zinc-400 border border-white/5">
<Clock className="w-4 h-4" />
</div>
<div>
<h4 className="font-bold text-white text-sm">Fast Transfer</h4>
<p className="text-xs text-zinc-500 mt-1">Most transfers complete within 24 hours.</p>
</div>
</div>
</div>
</div>
</div>
<div className="lg:col-span-5 relative">
<div className="sticky top-32">
<div className="absolute -inset-1 bg-gradient-to-b from-emerald-500/20 to-blue-500/20 rounded-3xl blur-2xl opacity-50" />
<div className="relative bg-black border border-white/10 rounded-2xl p-8 shadow-2xl overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
{!submitted ? (
<>
<div className="mb-8">
<p className="text-sm font-medium text-zinc-400 uppercase tracking-widest mb-2">
{listing.price_type === 'fixed' ? 'Buy Now Price' : 'Asking Price'}
</p>
<div className="flex items-baseline gap-2">
{listing.asking_price ? (
<span className="text-5xl font-bold text-white tracking-tight">
{formatPrice(listing.asking_price, listing.currency)}
</span>
) : (
<span className="text-4xl font-bold text-white tracking-tight">Make Offer</span>
)}
{listing.price_type === 'negotiable' && listing.asking_price && (
<span className="px-2 py-1 bg-white/10 rounded text-[10px] font-bold uppercase tracking-wider text-white">
Negotiable
</span>
)}
</div>
</div>
{authLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="w-5 h-5 text-emerald-500 animate-spin" />
</div>
) : !isAuthenticated ? (
<div className="space-y-4 animate-fade-in">
<div className="p-4 rounded-xl border border-white/10 bg-zinc-900/30">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-zinc-900 flex items-center justify-center text-zinc-400 border border-white/5 shrink-0">
<Lock className="w-5 h-5" />
</div>
<div className="flex-1">
<h3 className="font-bold text-white text-lg">Contact requires login</h3>
<p className="text-sm text-zinc-400 mt-1">
To protect sellers from spam, inquiries are only available for logged-in accounts.
</p>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Link
href={`/login?redirect=${encodeURIComponent(`/buy/${slug}`)}`}
className="w-full py-3 bg-white text-black font-bold rounded-xl hover:bg-zinc-200 transition-all flex items-center justify-center gap-2"
>
Login
<ArrowRight className="w-4 h-4" />
</Link>
<Link
href={`/register?redirect=${encodeURIComponent(`/buy/${slug}`)}`}
className="w-full py-3 bg-zinc-900/50 border border-white/10 text-white font-bold rounded-xl hover:border-emerald-500/40 transition-all flex items-center justify-center gap-2"
>
Create Account
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4 animate-fade-in">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-white text-lg">
{listing.asking_price ? 'Purchase Inquiry' : 'Contact Seller'}
</h3>
</div>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<input
type="text"
placeholder="Name"
required
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all"
/>
<input
type="email"
placeholder="Email"
required
value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })}
readOnly={isAuthenticated}
title={isAuthenticated ? 'Uses your account email' : undefined}
className={clsx(
'w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all',
isAuthenticated && 'opacity-70 cursor-not-allowed'
)}
/>
</div>
{isAuthenticated && (
<p className="text-[10px] text-zinc-600 font-mono">Inquiries are sent from your account email.</p>
)}
<input
type="text"
placeholder="Phone (Optional)"
value={formData.phone}
onChange={e => setFormData({ ...formData, phone: e.target.value })}
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all"
/>
{listing.allow_offers && (
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500">$</span>
<input
type="number"
placeholder="Your Offer Amount"
value={formData.offer_amount}
onChange={e => setFormData({ ...formData, offer_amount: e.target.value })}
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg pl-8 pr-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all"
/>
</div>
)}
<textarea
placeholder="I'm interested in this domain..."
rows={3}
required
value={formData.message}
onChange={e => setFormData({ ...formData, message: e.target.value })}
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all resize-none"
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full py-4 bg-white text-black font-bold text-lg rounded-xl hover:bg-zinc-200 transition-all disabled:opacity-50 flex items-center justify-center gap-2 mt-6 shadow-lg shadow-white/10"
>
{submitting ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
{listing.asking_price ? 'Send Purchase Request' : 'Send Offer'}
</button>
<p className="text-center text-xs text-zinc-600 mt-3">Secure escrow transfer available via Escrow.com</p>
</form>
)}
{/* Powered by Pounce (viral loop) */}
{listing.seller_invite_code ? (
<div className="mt-6 pt-6 border-t border-white/10">
<p className="text-xs text-zinc-600 text-center">
Powered by Pounce {' '}
<Link
href={`/register?ref=${encodeURIComponent(listing.seller_invite_code)}`}
className="text-emerald-400 hover:text-emerald-300 underline-offset-4 hover:underline"
>
create your own verified listing
</Link>
</p>
</div>
) : null}
</>
) : (
<div className="text-center py-12 animate-fade-in">
<div className="w-16 h-16 bg-emerald-500/10 rounded-full flex items-center justify-center mx-auto mb-4 text-emerald-400">
<Check className="w-8 h-8" />
</div>
<h3 className="text-2xl font-bold text-white mb-2">Inquiry Sent</h3>
<p className="text-zinc-400">
The seller has been notified. You can continue the conversation in your inbox.
</p>
<div className="mt-6 flex flex-col sm:flex-row gap-3 justify-center">
<Link
href="/terminal/inbox"
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-white text-black font-bold rounded-xl hover:bg-zinc-200 transition-all"
>
Open Inbox
<ArrowRight className="w-4 h-4" />
</Link>
<button
onClick={() => setSubmitted(false)}
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-zinc-900/50 border border-white/10 text-white font-bold rounded-xl hover:border-emerald-500/40 transition-all"
>
Send another message
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</main>
<Footer />
</div>
)
}

View File

@ -1,491 +1,87 @@
'use client'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Script from 'next/script'
import { SITE_URL } from '@/lib/seo'
import BuyDomainClient from './BuyDomainClient'
import type { Listing } from './types'
import { useEffect, useState, memo } from 'react'
import { useParams } from 'next/navigation'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import {
Shield,
CheckCircle,
Clock,
DollarSign,
Mail,
User,
Building,
Phone,
MessageSquare,
Send,
Loader2,
AlertCircle,
Sparkles,
TrendingUp,
Globe,
Calendar,
ExternalLink,
ShieldCheck,
Lock,
ArrowRight,
Check,
Info
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
interface Listing {
domain: string
slug: string
title: string | null
description: string | null
asking_price: number | null
currency: string
price_type: 'bid' | 'fixed' | 'negotiable'
pounce_score: number | null
estimated_value: number | null
is_verified: boolean
allow_offers: boolean
public_url: string
seller_verified: boolean
seller_member_since: string | null
status: string
async function fetchListing(slug: string): Promise<Listing | null> {
const baseUrl = (process.env.BACKEND_URL || process.env.NEXT_PUBLIC_SITE_URL || SITE_URL).replace(/\/$/, '')
const res = await fetch(`${baseUrl}/api/v1/listings/${encodeURIComponent(slug)}`, {
next: { revalidate: 60 },
})
if (res.status === 404) return null
if (!res.ok) throw new Error(`Failed to load listing: ${res.status}`)
return (await res.json()) as Listing
}
// Tooltip Component
const Tooltip = memo(({ children, content }: { children: React.ReactNode; content: string }) => (
<div className="relative flex items-center group/tooltip w-fit">
{children}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-zinc-900 border border-zinc-800 rounded text-[10px] text-zinc-300 whitespace-nowrap opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none z-50 shadow-xl">
{content}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-zinc-800" />
</div>
</div>
))
Tooltip.displayName = 'Tooltip'
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const listing = await fetchListing(slug)
if (!listing) return { title: 'Listing not found | Pounce', robots: { index: false, follow: false } }
export default function BuyDomainPage() {
const params = useParams()
const slug = params.slug as string
const { isAuthenticated, checkAuth, user, isLoading: authLoading } = useStore()
const title = listing.title ? `${listing.domain}${listing.title}` : `${listing.domain} — Verified Domain Listing`
const description =
listing.description ||
`Buy ${listing.domain} via Pounce Direct. DNS-verified seller, clean workflow, and secure transfer.`
const [listing, setListing] = useState<Listing | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Inquiry form state
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
company: '',
message: '',
offer_amount: '',
})
useEffect(() => {
checkAuth()
loadListing()
}, [slug])
// Prefill inquiry identity from authenticated user (no overrides once typed)
useEffect(() => {
if (!isAuthenticated || !user) return
setFormData(prev => ({
...prev,
name: prev.name || (user.name || ''),
email: prev.email || user.email || '',
}))
}, [isAuthenticated, user])
const loadListing = async () => {
setLoading(true)
setError(null)
try {
const data = await api.request<Listing>(`/listings/${slug}`)
setListing(data)
} catch (err: any) {
setError(err.message || 'Listing not found')
} finally {
setLoading(false)
}
return {
title,
description,
alternates: { canonical: `${SITE_URL}/buy/${listing.slug}` },
openGraph: {
title,
description,
url: `${SITE_URL}/buy/${listing.slug}`,
type: 'website',
},
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
try {
await api.request(`/listings/${slug}/inquire`, {
method: 'POST',
body: JSON.stringify({
...formData,
offer_amount: formData.offer_amount ? parseFloat(formData.offer_amount) : null,
}),
})
setSubmitted(true)
} catch (err: any) {
setError(err.message || 'Failed to submit inquiry')
} finally {
setSubmitting(false)
}
}
export default async function BuyDomainPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const listing = await fetchListing(slug)
if (!listing) notFound()
const formatPrice = (price: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(price)
}
const getScoreColor = (score: number) => {
if (score >= 80) return 'text-emerald-400'
if (score >= 60) return 'text-amber-400'
return 'text-zinc-500'
}
if (loading) {
return (
<div className="min-h-screen bg-black flex flex-col items-center justify-center relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-zinc-900/50 via-black to-black" />
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin relative z-10" />
</div>
)
}
if (error || !listing) {
return (
<div className="min-h-screen bg-black text-white font-sans selection:bg-emerald-500/30">
<Header />
<main className="min-h-[70vh] flex items-center justify-center relative px-4">
{/* Background Grid */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] pointer-events-none" />
<div className="max-w-md w-full text-center relative z-10">
<div className="w-20 h-20 bg-zinc-900 rounded-2xl flex items-center justify-center mx-auto mb-6 border border-zinc-800 shadow-2xl rotate-3">
<AlertCircle className="w-10 h-10 text-zinc-500" />
</div>
<h1 className="text-3xl font-bold text-white mb-3 tracking-tight">Domain Unavailable</h1>
<p className="text-zinc-500 mb-8 leading-relaxed">
The domain you are looking for has been sold, removed, or is temporarily unavailable.
</p>
<Link
href="/buy"
className="inline-flex items-center gap-2 px-8 py-4 bg-white text-black font-bold rounded-full hover:bg-zinc-200 transition-all hover:scale-105"
>
Browse Marketplace
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</main>
<Footer />
</div>
)
const schema = {
'@context': 'https://schema.org',
'@type': 'Product',
name: listing.domain,
description: listing.description || listing.title || `Domain ${listing.domain} listed on Pounce Direct.`,
url: `${SITE_URL}/buy/${listing.slug}`,
category: 'Domain Names',
offers: listing.asking_price
? {
'@type': 'Offer',
price: String(listing.asking_price),
priceCurrency: listing.currency || 'USD',
availability: 'https://schema.org/InStock',
url: `${SITE_URL}/buy/${listing.slug}`,
seller: {
'@type': 'Organization',
name: 'Pounce Direct',
},
}
: undefined,
additionalProperty: [
{ '@type': 'PropertyValue', name: 'dns_verified', value: listing.is_verified ? 'true' : 'false' },
{ '@type': 'PropertyValue', name: 'price_type', value: listing.price_type },
{ '@type': 'PropertyValue', name: 'allow_offers', value: listing.allow_offers ? 'true' : 'false' },
],
}
return (
<div className="min-h-screen bg-black text-white font-sans selection:bg-emerald-500/30">
<Header />
{/* Hero Section */}
<main className="relative pt-32 pb-20 px-4 sm:px-6 lg:px-8">
{/* Cinematic Background */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-[1000px] bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-zinc-900/50 via-black to-black" />
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-emerald-500/30 to-transparent" />
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808008_1px,transparent_1px),linear-gradient(to_bottom,#80808008_1px,transparent_1px)] bg-[size:48px_48px] mask-image-gradient-to-b" />
</div>
<div className="max-w-7xl mx-auto relative z-10">
{/* Top Label */}
<div className="flex justify-center mb-8 sm:mb-10">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-emerald-500/20 bg-emerald-500/5 text-emerald-400 text-sm font-bold uppercase tracking-widest shadow-[0_0_20px_rgba(16,185,129,0.2)]">
<ShieldCheck className="w-4 h-4" />
Verified Listing
</div>
</div>
{/* Domain Name */}
<div className="text-center mb-16 sm:mb-24 relative max-w-5xl mx-auto">
<h1 className="font-display text-[2.5rem] sm:text-[4rem] md:text-[5rem] lg:text-[7rem] leading-[0.9] tracking-[-0.03em] text-white drop-shadow-2xl break-words">
{listing.domain}
</h1>
{listing.title && (
<p className="mt-6 sm:mt-8 text-xl sm:text-2xl md:text-3xl text-zinc-400 max-w-3xl mx-auto font-light leading-relaxed">
{listing.title}
</p>
)}
</div>
<div className="grid lg:grid-cols-12 gap-12 lg:gap-24 items-start">
{/* Left Column: Details & Stats */}
<div className="lg:col-span-7 space-y-12">
{/* Description */}
{listing.description && (
<div className="prose prose-invert prose-lg max-w-none">
<h3 className="text-2xl font-bold text-white mb-4">About this Asset</h3>
<p className="text-zinc-400 leading-relaxed text-lg whitespace-pre-line">
{listing.description}
</p>
</div>
)}
{/* Stats Grid */}
<div className="grid sm:grid-cols-2 gap-4">
<div className="p-6 rounded-2xl bg-zinc-900/30 border border-white/5 backdrop-blur-sm relative overflow-hidden group">
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative z-10">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-emerald-500/10 flex items-center justify-center text-emerald-400">
<Sparkles className="w-5 h-5" />
</div>
<span className="text-sm font-bold text-zinc-500 uppercase tracking-wider">Pounce Score</span>
</div>
<div className="flex items-baseline gap-2">
<span className={clsx("text-4xl font-bold", getScoreColor(listing.pounce_score || 0))}>
{listing.pounce_score || 'N/A'}
</span>
<span className="text-lg text-zinc-600">/100</span>
</div>
<p className="mt-2 text-xs text-zinc-500">Based on length, TLD, and market demand.</p>
</div>
</div>
<div className="p-6 rounded-2xl bg-zinc-900/30 border border-white/5 backdrop-blur-sm flex flex-col justify-center relative overflow-hidden group">
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative z-10">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-400">
<TrendingUp className="w-5 h-5" />
</div>
<span className="text-sm font-bold text-zinc-500 uppercase tracking-wider">Est. Value</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold text-white">
{listing.estimated_value ? formatPrice(listing.estimated_value, listing.currency) : '—'}
</span>
</div>
<p className="mt-2 text-xs text-zinc-500">Automated AI valuation estimate.</p>
</div>
</div>
</div>
{/* Trust Section */}
<div className="pt-8 border-t border-white/5">
<h3 className="text-lg font-bold text-white mb-6">Secure Transfer Guarantee</h3>
<div className="grid sm:grid-cols-3 gap-6">
<div className="flex flex-col gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-900 flex items-center justify-center text-zinc-400 border border-white/5">
<Lock className="w-4 h-4" />
</div>
<div>
<h4 className="font-bold text-white text-sm">Escrow Service</h4>
<p className="text-xs text-zinc-500 mt-1">Funds held securely until transfer is complete.</p>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-900 flex items-center justify-center text-zinc-400 border border-white/5">
<Shield className="w-4 h-4" />
</div>
<div>
<h4 className="font-bold text-white text-sm">Verified Owner</h4>
<p className="text-xs text-zinc-500 mt-1">Ownership verified via DNS validation.</p>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-900 flex items-center justify-center text-zinc-400 border border-white/5">
<Clock className="w-4 h-4" />
</div>
<div>
<h4 className="font-bold text-white text-sm">Fast Transfer</h4>
<p className="text-xs text-zinc-500 mt-1">Most transfers completed within 24 hours.</p>
</div>
</div>
</div>
</div>
</div>
{/* Right Column: Action Card */}
<div className="lg:col-span-5 relative">
<div className="sticky top-32">
<div className="absolute -inset-1 bg-gradient-to-b from-emerald-500/20 to-blue-500/20 rounded-3xl blur-2xl opacity-50" />
<div className="relative bg-black border border-white/10 rounded-2xl p-8 shadow-2xl overflow-hidden">
{/* Card Shine */}
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
{!submitted ? (
<>
<div className="mb-8">
<p className="text-sm font-medium text-zinc-400 uppercase tracking-widest mb-2">
{listing.price_type === 'fixed' ? 'Buy Now Price' : 'Asking Price'}
</p>
<div className="flex items-baseline gap-2">
{listing.asking_price ? (
<span className="text-5xl font-bold text-white tracking-tight">
{formatPrice(listing.asking_price, listing.currency)}
</span>
) : (
<span className="text-4xl font-bold text-white tracking-tight">Make Offer</span>
)}
{listing.price_type === 'negotiable' && listing.asking_price && (
<span className="px-2 py-1 bg-white/10 rounded text-[10px] font-bold uppercase tracking-wider text-white">
Negotiable
</span>
)}
</div>
</div>
{/* Auth Gate: inquiries require login */}
{authLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="w-5 h-5 text-emerald-500 animate-spin" />
</div>
) : !isAuthenticated ? (
<div className="space-y-4 animate-fade-in">
<div className="p-4 rounded-xl border border-white/10 bg-zinc-900/30">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-zinc-900 flex items-center justify-center text-zinc-400 border border-white/5 shrink-0">
<Lock className="w-5 h-5" />
</div>
<div className="flex-1">
<h3 className="font-bold text-white text-lg">Contact requires login</h3>
<p className="text-sm text-zinc-400 mt-1">
To protect sellers from spam, inquiries are only available for logged-in accounts.
</p>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Link
href={`/login?redirect=${encodeURIComponent(`/buy/${slug}`)}`}
className="w-full py-3 bg-white text-black font-bold rounded-xl hover:bg-zinc-200 transition-all flex items-center justify-center gap-2"
>
Login
<ArrowRight className="w-4 h-4" />
</Link>
<Link
href={`/register?redirect=${encodeURIComponent(`/buy/${slug}`)}`}
className="w-full py-3 bg-zinc-900/50 border border-white/10 text-white font-bold rounded-xl hover:border-emerald-500/40 transition-all flex items-center justify-center gap-2"
>
Create Account
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4 animate-fade-in">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-white text-lg">
{listing.asking_price ? 'Purchase Inquiry' : 'Contact Seller'}
</h3>
</div>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<input
type="text"
placeholder="Name"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all"
/>
<input
type="email"
placeholder="Email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
readOnly={isAuthenticated}
title={isAuthenticated ? 'Uses your account email' : undefined}
className={clsx(
"w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all",
isAuthenticated && "opacity-70 cursor-not-allowed"
)}
/>
</div>
{isAuthenticated && (
<p className="text-[10px] text-zinc-600 font-mono">
Inquiries are sent from your account email.
</p>
)}
<input
type="text"
placeholder="Phone (Optional)"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all"
/>
{listing.allow_offers && (
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500">$</span>
<input
type="number"
placeholder="Your Offer Amount"
value={formData.offer_amount}
onChange={(e) => setFormData({ ...formData, offer_amount: e.target.value })}
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg pl-8 pr-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all"
/>
</div>
)}
<textarea
placeholder="I'm interested in this domain..."
rows={3}
required
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all resize-none"
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full py-4 bg-white text-black font-bold text-lg rounded-xl hover:bg-zinc-200 transition-all disabled:opacity-50 flex items-center justify-center gap-2 mt-6 shadow-lg shadow-white/10"
>
{submitting ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
{listing.asking_price ? 'Send Purchase Request' : 'Send Offer'}
</button>
<p className="text-center text-xs text-zinc-600 mt-3">
Secure escrow transfer available via Escrow.com
</p>
</form>
)}
</>
) : (
<div className="text-center py-12 animate-fade-in">
<div className="w-16 h-16 bg-emerald-500/10 rounded-full flex items-center justify-center mx-auto mb-4 text-emerald-400">
<Check className="w-8 h-8" />
</div>
<h3 className="text-2xl font-bold text-white mb-2">Inquiry Sent</h3>
<p className="text-zinc-400">The seller has been notified and will contact you shortly.</p>
<button
onClick={() => setSubmitted(false)}
className="mt-6 text-sm text-zinc-500 hover:text-white"
>
Send another message
</button>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</main>
<Footer />
</div>
<>
<Script id="pounce-buy-schema" type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />
<BuyDomainClient initialListing={listing} />
</>
)
}

View File

@ -0,0 +1,19 @@
export interface Listing {
domain: string
slug: string
title: string | null
description: string | null
asking_price: number | null
currency: string
price_type: 'bid' | 'fixed' | 'negotiable'
pounce_score: number | null
estimated_value: number | null
is_verified: boolean
allow_offers: boolean
public_url: string
seller_verified: boolean
seller_member_since: string | null
seller_invite_code?: string | null
status?: string
}

View File

@ -0,0 +1,45 @@
import type { Metadata } from 'next'
import Script from 'next/script'
import { SITE_URL } from '@/lib/seo'
export const metadata: Metadata = {
title: 'Pounce Direct | Verified Domain Listings',
description: 'Browse DNS-verified domain listings. Clean workflow, zero commission, and operator-grade trust signals.',
alternates: { canonical: `${SITE_URL}/buy` },
openGraph: {
title: 'Pounce Direct | Verified Domain Listings',
description: 'Browse DNS-verified domain listings with operator-grade trust signals.',
url: `${SITE_URL}/buy`,
type: 'website',
},
}
export default function BuyLayout({ children }: { children: React.ReactNode }) {
const schema = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
{ '@type': 'ListItem', position: 2, name: 'Pounce Direct', item: `${SITE_URL}/buy` },
],
},
{
'@type': 'CollectionPage',
name: 'Pounce Direct Listings',
description: 'DNS-verified domain listings on Pounce.',
url: `${SITE_URL}/buy`,
isPartOf: { '@type': 'WebSite', name: 'Pounce', url: SITE_URL },
},
],
}
return (
<>
<Script id="buy-schema" type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />
{children}
</>
)
}

View File

@ -0,0 +1,45 @@
import type { Metadata } from 'next'
import Script from 'next/script'
import { SITE_URL } from '@/lib/seo'
export const metadata: Metadata = {
title: 'Contact | Pounce',
description: 'Support and partnerships. Reach the Pounce team via email.',
alternates: { canonical: `${SITE_URL}/contact` },
openGraph: {
title: 'Contact | Pounce',
description: 'Support and partnerships. Reach the Pounce team via email.',
url: `${SITE_URL}/contact`,
type: 'website',
},
}
export default function ContactLayout({ children }: { children: React.ReactNode }) {
const schema = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
{ '@type': 'ListItem', position: 2, name: 'Contact', item: `${SITE_URL}/contact` },
],
},
{
'@type': 'ContactPage',
name: 'Contact Pounce',
url: `${SITE_URL}/contact`,
description: 'Support and partnerships.',
isPartOf: { '@type': 'WebSite', name: 'Pounce', url: SITE_URL },
},
],
}
return (
<>
<Script id="contact-schema" type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />
{children}
</>
)
}

View File

@ -267,7 +267,7 @@ export default function ContactPage() {
<div className="flex items-start gap-4">
<MapPin className="w-4 h-4 text-accent mt-1" />
<div>
<p className="text-white font-bold">POUNCE AG</p>
<p className="text-white font-bold">Gugger Digital Services</p>
<p>Bahnhofstrasse 100</p>
<p>8001 Zurich, Switzerland</p>
</div>
@ -275,7 +275,12 @@ export default function ContactPage() {
<div className="flex items-start gap-4">
<Mail className="w-4 h-4 text-accent mt-1" />
<div>
<p className="text-white hover:text-accent transition-colors cursor-pointer">support@pounce.dev</p>
<a
href="mailto:hello@pounce.ch"
className="text-white hover:text-accent transition-colors"
>
hello@pounce.ch
</a>
<p>General Inquiries</p>
</div>
</div>

View File

@ -88,8 +88,8 @@ const sections = [
content: `
<p>If you have questions about our use of cookies, please contact us at:</p>
<ul>
<li>Email: privacy@pounce.dev</li>
<li>Address: pounce AG, Zurich, Switzerland</li>
<li>Email: hello@pounce.ch</li>
<li>Address: Gugger Digital Services, Zurich, Switzerland</li>
</ul>
`,
},
@ -120,7 +120,7 @@ export default function CookiesPage() {
{/* Introduction */}
<div className="prose-custom mb-12 animate-slide-up">
<p className="text-body-lg text-foreground-muted leading-relaxed">
This Cookie Policy explains how pounce uses cookies and similar technologies
This Cookie Policy explains how Pounce uses cookies and similar technologies
to recognize you when you visit our website.
</p>
</div>

View File

@ -0,0 +1,136 @@
import type { Metadata } from 'next'
import Script from 'next/script'
import { SITE_URL } from '@/lib/seo'
type TldCompareResponse = {
tld: string
type?: string
description?: string
registry?: string
introduced?: number | null
registrars: Array<{
name: string
registration_price: number
renewal_price: number
transfer_price?: number | null
source?: string
}>
cheapest_registrar: string
cheapest_price: number
price_range: { min: number; max: number; avg: number }
registrar_count: number
}
async function fetchTldCompare(tld: string): Promise<TldCompareResponse | null> {
const baseUrl = (process.env.BACKEND_URL || process.env.NEXT_PUBLIC_SITE_URL || SITE_URL).replace(/\/$/, '')
const res = await fetch(`${baseUrl}/api/v1/tld-prices/${encodeURIComponent(tld)}/compare`, {
next: { revalidate: 3600 },
})
if (res.status === 404) return null
if (!res.ok) throw new Error(`Failed to fetch tld compare: ${res.status}`)
return (await res.json()) as TldCompareResponse
}
export async function generateMetadata({
params,
}: {
params: Promise<{ tld: string }>
}): Promise<Metadata> {
const { tld } = await params
const tldLower = String(tld).toLowerCase().replace(/^\./, '')
const tldUpper = tldLower.toUpperCase()
const compare = await fetchTldCompare(tldLower)
if (!compare) {
return {
title: `.${tldUpper} — Not found | Pounce`,
robots: { index: false, follow: false },
}
}
const title = `.${tldUpper} Domain Pricing & Renewal Costs — Compare Registrars | Pounce`
const description = `Compare real registrar pricing for .${tldUpper} domains. Cheapest registration: $${compare.cheapest_price.toFixed(
2
)} at ${compare.cheapest_registrar}. Track renewals and avoid renewal traps.`
return {
title,
description,
alternates: { canonical: `${SITE_URL}/discover/${tldLower}` },
openGraph: {
title,
description,
url: `${SITE_URL}/discover/${tldLower}`,
type: 'article',
},
}
}
export default async function DiscoverTldLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ tld: string }>
}) {
const { tld } = await params
const tldLower = String(tld).toLowerCase().replace(/^\./, '')
const tldUpper = tldLower.toUpperCase()
const compare = await fetchTldCompare(tldLower)
const registrars = compare?.registrars || []
const low = compare?.price_range?.min
const high = compare?.price_range?.max
const schema = compare
? {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL },
{ '@type': 'ListItem', position: 2, name: 'Discover', item: `${SITE_URL}/discover` },
{ '@type': 'ListItem', position: 3, name: `.${tldUpper}`, item: `${SITE_URL}/discover/${tldLower}` },
],
},
{
'@type': 'Product',
name: `.${tldUpper} Domain Extension`,
description: compare.description || `Registrar comparison and pricing for .${tldUpper} domains.`,
category: compare.type || 'Domain Extension',
url: `${SITE_URL}/discover/${tldLower}`,
offers: {
'@type': 'AggregateOffer',
priceCurrency: 'USD',
...(typeof low === 'number' ? { lowPrice: low } : {}),
...(typeof high === 'number' ? { highPrice: high } : {}),
offerCount: registrars.length,
offers: registrars.slice(0, 8).map(r => ({
'@type': 'Offer',
price: r.registration_price,
priceCurrency: 'USD',
seller: { '@type': 'Organization', name: r.name },
availability: 'https://schema.org/InStock',
url: `${SITE_URL}/discover/${tldLower}`,
})),
},
},
],
}
: null
return (
<>
{schema ? (
<Script
id="discover-tld-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
) : null}
{children}
</>
)
}

View File

@ -1,147 +0,0 @@
import type { Metadata } from 'next'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.com'
export async function generateTLDMetadata(tld: string, price?: number, trend?: number): Promise<Metadata> {
const tldUpper = tld.toUpperCase()
const trendText = trend ? (trend > 0 ? `+${trend.toFixed(1)}%` : `${trend.toFixed(1)}%`) : ''
const title = `.${tldUpper} Domain Pricing & Market Analysis ${new Date().getFullYear()}`
const description = `Complete .${tldUpper} domain pricing intelligence${price ? ` starting at $${price.toFixed(2)}` : ''}${trendText ? ` (${trendText} trend)` : ''}. Compare registration, renewal, and transfer costs across major registrars. Updated daily with market data and price alerts.`
return {
title,
description,
keywords: [
`.${tld} domain`,
`.${tld} domain price`,
`.${tld} domain registration`,
`.${tld} domain renewal`,
`.${tld} domain cost`,
`buy .${tld} domain`,
`.${tld} registrar comparison`,
`.${tld} domain market`,
`${tld} tld pricing`,
`${tld} domain investing`,
],
openGraph: {
title,
description,
url: `${siteUrl}/discover/${tld}`,
type: 'article',
images: [
{
url: `${siteUrl}/api/og/tld?tld=${tld}&price=${price || 0}&trend=${trend || 0}`,
width: 1200,
height: 630,
alt: `.${tldUpper} Domain Pricing`,
},
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [`${siteUrl}/api/og/tld?tld=${tld}&price=${price || 0}&trend=${trend || 0}`],
},
alternates: {
canonical: `${siteUrl}/discover/${tld}`,
},
}
}
/**
* Generate structured data for TLD page (JSON-LD)
*/
export function generateTLDStructuredData(tld: string, price: number, trend: number, registrars: any[]) {
const tldUpper = tld.toUpperCase()
return {
'@context': 'https://schema.org',
'@graph': [
// Article
{
'@type': 'Article',
headline: `.${tldUpper} Domain Pricing & Market Analysis`,
description: `Complete pricing intelligence for .${tldUpper} domains including registration, renewal, and transfer costs across major registrars.`,
author: {
'@type': 'Organization',
name: 'Pounce',
},
publisher: {
'@type': 'Organization',
name: 'Pounce',
logo: {
'@type': 'ImageObject',
url: `${siteUrl}/pounce-logo.png`,
},
},
datePublished: new Date().toISOString(),
dateModified: new Date().toISOString(),
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `${siteUrl}/discover/${tld}`,
},
},
// Product (Domain TLD)
{
'@type': 'Product',
name: `.${tldUpper} Domain`,
description: `Premium .${tldUpper} top-level domain extension`,
brand: {
'@type': 'Brand',
name: 'ICANN',
},
offers: {
'@type': 'AggregateOffer',
priceCurrency: 'USD',
lowPrice: registrars.length > 0 ? Math.min(...registrars.map(r => r.price || Infinity)).toFixed(2) : price.toFixed(2),
highPrice: registrars.length > 0 ? Math.max(...registrars.map(r => r.price || 0)).toFixed(2) : price.toFixed(2),
offerCount: registrars.length || 1,
offers: registrars.slice(0, 5).map(r => ({
'@type': 'Offer',
price: (r.price || 0).toFixed(2),
priceCurrency: 'USD',
seller: {
'@type': 'Organization',
name: r.name,
},
availability: 'https://schema.org/InStock',
})),
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: trend > 10 ? '3.5' : trend > 0 ? '4.0' : '4.5',
reviewCount: '1000',
bestRating: '5',
worstRating: '1',
},
},
// Breadcrumb
{
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: siteUrl,
},
{
'@type': 'ListItem',
position: 2,
name: 'Discover',
item: `${siteUrl}/discover`,
},
{
'@type': 'ListItem',
position: 3,
name: `.${tldUpper}`,
item: `${siteUrl}/discover/${tld}`,
},
],
},
],
}
}

View File

@ -0,0 +1,13 @@
import type { Metadata } from 'next'
import { SITE_URL } from '@/lib/seo'
export const metadata: Metadata = {
title: 'Forgot Password | Pounce',
robots: { index: false, follow: false },
alternates: { canonical: `${SITE_URL}/forgot-password` },
}
export default function ForgotPasswordLayout({ children }: { children: React.ReactNode }) {
return children
}

View File

@ -8,7 +8,7 @@ const companyInfo = [
{
icon: Building,
label: 'Company Name',
value: 'pounce AG',
value: 'Gugger Digital Services',
},
{
icon: Globe,
@ -30,19 +30,19 @@ const companyInfo = [
const contacts = [
{
type: 'General Inquiries',
email: 'info@pounce.dev',
email: 'hello@pounce.ch',
},
{
type: 'Technical Support',
email: 'support@pounce.dev',
email: 'hello@pounce.ch',
},
{
type: 'Legal / Privacy',
email: 'legal@pounce.dev',
email: 'hello@pounce.ch',
},
{
type: 'Press / Media',
email: 'press@pounce.dev',
email: 'hello@pounce.ch',
},
]
@ -63,7 +63,7 @@ export default function ImprintPage() {
Imprint
</h1>
<p className="text-body-lg text-foreground-muted">
Legal information about pounce AG
Legal information about Gugger Digital Services
</p>
</div>
@ -159,11 +159,11 @@ export default function ImprintPage() {
<h2 className="text-heading-sm font-medium text-foreground mb-6">Regulatory Information</h2>
<div className="p-6 bg-background-secondary/30 border border-border rounded-xl">
<p className="text-body text-foreground-muted leading-relaxed">
pounce AG is a company registered in Switzerland. We operate in compliance with Swiss
Gugger Digital Services is a company registered in Switzerland. We operate in compliance with Swiss
law and applicable EU regulations including GDPR for EU residents. For complaints or
regulatory matters, please contact our legal department at{' '}
<a href="mailto:legal@pounce.dev" className="text-accent hover:text-accent-hover">
legal@pounce.dev
<a href="mailto:hello@pounce.ch" className="text-accent hover:text-accent-hover">
hello@pounce.ch
</a>.
</p>
</div>

View File

@ -0,0 +1,13 @@
import type { Metadata } from 'next'
import { SITE_URL } from '@/lib/seo'
export const metadata: Metadata = {
title: 'Intelligence | Pounce',
robots: { index: false, follow: false },
alternates: { canonical: `${SITE_URL}/intelligence` },
}
export default function IntelligenceLayout({ children }: { children: React.ReactNode }) {
return children
}

View File

@ -1,26 +1,9 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { redirect } from 'next/navigation'
/**
* Redirect /intelligence to /tld-pricing
* This page is kept for backwards compatibility
* Legacy route: canonical TLD intelligence lives under /discover.
*/
export default function IntelligenceRedirect() {
const router = useRouter()
useEffect(() => {
router.replace('/tld-pricing')
}, [router])
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-foreground-muted">Redirecting to TLD Pricing...</p>
</div>
</div>
)
redirect('/discover')
}

View File

@ -44,7 +44,7 @@ export default function ImprintPage() {
</div>
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-accent mb-8">Operator</h3>
<div className="space-y-2 font-display text-2xl text-white">
<p>POUNCE AG</p>
<p>Gugger Digital Services</p>
<p className="text-white/40">Bahnhofstrasse 100</p>
<p className="text-white/40">8001 Zurich</p>
<p className="text-white/40">Switzerland</p>
@ -78,7 +78,7 @@ export default function ImprintPage() {
<div className="space-y-6">
<div className="flex justify-between items-end border-b border-white/5 pb-4">
<span className="text-sm font-mono text-white/40">Commercial Register</span>
<span className="text-lg font-display text-white">Pounce AG</span>
<span className="text-lg font-display text-white">Gugger Digital Services</span>
</div>
<div className="flex justify-between items-end border-b border-white/5 pb-4">
<span className="text-sm font-mono text-white/40">Number</span>

View File

@ -140,7 +140,7 @@ export default function TermsPage() {
</p>
<div className="bg-[#020202] border border-white/10 p-6">
<p className="font-mono text-xs text-white/40 uppercase tracking-wide leading-relaxed">
POUNCE AG shall not be liable for any indirect, incidental, or consequential damages arising from the use of our services, including but not limited to lost profits or lost data.
Gugger Digital Services shall not be liable for any indirect, incidental, or consequential damages arising from the use of our services, including but not limited to lost profits or lost data.
</p>
</div>
</div>
@ -152,7 +152,7 @@ export default function TermsPage() {
{/* Footer of Card */}
<div className="mt-20 pt-8 border-t border-white/10 flex justify-between items-center opacity-50">
<div className="font-mono text-[10px] uppercase tracking-widest text-left text-white/30">
Pounce Legal Dept.<br/>
Gugger Digital Services<br/>
Zurich, CH
</div>
<div className="font-mono text-[10px] uppercase tracking-widest text-right">

View File

@ -0,0 +1,13 @@
import type { Metadata } from 'next'
import { SITE_URL } from '@/lib/seo'
export const metadata: Metadata = {
title: 'Login | Pounce',
robots: { index: false, follow: false },
alternates: { canonical: `${SITE_URL}/login` },
}
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return children
}

View File

@ -1,6 +1,8 @@
import type { Metadata } from 'next'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.com'
import { SITE_URL } from '@/lib/seo'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || SITE_URL
export const homeMetadata: Metadata = {
title: 'Pounce - Domain Intelligence for Investors | The Market Never Sleeps',
@ -100,13 +102,6 @@ export function getHomeStructuredData() {
priceCurrency: 'USD',
offerCount: '3',
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '4.8',
ratingCount: '450',
bestRating: '5',
worstRating: '1',
},
featureList: [
'Live domain monitoring',
'Spam-filtered auction feed',

View File

@ -0,0 +1,13 @@
import type { Metadata } from 'next'
import { SITE_URL } from '@/lib/seo'
export const metadata: Metadata = {
title: 'OAuth Callback | Pounce',
robots: { index: false, follow: false },
alternates: { canonical: `${SITE_URL}/oauth/callback` },
}
export default function OAuthCallbackLayout({ children }: { children: React.ReactNode }) {
return children
}

View File

@ -1,189 +0,0 @@
import type { Metadata } from 'next'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.com'
export const pricingMetadata: Metadata = {
title: 'Pricing Plans - Domain Intelligence & Market Access',
description: 'Choose your domain intelligence plan. Scout (Free), Trader ($9/mo), or Tycoon ($29/mo). Live market data, spam-filtered auctions, and portfolio monitoring. 0% commission on Pounce Direct sales.',
keywords: [
'domain intelligence pricing',
'domain monitoring subscription',
'domain portfolio management',
'domain marketplace free',
'domain auction monitoring',
'TLD price tracking',
'domain investing plans',
'domain valuation tools',
],
openGraph: {
title: 'Pricing Plans - Pounce Domain Intelligence',
description: 'Scout (Free), Trader ($9/mo), Tycoon ($29/mo). Live market data, spam-filtered auctions, and portfolio monitoring.',
url: `${siteUrl}/pricing`,
type: 'website',
images: [
{
url: `${siteUrl}/og-pricing.png`,
width: 1200,
height: 630,
alt: 'Pounce Pricing Plans',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Pricing Plans - Pounce Domain Intelligence',
description: 'Scout (Free), Trader ($9/mo), Tycoon ($29/mo). Clean market feed, faster monitoring, and verified listings.',
images: [`${siteUrl}/og-pricing.png`],
},
alternates: {
canonical: `${siteUrl}/pricing`,
},
}
/**
* Structured data for pricing page
*/
export function getPricingStructuredData() {
return {
'@context': 'https://schema.org',
'@type': 'ProductGroup',
name: 'Pounce Domain Intelligence Subscriptions',
description: 'Domain intelligence and monitoring subscriptions for investors and traders',
brand: {
'@type': 'Brand',
name: 'Pounce',
},
hasVariant: [
{
'@type': 'Product',
name: 'Scout Plan',
description: 'Free domain intelligence - 5 watchlist domains, basic market access, email alerts',
brand: {
'@type': 'Brand',
name: 'Pounce',
},
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
seller: {
'@type': 'Organization',
name: 'Pounce',
},
},
},
{
'@type': 'Product',
name: 'Trader Plan',
description: 'Professional domain intelligence - 50 watchlist domains, spam-filtered feed, hourly monitoring, renewal price intel, 5 marketplace listings',
brand: {
'@type': 'Brand',
name: 'Pounce',
},
offers: {
'@type': 'Offer',
price: '9.00',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
priceValidUntil: '2025-12-31',
seller: {
'@type': 'Organization',
name: 'Pounce',
},
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '4.8',
reviewCount: '250',
},
},
{
'@type': 'Product',
name: 'Tycoon Plan',
description: 'Enterprise domain intelligence - 500 watchlist domains, 10-minute monitoring, priority alerts, SMS notifications, unlimited portfolio, 50 marketplace listings, featured badge',
brand: {
'@type': 'Brand',
name: 'Pounce',
},
offers: {
'@type': 'Offer',
price: '29.00',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
priceValidUntil: '2025-12-31',
seller: {
'@type': 'Organization',
name: 'Pounce',
},
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '4.9',
reviewCount: '150',
},
},
],
}
}
/**
* FAQ Structured Data for Pricing
*/
export function getPricingFAQStructuredData() {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: 'Is there a free plan?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Yes! Scout plan is free forever with 5 watchlist domains, basic market access, and email alerts. Perfect for getting started with domain intelligence.',
},
},
{
'@type': 'Question',
name: 'Can I upgrade or downgrade anytime?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Absolutely. You can upgrade or downgrade your plan at any time. Changes take effect immediately, and billing is prorated.',
},
},
{
'@type': 'Question',
name: 'Do you charge commission on marketplace sales?',
acceptedAnswer: {
'@type': 'Answer',
text: 'No! Pounce charges 0% commission on all marketplace transactions. Unlike competitors who charge 15-20%, you keep 100% of your sale price.',
},
},
{
'@type': 'Question',
name: 'What payment methods do you accept?',
acceptedAnswer: {
'@type': 'Answer',
text: 'We accept all major credit cards (Visa, Mastercard, American Express) and debit cards through Stripe. All payments are secure and encrypted.',
},
},
{
'@type': 'Question',
name: 'How often are domains monitored?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Scout: Daily checks. Trader: Hourly checks. Tycoon: Every 10 minutes. You get instant email alerts when watched domains become available or when price changes occur.',
},
},
{
'@type': 'Question',
name: 'Can I cancel anytime?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Yes, there are no contracts or commitments. Cancel anytime from your settings. Your data remains accessible until the end of your billing period.',
},
},
],
}
}

View File

@ -58,7 +58,7 @@ const sections = [
{
title: '5. Data Retention',
content: `
<p>We retain your personal information for as long as your account is active or as needed to provide you services. You can request deletion of your account and associated data at any time by contacting us at privacy@pounce.dev.</p>
<p>We retain your personal information for as long as your account is active or as needed to provide you services. You can request deletion of your account and associated data at any time by contacting us at hello@pounce.ch.</p>
<p>We may retain certain information as required by law or for legitimate business purposes, such as fraud prevention.</p>
`,
},
@ -73,7 +73,7 @@ const sections = [
<li><strong>Data Portability:</strong> Request a copy of your data in a machine-readable format.</li>
<li><strong>Objection:</strong> Object to processing of your personal information.</li>
</ul>
<p>To exercise any of these rights, please contact us at privacy@pounce.dev.</p>
<p>To exercise any of these rights, please contact us at hello@pounce.ch.</p>
`,
},
{
@ -93,8 +93,8 @@ const sections = [
content: `
<p>If you have any questions about this Privacy Policy, please contact us at:</p>
<ul>
<li>Email: privacy@pounce.dev</li>
<li>Address: pounce AG, Zurich, Switzerland</li>
<li>Email: hello@pounce.ch</li>
<li>Address: Gugger Digital Services, Zurich, Switzerland</li>
</ul>
`,
},
@ -125,7 +125,7 @@ export default function PrivacyPage() {
{/* Introduction */}
<div className="prose-custom mb-12 animate-slide-up">
<p className="text-body-lg text-foreground-muted leading-relaxed">
At pounce, we take your privacy seriously. This Privacy Policy explains how we collect,
At Pounce, we take your privacy seriously. This Privacy Policy explains how we collect,
use, disclose, and safeguard your information when you use our domain monitoring service.
</p>
</div>

View File

@ -0,0 +1,13 @@
import type { Metadata } from 'next'
import { SITE_URL } from '@/lib/seo'
export const metadata: Metadata = {
title: 'Register | Pounce',
robots: { index: false, follow: false },
alternates: { canonical: `${SITE_URL}/register` },
}
export default function RegisterLayout({ children }: { children: React.ReactNode }) {
return children
}

View File

@ -63,6 +63,13 @@ function RegisterForm() {
// Get redirect URL from query params
const redirectTo = searchParams.get('redirect') || '/terminal/radar'
const refFromUrl = searchParams.get('ref')
const getCookie = (name: string): string | null => {
if (typeof document === 'undefined') return null
const m = document.cookie.match(new RegExp(`(?:^|; )${name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\\\$&')}=([^;]*)`))
return m ? decodeURIComponent(m[1]) : null
}
// Load OAuth providers
useEffect(() => {
@ -75,7 +82,8 @@ function RegisterForm() {
setLoading(true)
try {
await register(email, password)
const ref = refFromUrl || getCookie('pounce_ref') || undefined
await register(email, password, undefined, ref || undefined)
if (redirectTo !== '/terminal/radar') {
localStorage.setItem('pounce_redirect_after_login', redirectTo)

View File

@ -0,0 +1,13 @@
import type { Metadata } from 'next'
import { SITE_URL } from '@/lib/seo'
export const metadata: Metadata = {
title: 'Reset Password | Pounce',
robots: { index: false, follow: false },
alternates: { canonical: `${SITE_URL}/reset-password` },
}
export default function ResetPasswordLayout({ children }: { children: React.ReactNode }) {
return children
}

View File

@ -12,6 +12,7 @@ export default function robots(): MetadataRoute.Robots {
'/api/',
'/admin/',
'/command/',
'/tld-pricing/',
'/_next/',
'/static/',
],
@ -24,6 +25,7 @@ export default function robots(): MetadataRoute.Robots {
'/api/',
'/admin/',
'/command/',
'/tld-pricing/',
],
},
],

View File

@ -1,125 +1,136 @@
import { MetadataRoute } from 'next'
import { POPULAR_TLDS, SITE_URL } from '@/lib/seo'
import { SITE_URL } from '@/lib/seo'
// All TLDs for sitemap - statically defined to avoid build-time API calls
const ALL_TLDS = [
// Generic TLDs
'com', 'net', 'org', 'info', 'biz', 'name', 'pro',
// New gTLDs - Popular
'io', 'ai', 'co', 'app', 'dev', 'xyz', 'online', 'store', 'shop', 'tech',
'site', 'club', 'fun', 'space', 'website', 'live', 'news', 'blog', 'cloud',
'digital', 'agency', 'studio', 'media', 'design', 'solutions', 'consulting',
'group', 'team', 'work', 'zone', 'center', 'network', 'systems', 'services',
// Finance
'finance', 'money', 'bank', 'insurance', 'invest', 'capital', 'fund', 'trading',
'exchange', 'cash', 'tax', 'accountant', 'financial', 'investments',
// Tech
'technology', 'software', 'computer', 'hosting', 'email', 'mobile', 'phone',
'web', 'page', 'link', 'click', 'download', 'stream',
// Business
'business', 'company', 'enterprises', 'inc', 'ltd', 'llc', 'gmbh', 'holdings',
'ventures', 'partners', 'associates', 'global', 'international', 'world',
// Lifestyle
'life', 'health', 'fitness', 'beauty', 'fashion', 'style', 'luxury', 'vip',
'cool', 'lol', 'wtf', 'fail', 'win', 'best', 'top', 'plus', 'one',
// Crypto
'crypto', 'nft', 'web3', 'blockchain', 'bitcoin', 'defi', 'dao', 'token', 'eth',
// Creative
'art', 'photography', 'photos', 'pics', 'gallery', 'graphics', 'video', 'film',
'music', 'band', 'audio', 'radio', 'tv', 'show', 'movie', 'theater',
// Food & Drink
'restaurant', 'cafe', 'coffee', 'bar', 'pub', 'wine', 'beer', 'pizza', 'kitchen',
// Real Estate
'house', 'homes', 'property', 'properties', 'estate', 'land', 'apartments', 'condos',
// Travel
'travel', 'tours', 'holiday', 'vacation', 'flights', 'hotel', 'hotels', 'resort',
// Education
'education', 'academy', 'school', 'college', 'university', 'training', 'courses',
// Sports
'sport', 'football', 'soccer', 'golf', 'tennis', 'racing', 'bike', 'run', 'fit',
// ccTLDs - Europe
'ch', 'de', 'at', 'uk', 'co.uk', 'fr', 'es', 'it', 'nl', 'be', 'pl', 'pt', 'se',
'no', 'fi', 'dk', 'ie', 'cz', 'sk', 'hu', 'ro', 'gr', 'ru', 'ua',
// ccTLDs - Americas
'us', 'ca', 'mx', 'br', 'ar', 'cl', 'co', 'pe', 'vc',
// ccTLDs - Asia Pacific
'jp', 'cn', 'kr', 'in', 'sg', 'hk', 'tw', 'au', 'nz', 'ph', 'my', 'id', 'th', 'vn',
// ccTLDs - Other
'za', 'ae', 'il', 'sa', 'ng', 'ke', 'eg',
// Popular alternatives
'cc', 'tv', 'me', 'ws', 'la', 'sx', 'gg', 'to', 'fm', 'am', 'im',
]
// This generates a static sitemap - no API calls during build
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = SITE_URL
const now = new Date()
// Static pages with high priority
const staticPages: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: now,
changeFrequency: 'daily',
priority: 1.0,
},
{
url: `${baseUrl}/discover`,
lastModified: now,
changeFrequency: 'daily',
priority: 0.9,
},
{
url: `${baseUrl}/acquire`,
lastModified: now,
changeFrequency: 'hourly',
priority: 0.9,
},
{
url: `${baseUrl}/yield`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.8,
},
{
url: `${baseUrl}/pricing`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.8,
},
{
url: `${baseUrl}/about`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/blog`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.7,
},
{
url: `${baseUrl}/login`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.3,
},
{
url: `${baseUrl}/register`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.4,
},
]
// TLD pages - statically generated
const tldPages: MetadataRoute.Sitemap = ALL_TLDS.map(tld => ({
url: `${baseUrl}/tld/${tld}`,
lastModified: now,
changeFrequency: 'daily' as const,
priority: 0.7,
}))
return [...staticPages, ...tldPages]
type TldListResponse = {
tlds: string[]
latest_recorded_at: string | null
}
type BlogListResponse = {
posts: Array<{ slug: string; published_at?: string | null; updated_at?: string | null }>
total?: number
limit?: number
offset?: number
}
type ListingPublic = {
slug: string
}
async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url, {
// Cache to protect the backend. Sitemaps don't need to be real-time.
next: { revalidate: 3600 },
})
if (!res.ok) {
throw new Error(`Failed to fetch ${url}: ${res.status}`)
}
return (await res.json()) as T
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || SITE_URL
const now = new Date()
const staticPages: MetadataRoute.Sitemap = [
{ url: baseUrl, lastModified: now, changeFrequency: 'daily', priority: 1.0 },
{ url: `${baseUrl}/discover`, lastModified: now, changeFrequency: 'daily', priority: 0.9 },
{ url: `${baseUrl}/acquire`, lastModified: now, changeFrequency: 'hourly', priority: 0.9 },
{ url: `${baseUrl}/buy`, lastModified: now, changeFrequency: 'hourly', priority: 0.8 },
{ url: `${baseUrl}/auctions`, lastModified: now, changeFrequency: 'hourly', priority: 0.8 },
{ url: `${baseUrl}/yield`, lastModified: now, changeFrequency: 'weekly', priority: 0.8 },
{ url: `${baseUrl}/pricing`, lastModified: now, changeFrequency: 'weekly', priority: 0.8 },
{ url: `${baseUrl}/about`, lastModified: now, changeFrequency: 'monthly', priority: 0.5 },
{ url: `${baseUrl}/blog`, lastModified: now, changeFrequency: 'weekly', priority: 0.7 },
{ url: `${baseUrl}/contact`, lastModified: now, changeFrequency: 'monthly', priority: 0.4 },
{ url: `${baseUrl}/privacy`, lastModified: now, changeFrequency: 'yearly', priority: 0.2 },
{ url: `${baseUrl}/terms`, lastModified: now, changeFrequency: 'yearly', priority: 0.2 },
{ url: `${baseUrl}/imprint`, lastModified: now, changeFrequency: 'yearly', priority: 0.2 },
{ url: `${baseUrl}/cookies`, lastModified: now, changeFrequency: 'yearly', priority: 0.2 },
// We intentionally do not include /login and /register in the sitemap.
]
const out: MetadataRoute.Sitemap = [...staticPages]
// ----------------------------
// Discover (TLD) pages (DB-driven)
// ----------------------------
try {
const tlds = await fetchJson<TldListResponse>(`${baseUrl}/api/v1/tld-prices/tlds?limit=20000`)
const lastMod = tlds.latest_recorded_at ? new Date(tlds.latest_recorded_at) : now
for (const tld of tlds.tlds || []) {
if (!tld) continue
out.push({
url: `${baseUrl}/discover/${encodeURIComponent(tld)}`,
lastModified: lastMod,
changeFrequency: 'daily',
priority: 0.7,
})
}
} catch {
// If DB isn't ready yet, we still serve a valid sitemap for static pages.
}
// ----------------------------
// Blog post pages
// ----------------------------
try {
const limit = 200
const maxPages = 25 // hard safety cap
let offset = 0
let page = 0
while (page < maxPages) {
const blog = await fetchJson<BlogListResponse>(`${baseUrl}/api/v1/blog/posts?limit=${limit}&offset=${offset}`)
const posts = blog.posts || []
for (const post of posts) {
if (!post?.slug) continue
const ts = post.updated_at || post.published_at
out.push({
url: `${baseUrl}/blog/${encodeURIComponent(post.slug)}`,
lastModified: ts ? new Date(ts) : now,
changeFrequency: 'weekly',
priority: 0.6,
})
}
// Stop when API reports total or when a page is not full.
if (typeof blog.total === 'number' && offset + limit >= blog.total) break
if (posts.length < limit) break
offset += limit
page += 1
}
} catch {
// ignore
}
// ----------------------------
// Public listing pages (Pounce Direct)
// ----------------------------
try {
const limit = 50
const maxPages = 30 // 1500 listings max in sitemap
for (let page = 0; page < maxPages; page += 1) {
const offset = page * limit
const listings = await fetchJson<ListingPublic[]>(
`${baseUrl}/api/v1/listings?limit=${limit}&offset=${offset}&clean_only=true&sort_by=newest`
)
const rows = listings || []
for (const l of rows) {
if (!l?.slug) continue
out.push({
url: `${baseUrl}/buy/${encodeURIComponent(l.slug)}`,
lastModified: now,
changeFrequency: 'daily',
priority: 0.6,
})
}
if (rows.length < limit) break
}
} catch {
// ignore
}
return out
}

View File

@ -0,0 +1,387 @@
'use client'
import { useEffect, useMemo, useState, useCallback } from 'react'
import { useSearchParams } from 'next/navigation'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { Sidebar } from '@/components/Sidebar'
import {
Loader2,
MessageSquare,
X,
Send,
Lock,
Target,
Gavel,
Eye,
TrendingUp,
Menu,
Settings,
LogOut,
Crown,
Zap,
} from 'lucide-react'
import Link from 'next/link'
import Image from 'next/image'
import clsx from 'clsx'
type Thread = {
id: number
listing_id: number
domain: string
slug: string
status: string
created_at: string
closed_at: string | null
closed_reason: string | null
}
type Message = {
id: number
inquiry_id: number
listing_id: number
sender_user_id: number
body: string
created_at: string
}
export default function InboxPage() {
const { user, subscription, logout, checkAuth } = useStore()
const searchParams = useSearchParams()
const openInquiryId = searchParams.get('inquiry')
const [threads, setThreads] = useState<Thread[]>([])
const [loading, setLoading] = useState(true)
const [menuOpen, setMenuOpen] = useState(false)
const [activeThread, setActiveThread] = useState<Thread | null>(null)
const [messages, setMessages] = useState<Message[]>([])
const [loadingMessages, setLoadingMessages] = useState(false)
const [sending, setSending] = useState(false)
const [draft, setDraft] = useState('')
const [error, setError] = useState<string | null>(null)
useEffect(() => { checkAuth() }, [checkAuth])
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap
const drawerNavSections = [
{ title: 'Discover', items: [
{ href: '/terminal/radar', 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)
setError(null)
try {
const data = await api.getMyInquiryThreads()
setThreads(data)
} catch (err: any) {
setError(err?.message || 'Failed to load inbox')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadThreads() }, [loadThreads])
const threadsById = useMemo(() => {
const map = new Map<number, Thread>()
threads.forEach(t => map.set(t.id, t))
return map
}, [threads])
useEffect(() => {
if (!openInquiryId) return
const id = Number(openInquiryId)
if (!Number.isFinite(id)) return
const t = threadsById.get(id)
if (t) setActiveThread(t)
}, [openInquiryId, threadsById])
const loadMessages = useCallback(async (thread: Thread) => {
setLoadingMessages(true)
setError(null)
try {
const data = await api.getInquiryMessagesAsBuyer(thread.id)
setMessages(data)
} catch (err: any) {
setError(err?.message || 'Failed to load messages')
} finally {
setLoadingMessages(false)
}
}, [])
useEffect(() => {
if (!activeThread) return
loadMessages(activeThread)
}, [activeThread, loadMessages])
const sendMessage = useCallback(async () => {
if (!activeThread) return
const body = draft.trim()
if (!body) return
setSending(true)
setError(null)
try {
const created = await api.sendInquiryMessageAsBuyer(activeThread.id, body)
setDraft('')
setMessages(prev => [...prev, created])
} catch (err: any) {
setError(err?.message || 'Failed to send')
} finally {
setSending(false)
}
}, [activeThread, draft])
return (
<div className="min-h-screen bg-[#020202]">
<div className="hidden lg:block"><Sidebar /></div>
<main className="lg:pl-[240px]">
{/* MOBILE HEADER */}
<header className="lg:hidden sticky top-0 z-40 bg-[#020202]/95 backdrop-blur-md border-b border-white/[0.08]" style={{ paddingTop: 'env(safe-area-inset-top)' }}>
<div className="px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Inbox</span>
</div>
<button onClick={() => setMenuOpen(true)} className="p-2 text-white/40">
<Menu className="w-5 h-5" />
</button>
</div>
</div>
</header>
{/* DESKTOP HEADER */}
<section className="hidden lg:block px-10 pt-10 pb-6">
<div className="flex items-end justify-between gap-6">
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Inbox</span>
</div>
<h1 className="font-display text-[2.5rem] leading-[1] tracking-[-0.02em]">
<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.
</p>
</div>
</div>
</section>
<section className="px-4 lg:px-10 pb-28 lg:pb-10">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
) : error ? (
<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 ? (
<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
</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>
<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}` : ''}
</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>
))}
</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="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>
<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">
{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>
) : (
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>
</div>
<div className="text-sm text-white/80 whitespace-pre-line">{m.body}</div>
</div>
))
)}
</div>
<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.
</div>
) : (
<div className="flex gap-2">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Write a message..."
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"
/>
<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"
>
{sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
Send
</button>
</div>
)}
</div>
</>
)}
</div>
</div>
)}
</section>
{/* MOBILE DRAWER */}
{menuOpen && (
<div className="lg:hidden fixed inset-0 z-50">
<button onClick={() => setMenuOpen(false)} className="absolute inset-0 bg-black/70" />
<div className="absolute right-0 top-0 bottom-0 w-[320px] bg-[#020202] border-l border-white/[0.08]">
<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} />
<div>
<div className="text-sm font-bold text-white">Terminal</div>
<div className="text-[10px] font-mono text-white/40">{tierName}</div>
</div>
</div>
<button onClick={() => setMenuOpen(false)} className="p-1 text-white/40 hover:text-white">
<X className="w-5 h-5" />
</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>
</button>
</div>
</div>
</div>
</div>
)}
</main>
</div>
)
}

View File

@ -33,6 +33,10 @@ interface Listing {
verification_status: string
is_verified: boolean
status: string
sold_at?: string | null
sold_reason?: string | null
sold_price?: number | null
sold_currency?: string | null
view_count: number
inquiry_count: number
public_url: string
@ -66,6 +70,7 @@ export default function MyListingsPage() {
const [showCreateWizard, setShowCreateWizard] = useState(false)
const [selectedListing, setSelectedListing] = useState<Listing | null>(null)
const [leadsListing, setLeadsListing] = useState<Listing | null>(null)
const [soldListing, setSoldListing] = useState<Listing | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
@ -336,6 +341,7 @@ export default function MyListingsPage() {
onVerify={() => setSelectedListing(listing)}
onPublish={() => handlePublish(listing)}
onLeads={() => setLeadsListing(listing)}
onMarkSold={() => setSoldListing(listing)}
isDeleting={deletingId === listing.id}
/>
))}
@ -412,6 +418,14 @@ export default function MyListingsPage() {
onClose={() => setLeadsListing(null)}
/>
)}
{soldListing && (
<MarkSoldModal
listing={soldListing}
onClose={() => setSoldListing(null)}
onDone={() => { loadListings(); setSoldListing(null) }}
/>
)}
</div>
)
}
@ -427,6 +441,7 @@ function ListingRow({
onVerify,
onPublish,
onLeads,
onMarkSold,
isDeleting
}: {
listing: Listing
@ -435,6 +450,7 @@ function ListingRow({
onVerify: () => void
onPublish: () => void
onLeads: () => void
onMarkSold: () => void
isDeleting: boolean
}) {
const isDraft = listing.status === 'draft'
@ -483,6 +499,16 @@ function ListingRow({
<ExternalLink className="w-3 h-3" />View
</a>
)}
{isActive && (
<button
type="button"
onClick={onMarkSold}
className="px-4 py-2 border border-white/[0.08] text-white/40 hover:text-accent"
title="Mark as sold"
>
<CheckCircle className="w-4 h-4" />
</button>
)}
{listing.inquiry_count > 0 && (
<button
type="button"
@ -540,6 +566,16 @@ function ListingRow({
<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"
title="Mark as sold"
>
<CheckCircle className="w-3.5 h-3.5" />
</button>
)}
{listing.inquiry_count > 0 && (
<button
type="button"
@ -560,15 +596,123 @@ function ListingRow({
)
}
// ============================================================================
// MARK SOLD MODAL (GMV tracking)
// ============================================================================
function MarkSoldModal({ listing, onClose, onDone }: { listing: Listing; onClose: () => void; onDone: () => void }) {
const [reason, setReason] = useState<'sold_on_pounce' | 'sold_off_platform' | 'removed' | 'other'>('sold_on_pounce')
const [price, setPrice] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async () => {
setSaving(true)
setError(null)
try {
const soldPrice = price.trim() ? Number(price) : null
if (soldPrice !== null && !Number.isFinite(soldPrice)) {
throw new Error('Invalid price')
}
await api.updateListing(listing.id, {
status: 'sold',
sold_reason: reason,
sold_price: soldPrice,
sold_currency: listing.currency || 'USD',
})
onDone()
} catch (err: any) {
setError(err?.message || 'Failed')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={onClose} className="absolute inset-0 bg-black/70" aria-label="Close" />
<div className="relative w-full max-w-md border border-white/[0.10] bg-[#020202] shadow-2xl">
<div className="flex items-start justify-between gap-4 p-4 border-b border-white/[0.08]">
<div>
<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>
<button type="button" onClick={onClose} className="p-1 text-white/40 hover:text-white" aria-label="Close">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4 space-y-3">
{error && <div className="p-3 border border-rose-400/20 bg-rose-400/5 text-rose-300 text-xs font-mono">{error}</div>}
<div>
<label className="block text-[10px] font-mono text-white/40 uppercase mb-2">Reason</label>
<select
value={reason}
onChange={(e) => setReason(e.target.value as any)}
className="w-full px-3 py-2 bg-[#0A0A0A] border border-white/10 text-white font-mono text-sm focus:outline-none focus:border-accent"
>
<option value="sold_on_pounce">Sold on Pounce</option>
<option value="sold_off_platform">Sold off-platform</option>
<option value="removed">Removed</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label className="block text-[10px] font-mono text-white/40 uppercase mb-2">Deal Value (optional)</label>
<input
type="number"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="e.g. 2500"
className="w-full 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"
/>
<div className="mt-1 text-[10px] font-mono text-white/30">Currency: {listing.currency || 'USD'}</div>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-2 border border-white/10 text-white/60 text-[10px] font-mono uppercase hover:text-white hover:bg-white/[0.03]"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="flex-1 py-2 bg-accent text-black text-[10px] font-bold uppercase disabled:opacity-50 flex items-center justify-center gap-2"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
Confirm
</button>
</div>
</div>
</div>
</div>
)
}
// ============================================================================
// LEADS MODAL
// ============================================================================
function LeadsModal({ listing, onClose }: { listing: Listing; onClose: () => void }) {
const { user } = useStore()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [inquiries, setInquiries] = useState<ListingInquiry[]>([])
const [updatingId, setUpdatingId] = useState<number | null>(null)
const [closingId, setClosingId] = useState<number | null>(null)
const [closeReason, setCloseReason] = useState('')
const [threadInquiry, setThreadInquiry] = useState<ListingInquiry | null>(null)
const [threadMessages, setThreadMessages] = useState<any[]>([])
const [loadingThread, setLoadingThread] = useState(false)
const [sendingThread, setSendingThread] = useState(false)
const [threadDraft, setThreadDraft] = useState('')
useEffect(() => {
let mounted = true
@ -595,10 +739,13 @@ function LeadsModal({ listing, onClose }: { listing: Listing; onClose: () => voi
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(amount)
}
const updateInquiryStatus = async (inquiryId: number, status: 'new' | 'read' | 'replied' | 'spam') => {
const updateInquiryStatus = async (
inquiryId: number,
data: { status: 'new' | 'read' | 'replied' | 'closed' | 'spam'; reason?: string | null }
) => {
setUpdatingId(inquiryId)
try {
const updated = await api.updateListingInquiry(listing.id, inquiryId, status) as ListingInquiry
const updated = await api.updateListingInquiry(listing.id, inquiryId, data) as ListingInquiry
setInquiries(prev => prev.map(i => i.id === inquiryId ? { ...i, ...updated } : i))
} catch (err: any) {
setError(err?.message || 'Failed to update inquiry')
@ -607,6 +754,41 @@ function LeadsModal({ listing, onClose }: { listing: Listing; onClose: () => voi
}
}
const openThread = async (inq: ListingInquiry) => {
setThreadInquiry(inq)
setLoadingThread(true)
setError(null)
try {
const msgs = await api.getInquiryMessagesAsSeller(listing.id, inq.id)
setThreadMessages(msgs)
if (!inq.read_at) {
await updateInquiryStatus(inq.id, { status: 'read' })
}
} catch (err: any) {
setError(err?.message || 'Failed to load thread')
} finally {
setLoadingThread(false)
}
}
const sendThreadMessage = async () => {
if (!threadInquiry) return
const body = threadDraft.trim()
if (!body) return
setSendingThread(true)
setError(null)
try {
const created = await api.sendInquiryMessageAsSeller(listing.id, threadInquiry.id, body)
setThreadDraft('')
setThreadMessages(prev => [...prev, created])
await updateInquiryStatus(threadInquiry.id, { status: 'replied' })
} catch (err: any) {
setError(err?.message || 'Failed to send message')
} finally {
setSendingThread(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={onClose} className="absolute inset-0 bg-black/70" aria-label="Close" />
@ -688,7 +870,7 @@ function LeadsModal({ listing, onClose }: { listing: Listing; onClose: () => voi
{!inq.read_at && (
<button
type="button"
onClick={() => updateInquiryStatus(inq.id, 'read')}
onClick={() => updateInquiryStatus(inq.id, { status: 'read' })}
disabled={updatingId === inq.id}
className="inline-flex items-center gap-2 px-3 py-2 bg-white/[0.03] border border-white/[0.10] text-white/60 hover:text-white hover:bg-white/[0.06] text-[10px] font-mono uppercase tracking-wider transition-colors disabled:opacity-50"
title="Mark as read"
@ -699,7 +881,28 @@ function LeadsModal({ listing, onClose }: { listing: Listing; onClose: () => voi
)}
<button
type="button"
onClick={() => updateInquiryStatus(inq.id, 'spam')}
onClick={() => openThread(inq)}
className="inline-flex items-center gap-2 px-3 py-2 bg-accent/10 border border-accent/20 text-accent hover:bg-accent/20 text-[10px] font-mono uppercase tracking-wider transition-colors"
title="Open thread"
>
<MessageSquare className="w-4 h-4" />
Thread
</button>
{inq.status !== 'closed' && (
<button
type="button"
onClick={() => { setClosingId(inq.id); setCloseReason('') }}
disabled={updatingId === inq.id}
className="inline-flex items-center gap-2 px-3 py-2 bg-white/[0.03] border border-white/[0.10] text-white/60 hover:text-white hover:bg-white/[0.06] text-[10px] font-mono uppercase tracking-wider transition-colors disabled:opacity-50"
title="Close inquiry"
>
<CheckCircle className="w-4 h-4" />
Close
</button>
)}
<button
type="button"
onClick={() => updateInquiryStatus(inq.id, { status: 'spam', reason: 'spam' })}
disabled={updatingId === inq.id}
className="inline-flex items-center gap-2 px-3 py-2 bg-white/[0.03] border border-white/[0.10] text-white/60 hover:text-rose-300 hover:border-rose-400/30 hover:bg-rose-400/10 text-[10px] font-mono uppercase tracking-wider transition-colors disabled:opacity-50"
title="Mark as spam"
@ -709,19 +912,134 @@ function LeadsModal({ listing, onClose }: { listing: Listing; onClose: () => voi
</button>
<a
href={`mailto:${encodeURIComponent(inq.email)}?subject=${encodeURIComponent(`Re: ${listing.domain}`)}`}
onClick={() => updateInquiryStatus(inq.id, 'replied')}
className="inline-flex items-center gap-2 px-3 py-2 bg-white/[0.05] border border-white/[0.10] text-white/70 hover:text-white hover:bg-white/[0.08] text-[10px] font-mono uppercase tracking-wider transition-colors"
title="Reply via email"
className="inline-flex items-center gap-2 px-3 py-2 bg-white/[0.03] border border-white/[0.10] text-white/50 hover:text-white hover:bg-white/[0.06] text-[10px] font-mono uppercase tracking-wider transition-colors"
title="Fallback: reply via email"
>
Reply
<Send className="w-4 h-4" />
Email
<Mail className="w-4 h-4" />
</a>
</div>
{closingId === inq.id && (
<div className="mt-3 p-3 border border-white/[0.10] bg-white/[0.02]">
<div className="text-[10px] font-mono text-white/40 uppercase tracking-wider mb-2">Close reason</div>
<div className="flex flex-col sm:flex-row gap-2">
<select
value={closeReason}
onChange={(e) => setCloseReason(e.target.value)}
className="flex-1 px-3 py-2 bg-[#0A0A0A] border border-white/10 text-white font-mono text-xs focus:outline-none focus:border-accent"
>
<option value="">Select reason</option>
<option value="sold_on_pounce">Sold on Pounce</option>
<option value="sold_off_platform">Sold off-platform</option>
<option value="low_offer">Low offer</option>
<option value="no_fit">No fit</option>
<option value="other">Other</option>
</select>
<div className="flex gap-2">
<button
type="button"
onClick={() => setClosingId(null)}
className="px-3 py-2 border border-white/10 text-white/60 text-[10px] font-mono uppercase hover:text-white hover:bg-white/[0.03]"
>
Cancel
</button>
<button
type="button"
disabled={!closeReason || updatingId === inq.id}
onClick={async () => {
await updateInquiryStatus(inq.id, { status: 'closed', reason: closeReason })
setClosingId(null)
}}
className="px-3 py-2 bg-accent text-black text-[10px] font-bold uppercase disabled:opacity-50"
>
{updatingId === inq.id ? 'Closing…' : 'Confirm'}
</button>
</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* THREAD MODAL (nested) */}
{threadInquiry && (
<div className="absolute inset-0 bg-black/80 flex items-center justify-center p-4">
<div className="w-full max-w-xl border border-white/[0.10] bg-[#020202] shadow-2xl">
<div className="flex items-start justify-between gap-4 p-4 border-b border-white/[0.08]">
<div>
<div className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Thread</div>
<div className="text-sm font-bold text-white">{threadInquiry.name}</div>
<div className="text-[10px] font-mono text-white/40 mt-1">{threadInquiry.email}</div>
</div>
<button
type="button"
onClick={() => setThreadInquiry(null)}
className="p-1 text-white/40 hover:text-white"
aria-label="Close thread"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4 space-y-3 max-h-[50vh] overflow-auto">
{loadingThread ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
) : threadMessages.length === 0 ? (
<div className="text-sm font-mono text-white/40">No messages yet.</div>
) : (
threadMessages.map((m: any) => (
<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' : '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>
<div className="p-4 border-t border-white/[0.08]">
{threadInquiry.status === 'closed' || threadInquiry.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.
</div>
) : (
<div className="flex gap-2">
<textarea
value={threadDraft}
onChange={(e) => setThreadDraft(e.target.value)}
placeholder="Write a message..."
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"
/>
<button
type="button"
onClick={sendThreadMessage}
disabled={sendingThread || !threadDraft.trim()}
className="px-4 py-2 bg-accent text-black text-xs font-bold uppercase disabled:opacity-50 flex items-center gap-2"
>
{sendingThread ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
Send
</button>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
)

View File

@ -108,6 +108,23 @@ export default function SettingsPage() {
const [changingPlan, setChangingPlan] = useState<string | null>(null)
const [profileForm, setProfileForm] = useState({ name: '', email: '' })
const [inviteLink, setInviteLink] = useState<string | null>(null)
const [inviteStats, setInviteStats] = useState<{
window_days: number
referred_users_total: number
qualified_referrals_total: number
referral_link_views_window: number
bonus_domains: number
next_reward_at: number
badge: string | null
cooldown_days?: number
disqualified_cooldown_total?: number
disqualified_missing_ip_total?: number
disqualified_shared_ip_total?: number
disqualified_duplicate_ip_total?: number
} | null>(null)
const [inviteLoading, setInviteLoading] = useState(false)
const [inviteCopied, setInviteCopied] = useState(false)
const [notificationPrefs, setNotificationPrefs] = useState({
domain_availability: true,
price_alerts: true,
@ -128,6 +145,26 @@ export default function SettingsPage() {
if (user) setProfileForm({ name: user.name || '', email: user.email || '' })
}, [user])
const loadInviteLink = useCallback(async () => {
if (!isAuthenticated) return
setInviteLoading(true)
try {
const res = await api.getReferralLink()
setInviteLink(res.url)
setInviteStats(res.stats ?? null)
} catch {
// don't block settings if it fails
} finally {
setInviteLoading(false)
}
}, [isAuthenticated])
useEffect(() => {
if (isAuthenticated && activeTab === 'profile') {
void loadInviteLink()
}
}, [isAuthenticated, activeTab, loadInviteLink])
useEffect(() => {
if (isAuthenticated && activeTab === 'notifications') loadPriceAlerts()
}, [isAuthenticated, activeTab])
@ -369,38 +406,129 @@ export default function SettingsPage() {
{/* Profile Tab */}
{activeTab === 'profile' && (
<div className="bg-[#0A0A0A] border border-white/[0.08]">
<div className="px-4 py-2 border-b border-white/[0.06] bg-black/40">
<span className="text-[10px] font-mono text-white/40">Profile Information</span>
<div className="space-y-4">
<div className="bg-[#0A0A0A] border border-white/[0.08]">
<div className="px-4 py-2 border-b border-white/[0.06] bg-black/40">
<span className="text-[10px] font-mono text-white/40">Profile Information</span>
</div>
<form onSubmit={handleSaveProfile} className="p-4 lg:p-6 space-y-4 max-w-md">
<div>
<label className="block text-[10px] font-mono text-white/40 uppercase tracking-wider mb-2">Name</label>
<input
type="text"
value={profileForm.name}
onChange={(e) => setProfileForm({ ...profileForm, name: e.target.value })}
className="w-full px-4 py-3 bg-white/[0.02] border border-white/[0.08] text-white placeholder:text-white/20 outline-none focus:border-accent/50"
/>
</div>
<div>
<label className="block text-[10px] font-mono text-white/40 uppercase tracking-wider mb-2">Email</label>
<input
type="email"
value={profileForm.email}
disabled
className="w-full px-4 py-3 bg-white/[0.01] border border-white/[0.06] text-white/40 cursor-not-allowed"
/>
</div>
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-5 py-2.5 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white disabled:opacity-50"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Save
</button>
</form>
</div>
<form onSubmit={handleSaveProfile} className="p-4 lg:p-6 space-y-4 max-w-md">
<div>
<label className="block text-[10px] font-mono text-white/40 uppercase tracking-wider mb-2">Name</label>
<input
type="text"
value={profileForm.name}
onChange={(e) => setProfileForm({ ...profileForm, name: e.target.value })}
className="w-full px-4 py-3 bg-white/[0.02] border border-white/[0.08] text-white placeholder:text-white/20 outline-none focus:border-accent/50"
/>
<div className="bg-[#0A0A0A] border border-white/[0.08]">
<div className="px-4 py-2 border-b border-white/[0.06] bg-black/40 flex items-center justify-between">
<span className="text-[10px] font-mono text-white/40">Invite</span>
<span className="text-[10px] font-mono text-white/30">Earn trust by inviting operators</span>
</div>
<div>
<label className="block text-[10px] font-mono text-white/40 uppercase tracking-wider mb-2">Email</label>
<input
type="email"
value={profileForm.email}
disabled
className="w-full px-4 py-3 bg-white/[0.01] border border-white/[0.06] text-white/40 cursor-not-allowed"
/>
<div className="p-4 lg:p-6">
<p className="text-sm text-white/60 mb-4">
Share your invite link. If someone signs up through it, we attribute the referral securely.
</p>
<div className="flex items-center gap-2">
<input
value={inviteLink || (inviteLoading ? 'Loading…' : '')}
readOnly
className="flex-1 px-4 py-3 bg-white/[0.02] border border-white/[0.08] text-white/70 outline-none"
/>
<button
type="button"
disabled={!inviteLink || inviteLoading}
onClick={async () => {
if (!inviteLink) return
await navigator.clipboard.writeText(inviteLink)
setInviteCopied(true)
setTimeout(() => setInviteCopied(false), 1500)
}}
className="px-4 py-3 bg-white text-black text-xs font-bold uppercase tracking-wider disabled:opacity-50"
>
{inviteCopied ? 'Copied' : 'Copy'}
</button>
</div>
<p className="mt-2 text-[10px] font-mono text-white/30">
Link expires: never. Stored as a cookie on first visit (30 days).
</p>
{inviteStats ? (
<div className="mt-4 grid grid-cols-2 lg:grid-cols-4 gap-2">
<div className="p-3 bg-white/[0.02] border border-white/[0.06]">
<p className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Qualified</p>
<p className="text-lg text-white font-semibold">{inviteStats.qualified_referrals_total}</p>
<p className="text-[10px] text-white/30">Verified + active plan</p>
</div>
<div className="p-3 bg-white/[0.02] border border-white/[0.06]">
<p className="text-[10px] font-mono text-white/40 uppercase tracking-wider">All-time</p>
<p className="text-lg text-white font-semibold">{inviteStats.referred_users_total}</p>
<p className="text-[10px] text-white/30">Referred signups</p>
</div>
<div className="p-3 bg-white/[0.02] border border-white/[0.06]">
<p className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Views</p>
<p className="text-lg text-white font-semibold">{inviteStats.referral_link_views_window}</p>
<p className="text-[10px] text-white/30">{inviteStats.window_days}d window</p>
</div>
<div className="p-3 bg-white/[0.02] border border-white/[0.06]">
<p className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Bonus</p>
<p className="text-lg text-white font-semibold">+{inviteStats.bonus_domains}</p>
<p className="text-[10px] text-white/30">Watchlist slots</p>
</div>
<div className="col-span-2 lg:col-span-4 p-3 bg-black/40 border border-white/[0.06] flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Badge</span>
<span className={clsx(
"px-2 py-1 text-[10px] font-mono uppercase tracking-wider border",
inviteStats.badge === 'elite_referrer'
? "bg-accent/10 text-accent border-accent/30"
: inviteStats.badge === 'verified_referrer'
? "bg-white/5 text-white border-white/15"
: "bg-white/[0.02] text-white/40 border-white/[0.06]"
)}>
{inviteStats.badge === 'elite_referrer'
? 'Elite Referrer'
: inviteStats.badge === 'verified_referrer'
? 'Verified Referrer'
: '—'}
</span>
</div>
<p className="text-[10px] font-mono text-white/30">
Next reward at {inviteStats.next_reward_at} qualified referrals
</p>
</div>
{(inviteStats.cooldown_days || inviteStats.disqualified_cooldown_total || inviteStats.disqualified_shared_ip_total || inviteStats.disqualified_duplicate_ip_total || inviteStats.disqualified_missing_ip_total) ? (
<div className="col-span-2 lg:col-span-4 px-1">
<p className="text-[10px] font-mono text-white/30">
Rules: cooldown {inviteStats.cooldown_days ?? 7}d. Disqualified cooldown: {inviteStats.disqualified_cooldown_total ?? 0}, sharedIP: {inviteStats.disqualified_shared_ip_total ?? 0}, duplicateIP: {inviteStats.disqualified_duplicate_ip_total ?? 0}, missingIP: {inviteStats.disqualified_missing_ip_total ?? 0}.
</p>
</div>
) : null}
</div>
) : null}
</div>
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-5 py-2.5 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white disabled:opacity-50"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Save
</button>
</form>
</div>
</div>
)}

View File

@ -44,6 +44,28 @@ function ActivateModal({ isOpen, onClose, onSuccess }: { isOpen: boolean; onClos
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
}
}>(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
}>(null)
const [copied, setCopied] = useState<string | null>(null)
useEffect(() => {
if (!isOpen) return
@ -60,22 +82,68 @@ function ActivateModal({ isOpen, onClose, onSuccess }: { isOpen: boolean; onClos
}
fetchVerifiedDomains()
}, [isOpen])
useEffect(() => {
if (!isOpen) return
setStep(1)
setActivation(null)
setDnsResult(null)
setDnsChecking(false)
setError(null)
setSelectedDomain('')
}, [isOpen])
const copyToClipboard = async (value: string, key: string) => {
try {
await navigator.clipboard.writeText(value)
setCopied(key)
setTimeout(() => setCopied(null), 1200)
} catch {
// ignore
}
}
const handleActivate = async () => {
if (!selectedDomain) return
setLoading(true)
setError(null)
try {
await api.activateYieldDomain(selectedDomain, true)
onSuccess()
onClose()
setSelectedDomain('')
const res = await api.activateYieldDomain(selectedDomain, true)
setActivation({
domain_id: res.domain_id,
domain: res.domain,
status: res.status,
dns_instructions: res.dns_instructions,
})
setStep(2)
} catch (err: any) {
setError(err.message || 'Failed')
} finally {
setLoading(false)
}
}
const checkDNS = useCallback(async (domainId: number) => {
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,
})
if (res.verified) {
onSuccess()
}
} catch (err: any) {
setError(err.message || 'DNS check failed')
} finally {
setDnsChecking(false)
}
}, [onSuccess])
if (!isOpen) return null
@ -90,11 +158,11 @@ function ActivateModal({ isOpen, onClose, onSuccess }: { isOpen: boolean; onClos
<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">
{loadingDomains ? (
{step === 1 && loadingDomains ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="w-5 h-5 text-accent animate-spin" />
</div>
) : verifiedDomains.length === 0 ? (
) : 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>
@ -105,7 +173,7 @@ function ActivateModal({ isOpen, onClose, onSuccess }: { isOpen: boolean; onClos
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>
@ -130,6 +198,93 @@ function ActivateModal({ isOpen, onClose, onSuccess }: { isOpen: boolean; onClos
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>
</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.
</p>
</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>
{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>
</div>
)}
</div>
)}
{error && <div className="p-2 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs">{error}</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>
</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>
)}
</>
)}
</div>
</div>

View File

@ -67,7 +67,7 @@ const sections = [
{
title: '6. Intellectual Property',
content: `
<p>The Service and its original content, features, and functionality are owned by pounce AG and are protected by international copyright, trademark, patent, trade secret, and other intellectual property laws.</p>
<p>The Service and its original content, features, and functionality are owned by Gugger Digital Services and are protected by international copyright, trademark, patent, trade secret, and other intellectual property laws.</p>
<p>You retain ownership of any data you provide to the Service. By using the Service, you grant us a license to use this data solely to provide and improve the Service.</p>
`,
},
@ -86,7 +86,7 @@ const sections = [
{
title: '8. Limitation of Liability',
content: `
<p>To the maximum extent permitted by law, pounce AG shall not be liable for any indirect, incidental, special, consequential, or punitive damages, including but not limited to:</p>
<p>To the maximum extent permitted by law, Gugger Digital Services shall not be liable for any indirect, incidental, special, consequential, or punitive damages, including but not limited to:</p>
<ul>
<li>Loss of profits, data, or business opportunities</li>
<li>Service interruptions or downtime</li>
@ -127,8 +127,8 @@ const sections = [
content: `
<p>For any questions about these Terms, please contact us at:</p>
<ul>
<li>Email: legal@pounce.dev</li>
<li>Address: pounce AG, Zurich, Switzerland</li>
<li>Email: hello@pounce.ch</li>
<li>Address: Gugger Digital Services, Zurich, Switzerland</li>
</ul>
`,
},
@ -159,7 +159,7 @@ export default function TermsPage() {
{/* Introduction */}
<div className="prose-custom mb-12 animate-slide-up">
<p className="text-body-lg text-foreground-muted leading-relaxed">
Please read these Terms of Service carefully before using pounce.
Please read these Terms of Service carefully before using Pounce.
Your access to and use of the Service is conditioned on your acceptance of these Terms.
</p>
</div>

View File

@ -1,27 +1,14 @@
'use client'
import { useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { redirect } from 'next/navigation'
/**
* Redirect /tld-pricing/[tld] to /discover/[tld]
* This page is kept for backwards compatibility
*/
export default function TldDetailRedirect() {
const router = useRouter()
const params = useParams()
const tld = params.tld as string
useEffect(() => {
router.replace(`/discover/${tld}`)
}, [router, tld])
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-foreground-muted">Redirecting to Discover...</p>
</div>
</div>
)
export default async function TldDetailRedirect({
params,
}: {
params: Promise<{ tld: string }>
}) {
const { tld } = await params
redirect(`/discover/${String(tld).toLowerCase()}`)
}

View File

@ -1,6 +1,8 @@
import { Metadata } from 'next'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.ch'
import { SITE_URL } from '@/lib/seo'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || SITE_URL
export const metadata: Metadata = {
title: 'TLD Pricing — Compare 886+ Domain Extensions',
@ -41,33 +43,6 @@ export const metadata: Metadata = {
},
}
// JSON-LD for TLD Pricing
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: 'TLD Pricing Comparison',
description: 'Compare domain registration prices across 886+ top-level domains',
url: `${siteUrl}/tld-pricing`,
isPartOf: {
'@type': 'WebSite',
name: 'pounce',
url: siteUrl,
},
mainEntity: {
'@type': 'ItemList',
name: 'Top-Level Domains',
description: 'Comprehensive list of TLD pricing from major registrars',
numberOfItems: 886,
itemListElement: [
{ '@type': 'ListItem', position: 1, name: '.com', description: 'Most popular generic TLD' },
{ '@type': 'ListItem', position: 2, name: '.net', description: 'Network-focused TLD' },
{ '@type': 'ListItem', position: 3, name: '.org', description: 'Organization TLD' },
{ '@type': 'ListItem', position: 4, name: '.io', description: 'Tech startup favorite' },
{ '@type': 'ListItem', position: 5, name: '.ai', description: 'AI and tech industry TLD' },
],
},
}
export default function TldPricingLayout({
children,
}: {
@ -75,10 +50,6 @@ export default function TldPricingLayout({
}) {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{children}
</>
)

View File

@ -1,25 +1,9 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { redirect } from 'next/navigation'
/**
* Redirect /tld-pricing to /discover
* This page is kept for backwards compatibility
*/
export default function TldPricingRedirect() {
const router = useRouter()
useEffect(() => {
router.replace('/discover')
}, [router])
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-foreground-muted">Redirecting to Discover...</p>
</div>
</div>
)
redirect('/discover')
}

View File

@ -1,164 +1,15 @@
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Script from 'next/script'
import { SITE_URL, POPULAR_TLDS, generateTLDPageSchema, generateBreadcrumbSchema } from '@/lib/seo'
import TldDetailClient from './TldDetailClient'
import { redirect } from 'next/navigation'
interface TldData {
tld: string
type: string | null
registration_price: number | null
renewal_price: number | null
registrar_count: number
description?: string | null
introduced?: string | null
registry?: string | null
}
// No build-time API calls - data is fetched client-side
function getTldData(_tld: string): TldData | null {
return null // Data loaded client-side in TldDetailClient
}
// Generate static params for popular TLDs
export async function generateStaticParams() {
return POPULAR_TLDS.map(tld => ({ tld }))
}
// Generate dynamic metadata for each TLD
export async function generateMetadata({
params
}: {
params: Promise<{ tld: string }>
}): Promise<Metadata> {
const { tld } = await params
const tldUpper = tld.toUpperCase()
const tldLower = tld.toLowerCase()
// Try to fetch real data for better SEO
const data = await getTldData(tldLower)
const priceInfo = data?.registration_price
? `from $${data.registration_price}`
: ''
const typeInfo = data?.type
? `(${data.type})`
: ''
const title = `.${tldUpper} Domain Pricing & Trends 2025 | Registration & Renewal Costs | Pounce`
const description = `Complete .${tldUpper} domain guide ${typeInfo}. Current registration ${priceInfo}, renewal prices, registrar comparison, and historical trends. Find the cheapest .${tldLower} domains.`
return {
title,
description,
keywords: [
`.${tldLower} domain`, `.${tldLower} price`, `.${tldLower} registration`,
`.${tldLower} renewal cost`, `.${tldLower} domain buy`, `${tldLower} domain extension`,
`.${tldLower} registrar`, `cheap .${tldLower} domain`, `.${tldLower} 2025`,
`${tldLower} domain price history`, `${tldLower} domain trends`
],
alternates: {
canonical: `${SITE_URL}/tld/${tldLower}`,
},
openGraph: {
title,
description,
url: `${SITE_URL}/tld/${tldLower}`,
siteName: 'Pounce',
images: [
{
url: `${SITE_URL}/og-tld.png`,
width: 1200,
height: 630,
alt: `.${tldUpper} Domain Pricing - Pounce`,
},
],
locale: 'en_US',
type: 'article',
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [`${SITE_URL}/og-tld.png`],
},
}
}
export default async function TldPage({
params
}: {
params: Promise<{ tld: string }>
/**
* Permanent redirect: canonical TLD pages live under /discover/[tld].
* Keeping /tld/[tld] for backwards compatibility.
*/
export default async function TldPage({
params,
}: {
params: Promise<{ tld: string }>
}) {
const { tld } = await params
const tldLower = tld.toLowerCase()
const tldUpper = tld.toUpperCase()
// Fetch TLD data for schema
const data = await getTldData(tldLower)
// Generate structured data
const tldSchema = generateTLDPageSchema(tldLower, {
registrationPrice: data?.registration_price || undefined,
renewalPrice: data?.renewal_price || undefined,
type: data?.type || undefined,
registrarCount: data?.registrar_count || 1,
})
const breadcrumbSchema = generateBreadcrumbSchema([
{ name: 'Home', url: SITE_URL },
{ name: 'TLD Intelligence', url: `${SITE_URL}/discover` },
{ name: `.${tldUpper}`, url: `${SITE_URL}/tld/${tldLower}` },
])
// Article schema for better SEO
const articleSchema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: `.${tldUpper} Domain Pricing & Trends 2025`,
description: `Complete guide to .${tldUpper} domain registration and renewal costs`,
url: `${SITE_URL}/tld/${tldLower}`,
datePublished: '2024-01-01',
dateModified: new Date().toISOString().split('T')[0],
author: {
'@type': 'Organization',
name: 'Pounce',
url: SITE_URL,
},
publisher: {
'@type': 'Organization',
name: 'Pounce',
logo: {
'@type': 'ImageObject',
url: `${SITE_URL}/pounce-logo.png`,
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `${SITE_URL}/tld/${tldLower}`,
},
}
return (
<>
<Script
id="tld-product-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(tldSchema) }}
/>
<Script
id="tld-breadcrumb-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
/>
<Script
id="tld-article-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
/>
<TldDetailClient tld={tldLower} initialData={data} />
</>
)
redirect(`/discover/${String(tld).toLowerCase()}`)
}

View File

@ -0,0 +1,13 @@
import type { Metadata } from 'next'
import { SITE_URL } from '@/lib/seo'
export const metadata: Metadata = {
title: 'Unsubscribe | Pounce',
robots: { index: false, follow: false },
alternates: { canonical: `${SITE_URL}/unsubscribe` },
}
export default function UnsubscribeLayout({ children }: { children: React.ReactNode }) {
return children
}

View File

@ -0,0 +1,13 @@
import type { Metadata } from 'next'
import { SITE_URL } from '@/lib/seo'
export const metadata: Metadata = {
title: 'Verify Email | Pounce',
robots: { index: false, follow: false },
alternates: { canonical: `${SITE_URL}/verify-email` },
}
export default function VerifyEmailLayout({ children }: { children: React.ReactNode }) {
return children
}

View File

@ -8,6 +8,7 @@ import { useStore } from '@/lib/store'
import { KeyboardShortcutsProvider, useAdminShortcuts, ShortcutHint } from '@/hooks/useKeyboardShortcuts'
import {
Activity,
BarChart3,
Users,
Bell,
Mail,
@ -203,6 +204,7 @@ function AdminSidebar({
const navItems = [
{ id: 'overview', label: 'Overview', icon: Activity, shortcut: 'O' },
{ id: 'telemetry', label: 'Telemetry', icon: BarChart3, shortcut: 'K' },
{ id: 'users', label: 'Users', icon: Users, shortcut: 'U' },
{ id: 'alerts', label: 'Price Alerts', icon: Bell },
{ id: 'newsletter', label: 'Newsletter', icon: Mail },

View File

@ -189,7 +189,7 @@ export function Footer() {
{/* Bottom Bar */}
<div className="pt-6 sm:pt-8 border-t border-white/10 flex flex-col sm:flex-row justify-between items-center gap-3 sm:gap-4 text-[9px] sm:text-[10px] font-mono text-white/20 uppercase tracking-widest">
<p>© {new Date().getFullYear()} POUNCE AG ZURICH</p>
<p>© {new Date().getFullYear()} Gugger Digital Services ZURICH</p>
<div className="flex items-center gap-4 sm:gap-6">
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-accent animate-pulse" />

View File

@ -0,0 +1,31 @@
'use client'
import { useEffect } from 'react'
function readQueryRef(): string | null {
try {
const url = new URL(window.location.href)
return url.searchParams.get('ref')
} catch {
return null
}
}
function setReferralCookie(code: string) {
const normalized = code.trim().toLowerCase()
// Accept only our invite codes (12 hex chars) to avoid storing garbage.
if (!/^[0-9a-f]{12}$/.test(normalized)) return
const maxAgeSeconds = 60 * 60 * 24 * 30 // 30 days
document.cookie = `pounce_ref=${encodeURIComponent(normalized)}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`
}
export default function ReferralCapture() {
useEffect(() => {
const ref = readQueryRef()
if (ref) setReferralCookie(ref)
}, [])
return null
}

View File

@ -1,4 +1,5 @@
import Head from 'next/head'
import { SITE_URL } from '@/lib/seo'
export interface SEOProps {
title?: string
@ -27,7 +28,7 @@ const defaultKeywords = [
'domain monitoring',
'domain valuation',
]
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.com'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || SITE_URL
const defaultOgImage = `${siteUrl}/og-image.png`
export function SEO({
@ -230,11 +231,6 @@ export function getDomainOfferSchema(domain: string, price: number, description?
},
url: `${siteUrl}/domains/${domain}`,
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '4.8',
reviewCount: '100',
},
}
}

View File

@ -23,6 +23,7 @@ import {
Coins,
Radar,
Briefcase,
MessageSquare,
} from 'lucide-react'
import { useState, useEffect } from 'react'
import clsx from 'clsx'
@ -107,6 +108,12 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
icon: Briefcase,
badge: null,
},
{
href: '/terminal/inbox',
label: 'INBOX',
icon: MessageSquare,
badge: null,
},
{
href: '/terminal/sniper',
label: 'SNIPER',

Some files were not shown because too many files have changed in this diff Show More