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,
|
||||
)
|
||||
|
||||
# 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
|
||||
ADMIN_EMAILS = ["guggeryves@hotmail.com"]
|
||||
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:
|
||||
return network_urls[partner.slug]
|
||||
|
||||
# Generic fallback - show Pounce marketplace
|
||||
return f"{settings.site_url}/buy?ref={yield_domain.domain}&clickid={click_id}"
|
||||
# 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(
|
||||
@ -98,18 +316,21 @@ def generate_landing_page(
|
||||
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 if partner else "Partner"
|
||||
partner_desc = partner.description if partner else "Find the best offers"
|
||||
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)
|
||||
if partner else f"{settings.site_url}/buy"
|
||||
)
|
||||
redirect_url = generate_tracking_url(partner, yield_domain, click_id)
|
||||
|
||||
return f"""
|
||||
<!DOCTYPE html>
|
||||
@ -206,8 +427,8 @@ def generate_landing_page(
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Powered by <a href="{settings.site_url}">Pounce</a> •
|
||||
<a href="{settings.site_url}/privacy">Privacy</a>
|
||||
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>
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""User model."""
|
||||
from datetime import datetime
|
||||
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 app.database import Base
|
||||
@ -40,6 +40,11 @@ class User(Base):
|
||||
oauth_id: Mapped[Optional[str]] = mapped_column(String(255), 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
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@ -10,6 +10,8 @@ class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8, 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):
|
||||
|
||||
@ -388,6 +388,22 @@ PARTNER_SEED_DATA: list[dict[str, Any]] = [
|
||||
"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
|
||||
# =========================================================================
|
||||
|
||||
@ -217,6 +217,33 @@ INTENT_PATTERNS = {
|
||||
"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": {
|
||||
"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.
|
||||
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