feat: integrate Pounce self-promotion & viral growth system
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
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
Pounce Eigenwerbung (from pounce_endgame.md):
- Add 'pounce_promo' as fallback partner for generic/unclear intent domains
- Create dedicated Pounce promo landing page with CTA to register
- Update footer on all yield pages: 'Monetized by Pounce • Own a domain? Start yielding'
Tech/Investment Domain Detection:
- Add 'investment_domains' category (invest, crypto, trading, domain, startup)
- Add 'tech_dev' category (developer, web3, fintech, proptech)
- Both categories have 'pounce_affinity' flag for higher Pounce conversion
Referral Tracking for Domain Owners:
- Add user fields: referred_by_user_id, referred_by_domain, referral_code
- Parse yield referral codes (yield_{user_id}_{domain_id}) on registration
- Domain owners earn lifetime commission when visitors sign up via their domain
DB Migrations:
- Add referral tracking columns to users table
This commit is contained in:
@ -100,6 +100,33 @@ async def register(
|
|||||||
name=user_data.name,
|
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
|
||||||
|
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}")
|
||||||
|
|
||||||
# Auto-admin for specific email
|
# Auto-admin for specific email
|
||||||
ADMIN_EMAILS = ["guggeryves@hotmail.com"]
|
ADMIN_EMAILS = ["guggeryves@hotmail.com"]
|
||||||
if user.email.lower() in [e.lower() for e in ADMIN_EMAILS]:
|
if user.email.lower() in [e.lower() for e in ADMIN_EMAILS]:
|
||||||
|
|||||||
@ -82,8 +82,226 @@ def generate_tracking_url(
|
|||||||
if partner.slug in network_urls:
|
if partner.slug in network_urls:
|
||||||
return network_urls[partner.slug]
|
return network_urls[partner.slug]
|
||||||
|
|
||||||
# Generic fallback - show Pounce marketplace
|
# Pounce self-promotion fallback with referral tracking
|
||||||
return f"{settings.site_url}/buy?ref={yield_domain.domain}&clickid={click_id}"
|
# 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(
|
def generate_landing_page(
|
||||||
@ -98,18 +316,21 @@ def generate_landing_page(
|
|||||||
1. Improve user experience
|
1. Improve user experience
|
||||||
2. Allow for A/B testing
|
2. Allow for A/B testing
|
||||||
3. Comply with affiliate disclosure requirements
|
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)
|
intent = detect_domain_intent(yield_domain.domain)
|
||||||
|
|
||||||
# Partner info
|
# Partner info
|
||||||
partner_name = partner.name if partner else "Partner"
|
partner_name = partner.name
|
||||||
partner_desc = partner.description if partner else "Find the best offers"
|
partner_desc = partner.description or "Find the best offers"
|
||||||
|
|
||||||
# Generate redirect URL
|
# Generate redirect URL
|
||||||
redirect_url = (
|
redirect_url = generate_tracking_url(partner, yield_domain, click_id)
|
||||||
generate_tracking_url(partner, yield_domain, click_id)
|
|
||||||
if partner else f"{settings.site_url}/buy"
|
|
||||||
)
|
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@ -206,8 +427,8 @@ def generate_landing_page(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
Powered by <a href="{settings.site_url}">Pounce</a> •
|
Monetized by <a href="{settings.site_url}/yield?ref={yield_domain.domain}">Pounce</a> •
|
||||||
<a href="{settings.site_url}/privacy">Privacy</a>
|
Own a domain? <a href="{settings.site_url}/yield?ref={yield_domain.domain}">Start yielding →</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -166,6 +166,20 @@ async def apply_migrations(conn: AsyncConnection) -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# 6) User referral tracking columns
|
||||||
|
# ----------------------------------------------------
|
||||||
|
if await _table_exists(conn, "users"):
|
||||||
|
if not await _has_column(conn, "users", "referred_by_user_id"):
|
||||||
|
logger.info("DB migrations: adding column users.referred_by_user_id")
|
||||||
|
await conn.execute(text("ALTER TABLE users ADD COLUMN referred_by_user_id INTEGER"))
|
||||||
|
if not await _has_column(conn, "users", "referred_by_domain"):
|
||||||
|
logger.info("DB migrations: adding column users.referred_by_domain")
|
||||||
|
await conn.execute(text("ALTER TABLE users ADD COLUMN referred_by_domain VARCHAR(255)"))
|
||||||
|
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)"))
|
||||||
|
|
||||||
logger.info("DB migrations: done")
|
logger.info("DB migrations: done")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"""User model."""
|
"""User model."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from sqlalchemy import String, Boolean, DateTime
|
from sqlalchemy import String, Boolean, DateTime, Integer
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@ -40,6 +40,11 @@ class User(Base):
|
|||||||
oauth_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
oauth_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
oauth_avatar: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
oauth_avatar: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# Yield Referral Tracking (for viral growth)
|
||||||
|
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
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
|||||||
@ -10,6 +10,8 @@ class UserCreate(BaseModel):
|
|||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str = Field(..., min_length=8, max_length=100)
|
password: str = Field(..., min_length=8, max_length=100)
|
||||||
name: Optional[str] = Field(None, max_length=100)
|
name: Optional[str] = Field(None, max_length=100)
|
||||||
|
# Yield referral tracking
|
||||||
|
ref: Optional[str] = Field(None, max_length=100, description="Referral code from yield domain")
|
||||||
|
|
||||||
|
|
||||||
class UserLogin(BaseModel):
|
class UserLogin(BaseModel):
|
||||||
|
|||||||
@ -388,6 +388,22 @@ PARTNER_SEED_DATA: list[dict[str, Any]] = [
|
|||||||
"priority": 90,
|
"priority": 90,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# POUNCE SELF-PROMOTION (Viral Growth)
|
||||||
|
# =========================================================================
|
||||||
|
{
|
||||||
|
"name": "Pounce Promo",
|
||||||
|
"slug": "pounce_promo",
|
||||||
|
"network": "internal",
|
||||||
|
"intent_categories": "investment_domains,tech_dev,generic",
|
||||||
|
"geo_countries": "CH,DE,AT",
|
||||||
|
"payout_type": "cps",
|
||||||
|
"payout_amount": Decimal("0"), # 30% lifetime commission handled separately
|
||||||
|
"payout_currency": "CHF",
|
||||||
|
"description": "Pounce self-promotion. Domain owners earn 30% lifetime commission on referrals.",
|
||||||
|
"priority": 50, # Higher than generic but lower than high-value partners
|
||||||
|
},
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# GENERIC FALLBACK
|
# GENERIC FALLBACK
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@ -217,6 +217,33 @@ INTENT_PATTERNS = {
|
|||||||
"partners": ["capterra", "g2"]
|
"partners": ["capterra", "g2"]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# Investment / Crypto / Finance Tech - HIGH POUNCE CONVERSION
|
||||||
|
"investment_domains": {
|
||||||
|
"keywords": [
|
||||||
|
"invest", "investment", "investor", "portfolio", "asset", "assets",
|
||||||
|
"trading", "trader", "crypto", "bitcoin", "blockchain", "nft",
|
||||||
|
"domain", "domains", "digital", "passive", "income", "yield",
|
||||||
|
"startup", "founder", "entrepreneur", "venture", "capital"
|
||||||
|
],
|
||||||
|
"patterns": [r"invest\w*", r"trad\w*", r"crypto\w*", r"domain\w*"],
|
||||||
|
"potential": "high",
|
||||||
|
"partners": ["pounce_promo"], # Pounce self-promotion
|
||||||
|
"pounce_affinity": True, # Flag for Pounce self-promotion
|
||||||
|
},
|
||||||
|
|
||||||
|
# Tech / Developer - GOOD POUNCE CONVERSION
|
||||||
|
"tech_dev": {
|
||||||
|
"keywords": [
|
||||||
|
"dev", "developer", "code", "coding", "tech", "technology",
|
||||||
|
"api", "sdk", "github", "git", "open-source", "opensource",
|
||||||
|
"web3", "defi", "fintech", "proptech", "saas"
|
||||||
|
],
|
||||||
|
"patterns": [r"dev\w*", r"tech\w*", r"web\d*"],
|
||||||
|
"potential": "medium",
|
||||||
|
"partners": ["pounce_promo"],
|
||||||
|
"pounce_affinity": True,
|
||||||
|
},
|
||||||
|
|
||||||
# Food / Restaurant
|
# Food / Restaurant
|
||||||
"food_restaurant": {
|
"food_restaurant": {
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
301
frontend/src/components/CommandCenterLayout.tsx
Executable file
301
frontend/src/components/CommandCenterLayout.tsx
Executable file
@ -0,0 +1,301 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useStore } from '@/lib/store'
|
||||||
|
import { Sidebar } from './Sidebar'
|
||||||
|
import { KeyboardShortcutsProvider, useUserShortcuts } from '@/hooks/useKeyboardShortcuts'
|
||||||
|
import { Bell, Search, X, Command } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
interface CommandCenterLayoutProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
actions?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandCenterLayout({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
actions
|
||||||
|
}: CommandCenterLayoutProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { isAuthenticated, isLoading, checkAuth, domains } = useStore()
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
const [notificationsOpen, setNotificationsOpen] = useState(false)
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const authCheckedRef = useRef(false)
|
||||||
|
|
||||||
|
// Ensure component is mounted before rendering
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load sidebar state from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (mounted) {
|
||||||
|
const saved = localStorage.getItem('sidebar-collapsed')
|
||||||
|
if (saved) {
|
||||||
|
setSidebarCollapsed(saved === 'true')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [mounted])
|
||||||
|
|
||||||
|
// Check auth only once on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authCheckedRef.current) {
|
||||||
|
authCheckedRef.current = true
|
||||||
|
checkAuth()
|
||||||
|
}
|
||||||
|
}, [checkAuth])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAuthenticated) {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}, [isLoading, isAuthenticated, router])
|
||||||
|
|
||||||
|
// Available domains for notifications
|
||||||
|
const availableDomains = domains?.filter(d => d.is_available) || []
|
||||||
|
const hasNotifications = availableDomains.length > 0
|
||||||
|
|
||||||
|
// Show loading only if we're still checking auth
|
||||||
|
if (!mounted || isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
|
<p className="text-sm text-foreground-muted">Loading Command Center...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardShortcutsProvider>
|
||||||
|
<UserShortcutsWrapper />
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* 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.02] rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.015] rounded-full blur-[100px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Sidebar
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onCollapsedChange={setSidebarCollapsed}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"relative min-h-screen transition-all duration-300",
|
||||||
|
// Desktop: adjust for sidebar
|
||||||
|
"lg:ml-[260px]",
|
||||||
|
sidebarCollapsed && "lg:ml-[72px]",
|
||||||
|
// Mobile: no margin, just padding for menu button
|
||||||
|
"ml-0 pt-16 lg:pt-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Top Bar */}
|
||||||
|
<header className="sticky top-0 z-30 bg-gradient-to-r from-background/95 via-background/90 to-background/95 backdrop-blur-xl border-b border-border/30">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-5 sm:py-6 flex items-center justify-between">
|
||||||
|
{/* Left: Title */}
|
||||||
|
<div className="ml-10 lg:ml-0 min-w-0 flex-1">
|
||||||
|
{title && (
|
||||||
|
<h1 className="text-xl sm:text-2xl font-semibold tracking-tight text-foreground truncate">{title}</h1>
|
||||||
|
)}
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-sm text-foreground-muted mt-0.5 hidden sm:block truncate">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Actions */}
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-4">
|
||||||
|
{/* Quick Search */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchOpen(true)}
|
||||||
|
className="hidden md:flex items-center gap-2 h-9 px-3 bg-foreground/5 hover:bg-foreground/8
|
||||||
|
border border-border/40 rounded-lg text-sm text-foreground-muted
|
||||||
|
hover:text-foreground transition-all duration-200 hover:border-border/60"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
<span className="hidden lg:inline">Search</span>
|
||||||
|
<kbd className="hidden xl:inline-flex items-center h-5 px-1.5 bg-background border border-border/60
|
||||||
|
rounded text-[10px] text-foreground-subtle font-mono">⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Mobile Search */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchOpen(true)}
|
||||||
|
className="md:hidden flex items-center justify-center w-9 h-9 text-foreground-muted
|
||||||
|
hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Search className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setNotificationsOpen(!notificationsOpen)}
|
||||||
|
className={clsx(
|
||||||
|
"relative flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-200",
|
||||||
|
notificationsOpen
|
||||||
|
? "bg-foreground/10 text-foreground"
|
||||||
|
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
{hasNotifications && (
|
||||||
|
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-accent rounded-full">
|
||||||
|
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-50" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Notifications Dropdown */}
|
||||||
|
{notificationsOpen && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-80 bg-background-secondary border border-border
|
||||||
|
rounded-xl shadow-2xl overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-border flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Notifications</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setNotificationsOpen(false)}
|
||||||
|
className="text-foreground-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-80 overflow-y-auto">
|
||||||
|
{availableDomains.length > 0 ? (
|
||||||
|
<div className="p-2">
|
||||||
|
{availableDomains.slice(0, 5).map((domain) => (
|
||||||
|
<Link
|
||||||
|
key={domain.id}
|
||||||
|
href="/command/watchlist"
|
||||||
|
onClick={() => setNotificationsOpen(false)}
|
||||||
|
className="flex items-start gap-3 p-3 hover:bg-foreground/5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center shrink-0">
|
||||||
|
<span className="w-2 h-2 bg-accent rounded-full animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">{domain.name}</p>
|
||||||
|
<p className="text-xs text-accent">Available now!</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<Bell className="w-8 h-8 text-foreground-subtle mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-foreground-muted">No notifications</p>
|
||||||
|
<p className="text-xs text-foreground-subtle mt-1">
|
||||||
|
We'll notify you when domains become available
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard Shortcuts Hint */}
|
||||||
|
<button
|
||||||
|
onClick={() => {}}
|
||||||
|
className="hidden sm:flex items-center gap-1.5 px-2 py-1.5 text-xs text-foreground-subtle hover:text-foreground
|
||||||
|
bg-foreground/5 rounded-lg border border-border/40 hover:border-border/60 transition-all"
|
||||||
|
title="Keyboard shortcuts (?)"
|
||||||
|
>
|
||||||
|
<Command className="w-3.5 h-3.5" />
|
||||||
|
<span>?</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Custom Actions */}
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page Content */}
|
||||||
|
<main className="relative">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Search Modal */}
|
||||||
|
{searchOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] bg-background/80 backdrop-blur-sm flex items-start justify-center pt-[15vh] sm:pt-[20vh] px-4"
|
||||||
|
onClick={() => setSearchOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-xl bg-background-secondary border border-border rounded-2xl shadow-2xl overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 p-4 border-b border-border">
|
||||||
|
<Search className="w-5 h-5 text-foreground-muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search domains, TLDs, auctions..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="flex-1 bg-transparent text-foreground placeholder:text-foreground-subtle
|
||||||
|
outline-none text-lg"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchOpen(false)}
|
||||||
|
className="flex items-center h-6 px-2 bg-background border border-border
|
||||||
|
rounded text-xs text-foreground-subtle font-mono hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
ESC
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 text-center text-foreground-muted text-sm">
|
||||||
|
Start typing to search...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Keyboard shortcut for search */}
|
||||||
|
<KeyboardShortcut onTrigger={() => setSearchOpen(true)} keys={['Meta', 'k']} />
|
||||||
|
</div>
|
||||||
|
</KeyboardShortcutsProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcut component
|
||||||
|
function KeyboardShortcut({ onTrigger, keys }: { onTrigger: () => void, keys: string[] }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (keys.includes('Meta') && e.metaKey && e.key === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
onTrigger()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handler)
|
||||||
|
return () => document.removeEventListener('keydown', handler)
|
||||||
|
}, [onTrigger, keys])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// User shortcuts wrapper
|
||||||
|
function UserShortcutsWrapper() {
|
||||||
|
useUserShortcuts()
|
||||||
|
return null
|
||||||
|
}
|
||||||
@ -111,4 +111,92 @@ Pounce becomes the **Asset Manager**.
|
|||||||
Das passt perfekt. Es ist sauber, es ist automatisiert, und es ist extrem skalierbar.
|
Das passt perfekt. Es ist sauber, es ist automatisiert, und es ist extrem skalierbar.
|
||||||
Du baust keine Webseiten. Du baust **Wegweiser**. Und für jeden, der den Wegweiser nutzt, kassierst du Maut.
|
Du baust keine Webseiten. Du baust **Wegweiser**. Und für jeden, der den Wegweiser nutzt, kassierst du Maut.
|
||||||
|
|
||||||
**Das ist das Unicorn-Modell.** 🦄
|
**Das ist das Unicorn-Modell.** 🦄
|
||||||
|
|
||||||
|
Das ist eine **geniale Growth-Hacking-Idee**. Du nutzt quasi das ungenutzte Inventar deiner User, um Pounce selbst zu bewerben. Das nennt man "Viral Loop" (wie bei Hotmail früher: "Sent with Hotmail").
|
||||||
|
|
||||||
|
Aber: Es gibt einen **entscheidenden Haken**, den wir beachten müssen, damit deine User nicht sauer werden.
|
||||||
|
|
||||||
|
Hier ist die Analyse, warum du das tun solltest – aber **nicht ausschließlich**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Das Problem: Der "Intent Mismatch" (Warum es nicht immer klappt)
|
||||||
|
|
||||||
|
Stell dir vor, ein User besitzt die Domain `notfall-zahnarzt-bern.ch`.
|
||||||
|
* **Der Besucher:** Hat Zahnschmerzen. Er sucht Hilfe.
|
||||||
|
* **Deine Pounce-Werbung:** "Werde Domain-Investor und verdiene Geld!"
|
||||||
|
* **Das Ergebnis:** Der Besucher ist verwirrt und geht sofort weg (Bounce). Niemand verdient Geld. Weder du noch der Domain-Besitzer.
|
||||||
|
|
||||||
|
**Die Konsequenz:**
|
||||||
|
Wenn der Domain-Besitzer sieht, dass er 1.000 Besucher hatte, aber **0 CHF** verdient hat (weil niemand mit Zahnschmerzen ein Domain-Tool abonniert), wird er Pounce Yield wieder abschalten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Die Lösung: Das "Fallback"-Modell (Der Lückenfüller)
|
||||||
|
|
||||||
|
Wir machen die Pounce-Eigenwerbung nicht zur *einzigen* Option, sondern zur **Standard-Option (Default)**, wenn wir nichts Besseres haben.
|
||||||
|
|
||||||
|
So integrieren wir das perfekt:
|
||||||
|
|
||||||
|
#### 1. Priorität A: High-Value Intent (3rd Party)
|
||||||
|
Wenn wir wissen, was der User will, geben wir es ihm.
|
||||||
|
* Domain: `kredit-vergleich.ch`
|
||||||
|
* Route: Bank / Affiliate
|
||||||
|
* **Warum:** Das bringt dem User vielleicht 50 CHF pro Lead. Damit kannst du mit Eigenwerbung nicht mithalten. **Das macht den User reich.**
|
||||||
|
|
||||||
|
#### 2. Priorität B: Low-Value / Unclear Intent (Pounce Promo)
|
||||||
|
Wenn die Domain generisch ist oder wir keinen Partner haben.
|
||||||
|
* Domain: `coole-namen.net` oder `peter-müller.com`
|
||||||
|
* Route: **Pounce Landing Page**
|
||||||
|
* *Headline:* "This domain is powered by Pounce."
|
||||||
|
* *Subline:* "Make money with your domains. Start for free."
|
||||||
|
* *Action:* User klickt, registriert sich.
|
||||||
|
* *Provision:* Der Domain-Besitzer bekommt z.B. 30% Lifetime-Provision auf das Abo des Geworbenen.
|
||||||
|
* **Warum:** Hier ist die Chance auf ein Abo höher, und es ist besser als eine leere Seite. **Das macht Pounce groß.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Die Strategie: "Pounce as the Default Ad"
|
||||||
|
|
||||||
|
Wir bauen das System so auf:
|
||||||
|
|
||||||
|
1. **Das "Powered by Pounce" Badge (Immer da):**
|
||||||
|
Egal, wohin wir leiten (auch beim Zahnarzt), ganz unten im Footer steht immer klein:
|
||||||
|
* *"Monetized by Pounce. Own a domain? [Start yielding]"*
|
||||||
|
* Das ist dein kostenloses virales Marketing auf *jeder* Seite.
|
||||||
|
|
||||||
|
2. **Der "Empty State" Füller:**
|
||||||
|
Solange der User keine spezifische Route (z.B. zu Amazon) eingestellt hat, schalten wir **automatisch** die Pounce-Eigenwerbung.
|
||||||
|
* *Vorteil für dich:* Kostenloses Werbeinventar auf tausenden Domains.
|
||||||
|
* *Vorteil für User:* Besser als eine Fehlerseite ("404 Not Found"). Er hat zumindest die *Chance* auf eine Provision.
|
||||||
|
|
||||||
|
3. **Gezieltes Targeting für Tech-Domains:**
|
||||||
|
Bei Domains wie `invest-tech.io` oder `crypto-assets.xyz` ist die Zielgruppe (Investoren/Techies) perfekt für Pounce.
|
||||||
|
* Hier schlägt dein System dem User aktiv vor: *"Wir empfehlen für diese Domain die **Pounce-Affiliate-Route**. Die Conversion-Wahrscheinlichkeit für unser Tool ist hier sehr hoch."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Warum das für dein Unicorn-Ziel wichtig ist
|
||||||
|
|
||||||
|
Indem du Pounce selbst bewirbst, senkst du deine **CAC (Customer Acquisition Cost)** auf fast Null.
|
||||||
|
|
||||||
|
* Normalerweise musst du für einen neuen Kunden 50 CHF Werbung auf Google ausgeben.
|
||||||
|
* Mit diesem Modell bringen dir deine *bestehenden* Kunden über ihre Domains *neue* Kunden – kostenlos.
|
||||||
|
|
||||||
|
**Das Rechenbeispiel:**
|
||||||
|
* Du hast 100 User.
|
||||||
|
* Jeder User hat 10 Domains auf "Pounce Yield" gestellt.
|
||||||
|
* = **1.000 digitale Plakatwände** im Internet, die für Pounce werben.
|
||||||
|
* Selbst wenn nur 1 Person pro Monat pro Domain klickt = 1.000 Klicks.
|
||||||
|
* Bei 1% Conversion = **10 neue Abos pro Monat geschenkt.**
|
||||||
|
|
||||||
|
### Fazit
|
||||||
|
|
||||||
|
Ja, wir machen das!
|
||||||
|
Aber wir machen es schlau:
|
||||||
|
|
||||||
|
1. **Footer-Link:** Überall. Immer.
|
||||||
|
2. **Full-Page Ad:** Nur, wenn wir keinen besseren (lukrativeren) Intent erkennen oder der User es als "Default" eingestellt hat.
|
||||||
|
|
||||||
|
Das schützt die Einnahmen deiner User (High Yield) und treibt gleichzeitig dein Wachstum (Viral Loop) voran. Das ist die perfekte Balance.
|
||||||
Reference in New Issue
Block a user