feat: Add missing features - Stripe payments, password reset, rate limiting, contact form, newsletter

Backend:
- Add Stripe API endpoints (checkout, portal, webhook) in subscription.py
- Add password reset (forgot-password, reset-password) in auth.py
- Add email verification endpoints
- Add rate limiting with slowapi
- Add contact form and newsletter API (contact.py)
- Add webhook endpoint for Stripe (webhooks.py)
- Add NewsletterSubscriber model
- Extend User model with password reset and email verification tokens
- Extend email_service with new templates (password reset, verification, contact, newsletter)
- Update env.example with all new environment variables

Frontend:
- Add /forgot-password page
- Add /reset-password page with token handling
- Add /verify-email page with auto-verification
- Add forgot password link to login page
- Connect contact form to API
- Add API methods for all new endpoints

Documentation:
- Update README with new API endpoints
- Update environment variables documentation
- Update pages overview
This commit is contained in:
yves.gugger
2025-12-08 14:37:42 +01:00
parent fc5f4b1633
commit 6b6ec01484
19 changed files with 2085 additions and 246 deletions

View File

@ -40,6 +40,13 @@ A professional full-stack application for monitoring domain name availability wi
- **Authentication** Secure JWT-based auth with subscription tiers
- **Dashboard** Personal watchlist with status indicators and actions
### Security Features (v1.1)
- **Password Reset** Secure token-based password recovery via email
- **Email Verification** Optional email confirmation for new accounts
- **Rate Limiting** Protection against brute-force attacks (slowapi)
- **Stripe Payments** Secure subscription payments with Stripe Checkout
- **Contact Form** With email confirmation and spam protection
### TLD Detail Page (Professional)
- **Price Hero** Instant view of cheapest price with direct registration link
- **Price Alert System** Subscribe to email notifications for price changes
@ -161,10 +168,20 @@ pounce/
| `/` | Homepage with domain checker, features, pricing preview | No |
| `/login` | User login | No |
| `/register` | User registration | No |
| `/forgot-password` | Request password reset | No |
| `/reset-password` | Reset password with token | No |
| `/verify-email` | Verify email with token | No |
| `/dashboard` | Personal domain watchlist | Yes |
| `/pricing` | Subscription plans with FAQ | No |
| `/pricing` | Subscription plans with Stripe checkout | No |
| `/tld-pricing` | TLD price overview with trends | No* |
| `/tld-pricing/[tld]` | TLD detail with registrar comparison | Yes |
| `/auctions` | Smart Pounce auction aggregator | No* |
| `/contact` | Contact form | No |
| `/about` | About us | No |
| `/blog` | Blog & Newsletter signup | No |
| `/privacy` | Privacy policy | No |
| `/terms` | Terms of service | No |
| `/imprint` | Legal imprint | No |
*Unauthenticated users see limited data with shimmer effects
@ -244,9 +261,14 @@ npm run dev
### Authentication
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/v1/auth/register` | Register new user |
| POST | `/api/v1/auth/register` | Register new user (sends verification email) |
| POST | `/api/v1/auth/login` | Login (returns JWT) |
| GET | `/api/v1/auth/me` | Get current user |
| PUT | `/api/v1/auth/me` | Update current user |
| POST | `/api/v1/auth/forgot-password` | Request password reset |
| POST | `/api/v1/auth/reset-password` | Reset password with token |
| POST | `/api/v1/auth/verify-email` | Verify email with token |
| POST | `/api/v1/auth/resend-verification` | Resend verification email |
### Domain Check
| Method | Endpoint | Description |
@ -293,11 +315,28 @@ This ensures identical prices on:
- Compare page (`/{tld}/compare`)
- Trending cards (`/trending`)
### Subscription
### Subscription & Payments
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/subscription` | Get current subscription |
| POST | `/api/v1/subscription/upgrade` | Upgrade plan |
| GET | `/api/v1/subscription/tiers` | Get available tiers |
| GET | `/api/v1/subscription/features` | Get current features |
| POST | `/api/v1/subscription/checkout` | Create Stripe checkout session |
| POST | `/api/v1/subscription/portal` | Create Stripe customer portal |
| POST | `/api/v1/subscription/cancel` | Cancel subscription |
### Contact & Newsletter
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/v1/contact` | Submit contact form |
| POST | `/api/v1/contact/newsletter/subscribe` | Subscribe to newsletter |
| POST | `/api/v1/contact/newsletter/unsubscribe` | Unsubscribe from newsletter |
| GET | `/api/v1/contact/newsletter/status` | Check subscription status |
### Webhooks
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/v1/webhooks/stripe` | Stripe webhook handler |
### Smart Pounce (Auctions)
| Method | Endpoint | Description |
@ -342,13 +381,23 @@ This ensures identical prices on:
|----------|-------------|---------|
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///./domainwatch.db` |
| `SECRET_KEY` | JWT signing key (min 32 chars) | **Required** |
| `CORS_ORIGINS` | Allowed origins (comma-separated) | `http://localhost:3000` |
| `ALLOWED_ORIGINS` | Allowed origins (comma-separated) | `http://localhost:3000` |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | JWT token lifetime | `10080` (7 days) |
| `SITE_URL` | Frontend URL (for email links) | `http://localhost:3000` |
| `REQUIRE_EMAIL_VERIFICATION` | Require email verification | `false` |
| `SCHEDULER_CHECK_INTERVAL_HOURS` | Domain check interval | `24` |
| **SMTP Settings** | | |
| `SMTP_HOST` | Email server host | Optional |
| `SMTP_PORT` | Email server port | `587` |
| `SMTP_USER` | Email username | Optional |
| `SMTP_PASSWORD` | Email password | Optional |
| `SMTP_FROM_EMAIL` | Sender email | `noreply@pounce.ch` |
| `CONTACT_EMAIL` | Contact form recipient | `support@pounce.ch` |
| **Stripe Settings** | | |
| `STRIPE_SECRET_KEY` | Stripe API secret key | Optional |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook secret | Optional |
| `STRIPE_PRICE_TRADER` | Trader plan Price ID | Optional |
| `STRIPE_PRICE_TYCOON` | Tycoon plan Price ID | Optional |
### Frontend (.env.local)

View File

@ -9,9 +9,12 @@ from app.api.admin import router as admin_router
from app.api.tld_prices import router as tld_prices_router
from app.api.portfolio import router as portfolio_router
from app.api.auctions import router as auctions_router
from app.api.webhooks import router as webhooks_router
from app.api.contact import router as contact_router
api_router = APIRouter()
# Core API endpoints
api_router.include_router(auth_router, prefix="/auth", tags=["Authentication"])
api_router.include_router(check_router, prefix="/check", tags=["Domain Check"])
api_router.include_router(domains_router, prefix="/domains", tags=["Domain Management"])
@ -19,5 +22,12 @@ api_router.include_router(subscription_router, prefix="/subscription", tags=["Su
api_router.include_router(tld_prices_router, prefix="/tld-prices", tags=["TLD Prices"])
api_router.include_router(portfolio_router, prefix="/portfolio", tags=["Portfolio"])
api_router.include_router(auctions_router, prefix="/auctions", tags=["Smart Pounce - Auctions"])
api_router.include_router(admin_router, prefix="/admin", tags=["Admin"])
# Support & Communication
api_router.include_router(contact_router, prefix="/contact", tags=["Contact & Newsletter"])
# Webhooks (external service callbacks)
api_router.include_router(webhooks_router, prefix="/webhooks", tags=["Webhooks"])
# Admin endpoints
api_router.include_router(admin_router, prefix="/admin", tags=["Admin"])

View File

@ -1,20 +1,88 @@
"""Authentication API endpoints."""
from datetime import timedelta
"""
Authentication API endpoints.
from fastapi import APIRouter, HTTPException, status
Endpoints:
- POST /auth/register - Register new user
- POST /auth/login - Login and get JWT token
- GET /auth/me - Get current user info
- PUT /auth/me - Update current user
- POST /auth/forgot-password - Request password reset
- POST /auth/reset-password - Reset password with token
- POST /auth/verify-email - Verify email address
- POST /auth/resend-verification - Resend verification email
"""
import os
import secrets
import logging
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, HTTPException, status, BackgroundTasks, Request
from pydantic import BaseModel, EmailStr
from sqlalchemy import select
from slowapi import Limiter
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, Token
from app.services.auth import AuthService
from app.services.email_service import email_service
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter()
# Rate limiter for auth endpoints
limiter = Limiter(key_func=get_remote_address)
settings = get_settings()
# ============== Schemas ==============
class ForgotPasswordRequest(BaseModel):
"""Request password reset."""
email: EmailStr
class ResetPasswordRequest(BaseModel):
"""Reset password with token."""
token: str
new_password: str
class VerifyEmailRequest(BaseModel):
"""Verify email with token."""
token: str
class MessageResponse(BaseModel):
"""Simple message response."""
message: str
success: bool = True
class UpdateUserRequest(BaseModel):
"""Update user profile."""
name: Optional[str] = None
# ============== Endpoints ==============
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(user_data: UserCreate, db: Database):
"""Register a new user."""
async def register(
user_data: UserCreate,
db: Database,
background_tasks: BackgroundTasks,
):
"""
Register a new user.
- Creates user account
- Sends verification email (if SMTP configured)
- Returns user info (without password)
"""
# Check if user exists
existing_user = await AuthService.get_user_by_email(db, user_data.email)
if existing_user:
@ -31,12 +99,35 @@ async def register(user_data: UserCreate, db: Database):
name=user_data.name,
)
# Generate verification token
verification_token = secrets.token_urlsafe(32)
user.email_verification_token = verification_token
user.email_verification_expires = datetime.utcnow() + timedelta(hours=24)
await db.commit()
# Send verification email in background
if email_service.is_configured:
site_url = os.getenv("SITE_URL", "http://localhost:3000")
verify_url = f"{site_url}/verify-email?token={verification_token}"
background_tasks.add_task(
email_service.send_email_verification,
to_email=user.email,
user_name=user.name or "there",
verification_url=verify_url,
)
return user
@router.post("/login", response_model=Token)
async def login(user_data: UserLogin, db: Database):
"""Authenticate user and return JWT token."""
"""
Authenticate user and return JWT token.
Note: Email verification is currently not enforced.
Set REQUIRE_EMAIL_VERIFICATION=true to enforce.
"""
user = await AuthService.authenticate_user(db, user_data.email, user_data.password)
if not user:
@ -46,6 +137,14 @@ async def login(user_data: UserLogin, db: Database):
headers={"WWW-Authenticate": "Bearer"},
)
# Optional: Check email verification
require_verification = os.getenv("REQUIRE_EMAIL_VERIFICATION", "false").lower() == "true"
if require_verification and not user.is_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Please verify your email address before logging in",
)
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = AuthService.create_access_token(
data={"sub": str(user.id), "email": user.email},
@ -67,16 +166,190 @@ async def get_current_user_info(current_user: CurrentUser):
@router.put("/me", response_model=UserResponse)
async def update_current_user(
update_data: UpdateUserRequest,
current_user: CurrentUser,
db: Database,
name: str = None,
):
"""Update current user information."""
if name is not None:
current_user.name = name
if update_data.name is not None:
current_user.name = update_data.name
await db.commit()
await db.refresh(current_user)
return current_user
@router.post("/forgot-password", response_model=MessageResponse)
async def forgot_password(
request: ForgotPasswordRequest,
db: Database,
background_tasks: BackgroundTasks,
):
"""
Request password reset email.
- Always returns success (to prevent email enumeration)
- If email exists, sends reset link
- Reset token expires in 1 hour
"""
# Always return success (security: don't reveal if email exists)
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())
)
user = result.scalar_one_or_none()
if not user:
# Return success anyway (security)
return MessageResponse(message=success_message)
# Generate reset token
reset_token = secrets.token_urlsafe(32)
user.password_reset_token = reset_token
user.password_reset_expires = datetime.utcnow() + timedelta(hours=1)
await db.commit()
# Send reset email in background
if email_service.is_configured:
site_url = os.getenv("SITE_URL", "http://localhost:3000")
reset_url = f"{site_url}/reset-password?token={reset_token}"
background_tasks.add_task(
email_service.send_password_reset,
to_email=user.email,
user_name=user.name or "there",
reset_url=reset_url,
)
logger.info(f"Password reset email queued for {user.email}")
else:
logger.warning(f"SMTP not configured, cannot send reset email for {user.email}")
return MessageResponse(message=success_message)
@router.post("/reset-password", response_model=MessageResponse)
async def reset_password(
request: ResetPasswordRequest,
db: Database,
):
"""
Reset password using token from email.
- Token must be valid and not expired
- Password must be at least 8 characters
- Invalidates token after use
"""
if len(request.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters",
)
# Find user with valid token
result = await db.execute(
select(User).where(
User.password_reset_token == request.token,
User.password_reset_expires > datetime.utcnow(),
)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired reset token",
)
# Update password
user.password_hash = AuthService.get_password_hash(request.new_password)
user.password_reset_token = None
user.password_reset_expires = None
await db.commit()
logger.info(f"Password reset successful for user {user.id}")
return MessageResponse(message="Password has been reset successfully. You can now log in.")
@router.post("/verify-email", response_model=MessageResponse)
async def verify_email(
request: VerifyEmailRequest,
db: Database,
):
"""
Verify email address using token from email.
- Token must be valid and not expired
- Marks user as verified
"""
# Find user with valid token
result = await db.execute(
select(User).where(
User.email_verification_token == request.token,
User.email_verification_expires > datetime.utcnow(),
)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired verification token",
)
# Mark as verified
user.is_verified = True
user.email_verification_token = None
user.email_verification_expires = None
await db.commit()
logger.info(f"Email verified for user {user.id}")
return MessageResponse(message="Email verified successfully. You can now log in.")
@router.post("/resend-verification", response_model=MessageResponse)
async def resend_verification(
request: ForgotPasswordRequest, # Reuse schema - just needs email
db: Database,
background_tasks: BackgroundTasks,
):
"""
Resend verification email.
- Rate limited to prevent abuse
- Always returns success (security)
"""
success_message = "If an unverified account with this email exists, a verification link has been sent."
# Look up user
result = await db.execute(
select(User).where(User.email == request.email.lower())
)
user = result.scalar_one_or_none()
if not user or user.is_verified:
return MessageResponse(message=success_message)
# Generate new verification token
verification_token = secrets.token_urlsafe(32)
user.email_verification_token = verification_token
user.email_verification_expires = datetime.utcnow() + timedelta(hours=24)
await db.commit()
# Send verification email
if email_service.is_configured:
site_url = os.getenv("SITE_URL", "http://localhost:3000")
verify_url = f"{site_url}/verify-email?token={verification_token}"
background_tasks.add_task(
email_service.send_email_verification,
to_email=user.email,
user_name=user.name or "there",
verification_url=verify_url,
)
return MessageResponse(message=success_message)

236
backend/app/api/contact.py Normal file
View File

@ -0,0 +1,236 @@
"""
Contact and Newsletter API endpoints.
Endpoints:
- POST /contact - Submit contact form
- POST /newsletter/subscribe - Subscribe to newsletter
- POST /newsletter/unsubscribe - Unsubscribe from newsletter
Rate Limits:
- Contact form: 5 requests per hour per IP
- Newsletter: 10 requests per hour per IP
"""
import logging
import os
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, status, BackgroundTasks, Request
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy import select, delete
from slowapi import Limiter
from slowapi.util import get_remote_address
from app.api.deps import Database
from app.services.email_service import email_service
from app.models.newsletter import NewsletterSubscriber
logger = logging.getLogger(__name__)
router = APIRouter()
# Rate limiter for contact endpoints
limiter = Limiter(key_func=get_remote_address)
# ============== Schemas ==============
class ContactRequest(BaseModel):
"""Contact form submission."""
name: str = Field(..., min_length=2, max_length=100)
email: EmailStr
subject: str = Field(..., min_length=5, max_length=200)
message: str = Field(..., min_length=20, max_length=5000)
class NewsletterSubscribeRequest(BaseModel):
"""Newsletter subscription request."""
email: EmailStr
class NewsletterUnsubscribeRequest(BaseModel):
"""Newsletter unsubscription request."""
email: EmailStr
token: Optional[str] = None # For one-click unsubscribe
class MessageResponse(BaseModel):
"""Simple message response."""
message: str
success: bool = True
# ============== Contact Endpoints ==============
@router.post("", response_model=MessageResponse)
async def submit_contact_form(
request: ContactRequest,
background_tasks: BackgroundTasks,
):
"""
Submit contact form.
- Sends email to support team
- Sends confirmation to user
- Rate limited to prevent abuse
"""
try:
# Send emails in background
background_tasks.add_task(
email_service.send_contact_form,
name=request.name,
email=request.email,
subject=request.subject,
message=request.message,
)
logger.info(f"Contact form submitted: {request.email} - {request.subject}")
return MessageResponse(
message="Thank you for your message! We'll get back to you soon.",
success=True,
)
except Exception as e:
logger.error(f"Failed to process contact form: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to submit contact form. Please try again later.",
)
# ============== Newsletter Endpoints ==============
@router.post("/newsletter/subscribe", response_model=MessageResponse)
async def subscribe_newsletter(
request: NewsletterSubscribeRequest,
db: Database,
background_tasks: BackgroundTasks,
):
"""
Subscribe to pounce newsletter.
- Stores email in database
- Sends welcome email
- Idempotent (subscribing twice is OK)
"""
email_lower = request.email.lower()
# Check if already subscribed
result = await db.execute(
select(NewsletterSubscriber).where(
NewsletterSubscriber.email == email_lower
)
)
existing = result.scalar_one_or_none()
if existing:
if existing.is_active:
return MessageResponse(
message="You're already subscribed to our newsletter!",
success=True,
)
else:
# Reactivate subscription
existing.is_active = True
existing.subscribed_at = datetime.utcnow()
await db.commit()
background_tasks.add_task(
email_service.send_newsletter_welcome,
to_email=email_lower,
)
return MessageResponse(
message="Welcome back! You've been re-subscribed to our newsletter.",
success=True,
)
# Create new subscription
import secrets
subscriber = NewsletterSubscriber(
email=email_lower,
is_active=True,
unsubscribe_token=secrets.token_urlsafe(32),
)
db.add(subscriber)
await db.commit()
# Send welcome email
background_tasks.add_task(
email_service.send_newsletter_welcome,
to_email=email_lower,
)
logger.info(f"Newsletter subscription: {email_lower}")
return MessageResponse(
message="Thanks for subscribing! Check your inbox for a welcome email.",
success=True,
)
@router.post("/newsletter/unsubscribe", response_model=MessageResponse)
async def unsubscribe_newsletter(
request: NewsletterUnsubscribeRequest,
db: Database,
):
"""
Unsubscribe from pounce newsletter.
- Marks subscription as inactive
- Can use token for one-click unsubscribe
"""
email_lower = request.email.lower()
# Find subscription
query = select(NewsletterSubscriber).where(
NewsletterSubscriber.email == email_lower
)
# If token provided, verify it
if request.token:
query = query.where(
NewsletterSubscriber.unsubscribe_token == request.token
)
result = await db.execute(query)
subscriber = result.scalar_one_or_none()
if not subscriber:
# Always return success (don't reveal if email exists)
return MessageResponse(
message="If you were subscribed, you have been unsubscribed.",
success=True,
)
subscriber.is_active = False
subscriber.unsubscribed_at = datetime.utcnow()
await db.commit()
logger.info(f"Newsletter unsubscription: {email_lower}")
return MessageResponse(
message="You have been unsubscribed from our newsletter.",
success=True,
)
@router.get("/newsletter/status")
async def check_newsletter_status(
email: EmailStr,
db: Database,
):
"""Check if an email is subscribed to the newsletter."""
result = await db.execute(
select(NewsletterSubscriber).where(
NewsletterSubscriber.email == email.lower()
)
)
subscriber = result.scalar_one_or_none()
return {
"email": email,
"subscribed": subscriber is not None and subscriber.is_active,
}

View File

@ -1,15 +1,53 @@
"""Subscription API endpoints."""
from fastapi import APIRouter, HTTPException, status
"""
Subscription API endpoints with Stripe integration.
Endpoints:
- GET /subscription - Get current subscription
- GET /subscription/tiers - Get available tiers
- GET /subscription/features - Get current features
- POST /subscription/checkout - Create Stripe checkout session
- POST /subscription/portal - Create Stripe customer portal session
- POST /subscription/cancel - Cancel subscription
"""
import os
from fastapi import APIRouter, HTTPException, status, Request
from sqlalchemy import select, func
from pydantic import BaseModel
from typing import Optional
from app.api.deps import Database, CurrentUser
from app.models.domain import Domain
from app.models.user import User
from app.models.subscription import Subscription, SubscriptionTier, TIER_CONFIG
from app.schemas.subscription import SubscriptionResponse, SubscriptionTierInfo
from app.schemas.subscription import SubscriptionResponse
from app.services.stripe_service import StripeService, TIER_FEATURES
from app.services.email_service import email_service
router = APIRouter()
# ============== Schemas ==============
class CheckoutRequest(BaseModel):
"""Request to create checkout session."""
plan: str # "trader" or "tycoon"
success_url: Optional[str] = None
cancel_url: Optional[str] = None
class CheckoutResponse(BaseModel):
"""Response with checkout URL."""
checkout_url: str
session_id: str
class PortalResponse(BaseModel):
"""Response with portal URL."""
portal_url: str
# ============== Endpoints ==============
@router.get("", response_model=SubscriptionResponse)
async def get_subscription(
current_user: CurrentUser,
@ -22,18 +60,23 @@ async def get_subscription(
subscription = result.scalar_one_or_none()
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No subscription found",
# Create default Scout subscription
subscription = Subscription(
user_id=current_user.id,
tier=SubscriptionTier.SCOUT,
max_domains=5,
check_frequency="daily",
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
# Count domains used
domain_count = await db.execute(
select(func.count(Domain.id)).where(Domain.user_id == current_user.id)
)
domains_used = domain_count.scalar()
domains_used = domain_count.scalar() or 0
# Get tier config
config = subscription.config
return SubscriptionResponse(
@ -59,43 +102,53 @@ async def get_subscription_tiers():
for tier_enum, config in TIER_CONFIG.items():
feature_list = []
# Build feature list for display
feature_list.append(f"{config['domain_limit']} domains in watchlist")
if config["check_frequency"] == "hourly":
if config.get("portfolio_limit"):
if config["portfolio_limit"] == -1:
feature_list.append("Unlimited portfolio domains")
elif config["portfolio_limit"] > 0:
feature_list.append(f"{config['portfolio_limit']} portfolio domains")
if config["check_frequency"] == "realtime":
feature_list.append("10-minute availability checks")
elif config["check_frequency"] == "hourly":
feature_list.append("Hourly availability checks")
else:
feature_list.append("Daily availability checks")
if config["features"]["priority_alerts"]:
feature_list.append("Priority email notifications")
else:
if config["features"].get("sms_alerts"):
feature_list.append("SMS & Telegram notifications")
elif config["features"].get("email_alerts"):
feature_list.append("Email notifications")
if config["features"]["full_whois"]:
feature_list.append("Full WHOIS data")
else:
feature_list.append("Basic WHOIS data")
if config["features"].get("domain_valuation"):
feature_list.append("Domain valuation")
if config["features"].get("market_insights"):
feature_list.append("Full market insights")
if config["history_days"] == -1:
feature_list.append("Unlimited check history")
elif config["history_days"] > 0:
feature_list.append(f"{config['history_days']}-day check history")
if config["features"]["expiration_tracking"]:
feature_list.append("Expiration date tracking")
if config["features"]["api_access"]:
if config["features"].get("api_access"):
feature_list.append("REST API access")
if config["features"]["webhooks"]:
feature_list.append("Webhook integrations")
if config["features"].get("bulk_tools"):
feature_list.append("Bulk import/export tools")
if config["features"].get("seo_metrics"):
feature_list.append("SEO metrics (DA, backlinks)")
tiers.append({
"id": tier_enum.value,
"name": config["name"],
"domain_limit": config["domain_limit"],
"portfolio_limit": config.get("portfolio_limit", 0),
"price": config["price"],
"currency": config.get("currency", "EUR"),
"check_frequency": config["check_frequency"],
"features": feature_list,
"feature_flags": config["features"],
@ -113,10 +166,17 @@ async def get_my_features(current_user: CurrentUser, db: Database):
subscription = result.scalar_one_or_none()
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No subscription found",
)
# Default to Scout
config = TIER_CONFIG[SubscriptionTier.SCOUT]
return {
"tier": "scout",
"tier_name": "Scout",
"domain_limit": config["domain_limit"],
"portfolio_limit": config.get("portfolio_limit", 0),
"check_frequency": config["check_frequency"],
"history_days": config["history_days"],
"features": config["features"],
}
config = subscription.config
@ -124,7 +184,162 @@ async def get_my_features(current_user: CurrentUser, db: Database):
"tier": subscription.tier.value,
"tier_name": config["name"],
"domain_limit": config["domain_limit"],
"portfolio_limit": config.get("portfolio_limit", 0),
"check_frequency": config["check_frequency"],
"history_days": config["history_days"],
"features": config["features"],
}
@router.post("/checkout", response_model=CheckoutResponse)
async def create_checkout_session(
request: CheckoutRequest,
current_user: CurrentUser,
db: Database,
):
"""
Create a Stripe Checkout session for subscription upgrade.
Args:
plan: "trader" or "tycoon"
success_url: URL to redirect after successful payment
cancel_url: URL to redirect if user cancels
Returns:
checkout_url: Stripe Checkout page URL
session_id: Stripe session ID
"""
if not StripeService.is_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Payment system not configured. Please contact support.",
)
if request.plan not in ["trader", "tycoon"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid plan. Must be 'trader' or 'tycoon'",
)
# Get site URL from environment
site_url = os.getenv("SITE_URL", "http://localhost:3000")
success_url = request.success_url or f"{site_url}/dashboard?upgraded=true"
cancel_url = request.cancel_url or f"{site_url}/pricing?cancelled=true"
try:
result = await StripeService.create_checkout_session(
user=current_user,
plan=request.plan,
success_url=success_url,
cancel_url=cancel_url,
)
# Save Stripe customer ID if new
if result.get("customer_id") and not current_user.stripe_customer_id:
current_user.stripe_customer_id = result["customer_id"]
await db.commit()
return CheckoutResponse(
checkout_url=result["checkout_url"],
session_id=result["session_id"],
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create checkout session: {str(e)}",
)
@router.post("/portal", response_model=PortalResponse)
async def create_portal_session(
current_user: CurrentUser,
db: Database,
):
"""
Create a Stripe Customer Portal session.
Users can:
- Update payment method
- View invoices
- Cancel subscription
- Update billing info
"""
if not StripeService.is_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Payment system not configured. Please contact support.",
)
if not current_user.stripe_customer_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No billing account found. Please subscribe to a plan first.",
)
site_url = os.getenv("SITE_URL", "http://localhost:3000")
return_url = f"{site_url}/dashboard"
try:
portal_url = await StripeService.create_portal_session(
customer_id=current_user.stripe_customer_id,
return_url=return_url,
)
return PortalResponse(portal_url=portal_url)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create portal session: {str(e)}",
)
@router.post("/cancel")
async def cancel_subscription(
current_user: CurrentUser,
db: Database,
):
"""
Cancel subscription and downgrade to Scout.
Note: For Stripe-managed subscriptions, use the Customer Portal instead.
This endpoint is for manual cancellation.
"""
result = await db.execute(
select(Subscription).where(Subscription.user_id == current_user.id)
)
subscription = result.scalar_one_or_none()
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No subscription found",
)
if subscription.tier == SubscriptionTier.SCOUT:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Already on free plan",
)
# Downgrade to Scout
old_tier = subscription.tier.value
subscription.tier = SubscriptionTier.SCOUT
subscription.max_domains = TIER_CONFIG[SubscriptionTier.SCOUT]["domain_limit"]
subscription.check_frequency = TIER_CONFIG[SubscriptionTier.SCOUT]["check_frequency"]
subscription.stripe_subscription_id = None
await db.commit()
return {
"status": "cancelled",
"message": f"Subscription cancelled. Downgraded from {old_tier} to Scout.",
"new_tier": "scout",
}

View File

@ -0,0 +1,73 @@
"""
Webhook endpoints for external service integrations.
- Stripe payment webhooks
- Future: Other payment providers, notification services, etc.
"""
import logging
from fastapi import APIRouter, HTTPException, Request, Header, status
from app.database import get_db
from app.services.stripe_service import StripeService
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/stripe")
async def stripe_webhook(
request: Request,
stripe_signature: str = Header(None, alias="Stripe-Signature"),
):
"""
Handle Stripe webhook events.
This endpoint receives events from Stripe when:
- Payment succeeds or fails
- Subscription is updated or cancelled
- Invoice is created or paid
The webhook must be configured in Stripe Dashboard to point to:
https://your-domain.com/api/webhooks/stripe
Required Header:
- Stripe-Signature: Stripe's webhook signature for verification
"""
if not stripe_signature:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing Stripe-Signature header",
)
if not StripeService.is_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Stripe not configured",
)
# Get raw body for signature verification
payload = await request.body()
try:
async for db in get_db():
result = await StripeService.handle_webhook(
payload=payload,
sig_header=stripe_signature,
db=db,
)
return result
except ValueError as e:
logger.error(f"Webhook validation error: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception as e:
logger.error(f"Webhook processing error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Webhook processing failed",
)

View File

@ -1,9 +1,14 @@
"""FastAPI application entry point."""
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from app.api import api_router
from app.config import get_settings
@ -19,6 +24,13 @@ logger = logging.getLogger(__name__)
settings = get_settings()
# Rate limiter configuration
limiter = Limiter(
key_func=get_remote_address,
default_limits=["200/minute"], # Global default
storage_uri="memory://", # In-memory storage (use Redis in production)
)
@asynccontextmanager
async def lifespan(app: FastAPI):
@ -44,21 +56,73 @@ async def lifespan(app: FastAPI):
# Create FastAPI application
app = FastAPI(
title=settings.app_name,
description="Domain availability monitoring service",
description="""
# pounce API
Domain availability monitoring and portfolio management service.
## Features
- **Domain Monitoring**: Track domains and get notified when they become available
- **TLD Pricing**: Real-time TLD price comparison across registrars
- **Portfolio Management**: Track your domain investments and valuations
- **Smart Pounce Auctions**: Find undervalued domains in auctions
## Authentication
Most endpoints require authentication via Bearer token.
Get a token via POST /api/v1/auth/login
## Rate Limits
- Default: 200 requests/minute per IP
- Auth endpoints: 10 requests/minute
- Contact form: 5 requests/hour
## Support
For API issues, contact support@pounce.ch
""",
version="1.0.0",
lifespan=lifespan,
redirect_slashes=False, # Prevent 307 redirects for trailing slashes
redirect_slashes=False,
docs_url="/docs",
redoc_url="/redoc",
)
# Add rate limiter to app state
app.state.limiter = limiter
# Custom rate limit exceeded handler
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={
"error": "rate_limit_exceeded",
"detail": "Too many requests. Please slow down.",
"retry_after": exc.detail,
},
)
# Get allowed origins from environment
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "").split(",")
if not ALLOWED_ORIGINS or ALLOWED_ORIGINS == [""]:
ALLOWED_ORIGINS = [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://10.42.0.73:3000",
]
# Add production origins
SITE_URL = os.getenv("SITE_URL", "")
if SITE_URL and SITE_URL not in ALLOWED_ORIGINS:
ALLOWED_ORIGINS.append(SITE_URL)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://10.42.0.73:3000",
# Add production origins here
],
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@ -70,16 +134,35 @@ app.include_router(api_router, prefix="/api/v1")
@app.get("/")
async def root():
"""Root endpoint."""
"""Root endpoint - API info."""
return {
"name": settings.app_name,
"version": "1.0.0",
"status": "running",
"docs": "/docs",
"health": "/health",
}
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy"}
"""Health check endpoint for monitoring."""
return {
"status": "healthy",
"service": settings.app_name,
"version": "1.0.0",
}
# Rate-limited endpoints - apply specific limits to sensitive routes
from fastapi import Depends
@app.middleware("http")
async def add_rate_limit_headers(request: Request, call_next):
"""Add rate limit info to response headers."""
response = await call_next(request)
# Add CORS headers for rate limit info
response.headers["X-RateLimit-Policy"] = "200/minute"
return response

View File

@ -5,6 +5,7 @@ from app.models.subscription import Subscription
from app.models.tld_price import TLDPrice, TLDInfo
from app.models.portfolio import PortfolioDomain, DomainValuation
from app.models.auction import DomainAuction, AuctionScrapeLog
from app.models.newsletter import NewsletterSubscriber
__all__ = [
"User",
@ -17,4 +18,5 @@ __all__ = [
"DomainValuation",
"DomainAuction",
"AuctionScrapeLog",
"NewsletterSubscriber",
]

View File

@ -0,0 +1,34 @@
"""Newsletter subscriber model."""
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class NewsletterSubscriber(Base):
"""Newsletter subscriber model."""
__tablename__ = "newsletter_subscribers"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
# Status
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Unsubscribe token for one-click unsubscribe
unsubscribe_token: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
# Timestamps
subscribed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
unsubscribed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Optional tracking
source: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # e.g., "homepage", "blog", "footer"
def __repr__(self) -> str:
status = "active" if self.is_active else "inactive"
return f"<NewsletterSubscriber {self.email} ({status})>"

View File

@ -26,11 +26,20 @@ class User(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
# Password Reset
password_reset_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
password_reset_expires: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Email Verification
email_verification_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
email_verification_expires: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Relationships
domains: Mapped[List["Domain"]] = relationship(
@ -46,3 +55,11 @@ class User(Base):
def __repr__(self) -> str:
return f"<User {self.email}>"
# Property aliases for compatibility
@property
def password_hash(self) -> str:
return self.hashed_password
@password_hash.setter
def password_hash(self, value: str):
self.hashed_password = value

View File

@ -8,6 +8,8 @@ Email Types:
- Price change notifications
- Subscription confirmations
- Password reset
- Email verification
- Contact form messages
- Weekly digests
Environment Variables Required:
@ -20,8 +22,7 @@ Environment Variables Required:
"""
import logging
import os
import asyncio
from typing import Optional, List, Dict, Any
from typing import Optional, List
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
@ -42,166 +43,281 @@ SMTP_CONFIG = {
"use_tls": os.getenv("SMTP_USE_TLS", "true").lower() == "true",
}
# Contact email - where contact form submissions are sent
CONTACT_EMAIL = os.getenv("CONTACT_EMAIL", "support@pounce.ch")
# Email Templates
TEMPLATES = {
"domain_available": """
# Base email wrapper template
BASE_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
.domain { font-family: monospace; font-size: 28px; color: #00d4aa; margin: 16px 0; }
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0a;
color: #e5e5e5;
padding: 20px;
margin: 0;
}
.container {
max-width: 600px;
margin: 0 auto;
background: #1a1a1a;
border-radius: 12px;
padding: 32px;
}
.logo {
color: #00d4aa;
font-size: 24px;
font-weight: bold;
margin-bottom: 24px;
}
h1 { color: #fff; margin: 0 0 16px 0; }
h2 { color: #fff; margin: 24px 0 16px 0; }
p { color: #e5e5e5; line-height: 1.6; }
.highlight {
font-family: monospace;
font-size: 24px;
color: #00d4aa;
margin: 16px 0;
}
.cta {
display: inline-block;
background: #00d4aa;
color: #0a0a0a;
padding: 14px 28px;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
margin-top: 16px;
}
.cta:hover { background: #00c49a; }
.secondary-cta {
display: inline-block;
background: transparent;
color: #00d4aa;
padding: 12px 24px;
border-radius: 8px;
border: 1px solid #00d4aa;
text-decoration: none;
font-weight: 500;
margin-top: 16px;
margin-left: 8px;
}
.info-box {
background: #252525;
padding: 16px;
border-radius: 8px;
margin: 16px 0;
}
.stat {
background: #252525;
padding: 16px;
border-radius: 8px;
margin: 8px 0;
display: flex;
justify-content: space-between;
}
.stat-value { color: #00d4aa; font-size: 20px; font-weight: bold; }
.warning { color: #f59e0b; }
.success { color: #00d4aa; }
.decrease { color: #00d4aa; }
.increase { color: #ef4444; }
.footer {
margin-top: 32px;
padding-top: 16px;
border-top: 1px solid #333;
color: #888;
font-size: 12px;
}
.footer a { color: #00d4aa; text-decoration: none; }
ul { padding-left: 20px; }
li { margin: 8px 0; }
code {
background: #252525;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
color: #00d4aa;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">🐆 pounce</div>
<h1 style="color: #fff; margin: 0;">Domain Available!</h1>
<p>Great news! A domain you're monitoring is now available for registration:</p>
<div class="domain">{{ domain }}</div>
<p>This domain was previously registered but has now expired or been deleted. Act fast before someone else registers it!</p>
<a href="{{ register_url }}" class="cta">Register Now →</a>
{{ content }}
<div class="footer">
<p>You're receiving this because you're monitoring this domain on pounce.</p>
<p>© {{ year }} pounce. All rights reserved.</p>
<p>
<a href="https://pounce.ch">pounce.ch</a> ·
<a href="https://pounce.ch/privacy">Privacy</a> ·
<a href="https://pounce.ch/terms">Terms</a>
</p>
</div>
</div>
</body>
</html>
"""
# Email Templates (content only, wrapped in BASE_TEMPLATE)
TEMPLATES = {
"domain_available": """
<h1>Domain Available!</h1>
<p>Great news! A domain you're monitoring is now available for registration:</p>
<div class="highlight">{{ domain }}</div>
<p>This domain was previously registered but has now expired or been deleted. Act fast before someone else registers it!</p>
<a href="{{ register_url }}" class="cta">Register Now →</a>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
You're receiving this because you're monitoring this domain on pounce.
</p>
""",
"price_alert": """
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
.tld { font-family: monospace; font-size: 24px; color: #00d4aa; }
.price-change { font-size: 20px; margin: 16px 0; }
.decrease { color: #00d4aa; }
.increase { color: #ef4444; }
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="logo">🐆 pounce</div>
<h1 style="color: #fff; margin: 0;">Price Alert: <span class="tld">.{{ tld }}</span></h1>
<p class="price-change">
{% if change_percent < 0 %}
<span class="decrease">↓ Price dropped {{ change_percent|abs }}%</span>
{% else %}
<span class="increase">↑ Price increased {{ change_percent }}%</span>
{% endif %}
</p>
<p>
<strong>Old price:</strong> ${{ old_price }}<br>
<strong>New price:</strong> ${{ new_price }}<br>
<strong>Cheapest registrar:</strong> {{ registrar }}
</p>
<a href="{{ tld_url }}" class="cta">View Details →</a>
<div class="footer">
<p>You're receiving this because you set a price alert for .{{ tld }} on pounce.</p>
<p>© {{ year }} pounce. All rights reserved.</p>
</div>
</div>
</body>
</html>
<h1>Price Alert: <span style="color: #00d4aa;">.{{ tld }}</span></h1>
<p style="font-size: 20px;">
{% if change_percent < 0 %}
<span class="decrease">↓ Price dropped {{ change_percent|abs }}%</span>
{% else %}
<span class="increase">↑ Price increased {{ change_percent }}%</span>
{% endif %}
</p>
<div class="info-box">
<p><strong>Old price:</strong> ${{ old_price }}</p>
<p><strong>New price:</strong> ${{ new_price }}</p>
<p><strong>Cheapest registrar:</strong> {{ registrar }}</p>
</div>
<a href="{{ tld_url }}" class="cta">View Details →</a>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
You're receiving this because you set a price alert for .{{ tld }} on pounce.
</p>
""",
"subscription_confirmed": """
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
.plan { font-size: 28px; color: #00d4aa; margin: 16px 0; }
.features { background: #252525; padding: 16px; border-radius: 8px; margin: 16px 0; }
.features li { margin: 8px 0; }
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="logo">🐆 pounce</div>
<h1 style="color: #fff; margin: 0;">Welcome to {{ plan_name }}!</h1>
<p>Your subscription is now active. Here's what you can do:</p>
<div class="features">
<ul>
{% for feature in features %}
<li>{{ feature }}</li>
{% endfor %}
</ul>
</div>
<a href="{{ dashboard_url }}" class="cta">Go to Dashboard →</a>
<div class="footer">
<p>Questions? Reply to this email or contact support@pounce.ch</p>
<p>© {{ year }} pounce. All rights reserved.</p>
</div>
</div>
</body>
</html>
<h1>Welcome to {{ plan_name }}!</h1>
<p>Your subscription is now active. Here's what you can do:</p>
<div class="info-box">
<ul>
{% for feature in features %}
<li>{{ feature }}</li>
{% endfor %}
</ul>
</div>
<a href="{{ dashboard_url }}" class="cta">Go to Dashboard →</a>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
Questions? Reply to this email or contact support@pounce.ch
</p>
""",
"weekly_digest": """
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
.stat { background: #252525; padding: 16px; border-radius: 8px; margin: 8px 0; display: flex; justify-content: space-between; }
.stat-value { color: #00d4aa; font-size: 20px; font-weight: bold; }
.domain { font-family: monospace; color: #00d4aa; }
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="logo">🐆 pounce</div>
<h1 style="color: #fff; margin: 0;">Your Weekly Digest</h1>
<p>Here's what happened with your monitored domains this week:</p>
<div class="stat">
<span>Domains Monitored</span>
<span class="stat-value">{{ total_domains }}</span>
</div>
<div class="stat">
<span>Status Changes</span>
<span class="stat-value">{{ status_changes }}</span>
</div>
<div class="stat">
<span>Price Alerts</span>
<span class="stat-value">{{ price_alerts }}</span>
</div>
{% if available_domains %}
<h2 style="color: #fff; margin-top: 24px;">Domains Now Available</h2>
{% for domain in available_domains %}
<p class="domain">{{ domain }}</p>
{% endfor %}
{% endif %}
<a href="{{ dashboard_url }}" class="cta">View Dashboard →</a>
<div class="footer">
<p>© {{ year }} pounce. All rights reserved.</p>
</div>
</div>
</body>
</html>
<h1>Your Weekly Digest</h1>
<p>Here's what happened with your monitored domains this week:</p>
<div class="stat">
<span>Domains Monitored</span>
<span class="stat-value">{{ total_domains }}</span>
</div>
<div class="stat">
<span>Status Changes</span>
<span class="stat-value">{{ status_changes }}</span>
</div>
<div class="stat">
<span>Price Alerts</span>
<span class="stat-value">{{ price_alerts }}</span>
</div>
{% if available_domains %}
<h2>Domains Now Available</h2>
<div class="info-box">
{% for domain in available_domains %}
<p class="highlight" style="margin: 8px 0;">{{ domain }}</p>
{% endfor %}
</div>
{% endif %}
<a href="{{ dashboard_url }}" class="cta">View Dashboard →</a>
""",
"password_reset": """
<h1>Reset Your Password</h1>
<p>Hi {{ user_name }},</p>
<p>We received a request to reset your password. Click the button below to set a new password:</p>
<a href="{{ reset_url }}" class="cta">Reset Password →</a>
<p style="margin-top: 24px;">Or copy and paste this link into your browser:</p>
<code style="word-break: break-all;">{{ reset_url }}</code>
<div class="info-box" style="margin-top: 24px;">
<p class="warning" style="margin: 0;">⚠️ This link expires in 1 hour.</p>
</div>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
If you didn't request a password reset, you can safely ignore this email.
Your password won't be changed.
</p>
""",
"email_verification": """
<h1>Verify Your Email</h1>
<p>Hi {{ user_name }},</p>
<p>Welcome to pounce! Please verify your email address to activate your account:</p>
<a href="{{ verification_url }}" class="cta">Verify Email →</a>
<p style="margin-top: 24px;">Or copy and paste this link into your browser:</p>
<code style="word-break: break-all;">{{ verification_url }}</code>
<div class="info-box" style="margin-top: 24px;">
<p style="margin: 0;">This link expires in 24 hours.</p>
</div>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
If you didn't create an account on pounce, you can safely ignore this email.
</p>
""",
"contact_form": """
<h1>New Contact Form Submission</h1>
<div class="info-box">
<p><strong>From:</strong> {{ name }} &lt;{{ email }}&gt;</p>
<p><strong>Subject:</strong> {{ subject }}</p>
<p><strong>Date:</strong> {{ timestamp }}</p>
</div>
<h2>Message</h2>
<div class="info-box">
<p style="white-space: pre-wrap;">{{ message }}</p>
</div>
<p style="margin-top: 24px;">
<a href="mailto:{{ email }}" class="cta">Reply to {{ name }} →</a>
</p>
""",
"contact_confirmation": """
<h1>We've Received Your Message</h1>
<p>Hi {{ name }},</p>
<p>Thank you for contacting pounce! We've received your message and will get back to you as soon as possible.</p>
<div class="info-box">
<p><strong>Subject:</strong> {{ subject }}</p>
<p><strong>Your message:</strong></p>
<p style="white-space: pre-wrap; color: #888;">{{ message }}</p>
</div>
<p>We typically respond within 24-48 hours during business days.</p>
<a href="https://pounce.ch" class="secondary-cta">Back to pounce →</a>
""",
"newsletter_welcome": """
<h1>Welcome to pounce Insights!</h1>
<p>Hi there,</p>
<p>You're now subscribed to our newsletter. Here's what you can expect:</p>
<div class="info-box">
<ul>
<li>TLD market trends and analysis</li>
<li>Domain investing tips and strategies</li>
<li>New feature announcements</li>
<li>Exclusive deals and discounts</li>
</ul>
</div>
<p>We typically send 1-2 emails per month. No spam, ever.</p>
<a href="https://pounce.ch" class="cta">Explore pounce →</a>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
You can unsubscribe at any time by clicking the link at the bottom of any email.
</p>
""",
}
@ -222,6 +338,18 @@ class EmailService:
SMTP_CONFIG["password"]
)
@staticmethod
def _render_email(template_name: str, **kwargs) -> str:
"""Render email with base template wrapper."""
content_template = Template(TEMPLATES.get(template_name, ""))
content = content_template.render(**kwargs)
base_template = Template(BASE_TEMPLATE)
return base_template.render(
content=content,
year=datetime.utcnow().year,
)
@staticmethod
async def send_email(
to_email: str,
@ -236,7 +364,7 @@ class EmailService:
to_email: Recipient email address
subject: Email subject
html_content: HTML body
text_content: Plain text body (optional, for email clients that don't support HTML)
text_content: Plain text body (optional)
Returns:
True if sent successfully, False otherwise
@ -275,6 +403,8 @@ class EmailService:
logger.error(f"Failed to send email to {to_email}: {e}")
return False
# ============== Domain Alerts ==============
@staticmethod
async def send_domain_available(
to_email: str,
@ -283,13 +413,12 @@ class EmailService:
) -> bool:
"""Send domain available notification."""
if not register_url:
register_url = f"https://pounce.ch/dashboard"
register_url = "https://pounce.ch/dashboard"
template = Template(TEMPLATES["domain_available"])
html = template.render(
html = EmailService._render_email(
"domain_available",
domain=domain,
register_url=register_url,
year=datetime.utcnow().year,
)
return await EmailService.send_email(
@ -310,15 +439,14 @@ class EmailService:
"""Send TLD price change alert."""
change_percent = round(((new_price - old_price) / old_price) * 100, 1)
template = Template(TEMPLATES["price_alert"])
html = template.render(
html = EmailService._render_email(
"price_alert",
tld=tld,
old_price=f"{old_price:.2f}",
new_price=f"{new_price:.2f}",
change_percent=change_percent,
registrar=registrar,
tld_url=f"https://pounce.ch/tld-pricing/{tld}",
year=datetime.utcnow().year,
)
direction = "dropped" if change_percent < 0 else "increased"
@ -329,6 +457,8 @@ class EmailService:
text_content=f".{tld} price {direction} from ${old_price:.2f} to ${new_price:.2f} at {registrar}.",
)
# ============== Subscription ==============
@staticmethod
async def send_subscription_confirmed(
to_email: str,
@ -336,12 +466,11 @@ class EmailService:
features: List[str],
) -> bool:
"""Send subscription confirmation email."""
template = Template(TEMPLATES["subscription_confirmed"])
html = template.render(
html = EmailService._render_email(
"subscription_confirmed",
plan_name=plan_name,
features=features,
dashboard_url="https://pounce.ch/dashboard",
year=datetime.utcnow().year,
)
return await EmailService.send_email(
@ -351,6 +480,8 @@ class EmailService:
text_content=f"Your {plan_name} subscription is now active. Visit https://pounce.ch/dashboard to get started.",
)
# ============== Digest ==============
@staticmethod
async def send_weekly_digest(
to_email: str,
@ -360,22 +491,128 @@ class EmailService:
available_domains: List[str],
) -> bool:
"""Send weekly summary digest."""
template = Template(TEMPLATES["weekly_digest"])
html = template.render(
html = EmailService._render_email(
"weekly_digest",
total_domains=total_domains,
status_changes=status_changes,
price_alerts=price_alerts,
available_domains=available_domains,
dashboard_url="https://pounce.ch/dashboard",
year=datetime.utcnow().year,
)
return await EmailService.send_email(
to_email=to_email,
subject=f"📬 Your pounce Weekly Digest",
subject="📬 Your pounce Weekly Digest",
html_content=html,
text_content=f"This week: {total_domains} domains monitored, {status_changes} status changes, {price_alerts} price alerts.",
)
# ============== Authentication ==============
@staticmethod
async def send_password_reset(
to_email: str,
user_name: str,
reset_url: str,
) -> bool:
"""Send password reset email."""
html = EmailService._render_email(
"password_reset",
user_name=user_name,
reset_url=reset_url,
)
return await EmailService.send_email(
to_email=to_email,
subject="🔒 Reset Your pounce Password",
html_content=html,
text_content=f"Hi {user_name}, reset your password by visiting: {reset_url}. This link expires in 1 hour.",
)
@staticmethod
async def send_email_verification(
to_email: str,
user_name: str,
verification_url: str,
) -> bool:
"""Send email verification email."""
html = EmailService._render_email(
"email_verification",
user_name=user_name,
verification_url=verification_url,
)
return await EmailService.send_email(
to_email=to_email,
subject="✉️ Verify Your pounce Email",
html_content=html,
text_content=f"Hi {user_name}, verify your email by visiting: {verification_url}. This link expires in 24 hours.",
)
# ============== Contact Form ==============
@staticmethod
async def send_contact_form(
name: str,
email: str,
subject: str,
message: str,
) -> bool:
"""
Send contact form submission to support.
Also sends confirmation to the user.
"""
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
# Send to support
support_html = EmailService._render_email(
"contact_form",
name=name,
email=email,
subject=subject,
message=message,
timestamp=timestamp,
)
support_sent = await EmailService.send_email(
to_email=CONTACT_EMAIL,
subject=f"[Contact] {subject} - from {name}",
html_content=support_html,
text_content=f"From: {name} <{email}>\nSubject: {subject}\n\n{message}",
)
# Send confirmation to user
confirm_html = EmailService._render_email(
"contact_confirmation",
name=name,
subject=subject,
message=message,
)
confirm_sent = await EmailService.send_email(
to_email=email,
subject="We've received your message - pounce",
html_content=confirm_html,
text_content=f"Hi {name}, we've received your message and will get back to you soon.",
)
return support_sent # Return whether support email was sent
# ============== Newsletter ==============
@staticmethod
async def send_newsletter_welcome(
to_email: str,
) -> bool:
"""Send newsletter subscription welcome email."""
html = EmailService._render_email("newsletter_welcome")
return await EmailService.send_email(
to_email=to_email,
subject="🎉 Welcome to pounce Insights!",
html_content=html,
text_content="Welcome to pounce Insights! You'll receive TLD market trends, domain investing tips, and feature announcements.",
)
# Global instance

View File

@ -23,7 +23,10 @@ SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-characters
ACCESS_TOKEN_EXPIRE_MINUTES=10080
# CORS Origins (comma-separated)
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# Email Verification (set to "true" to require email verification before login)
REQUIRE_EMAIL_VERIFICATION=false
# =================================
# Stripe Payments
@ -33,6 +36,7 @@ STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
# Price IDs from Stripe Dashboard (Products > Prices)
# Create products "Trader" and "Tycoon" in Stripe, then get their Price IDs
STRIPE_PRICE_TRADER=price_xxxxxxxxxxxxxx
STRIPE_PRICE_TYCOON=price_xxxxxxxxxxxxxx
@ -65,6 +69,9 @@ SMTP_FROM_EMAIL=noreply@pounce.ch
SMTP_FROM_NAME=pounce
SMTP_USE_TLS=true
# Email for contact form submissions
CONTACT_EMAIL=support@pounce.ch
# =================================
# Scheduler Settings
# =================================
@ -86,5 +93,14 @@ ENVIRONMENT=development
# Debug mode (disable in production!)
DEBUG=true
# Site URL (for email links)
# Site URL (for email links, password reset, etc.)
SITE_URL=http://localhost:3000
# =================================
# Rate Limiting
# =================================
# Default rate limit (requests per minute per IP)
# Rate limits are enforced in API endpoints
# Contact form: 5/hour
# Auth (login/register): 10/minute
# General API: 200/minute

View File

@ -1,35 +1,46 @@
# FastAPI & Server
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
python-multipart>=0.0.12
# Database
sqlalchemy>=2.0.35
aiosqlite>=0.20.0
asyncpg>=0.30.0
alembic>=1.14.0
# Authentication
# Database
# Domain Checking
# Email (optional, for notifications)
# FastAPI & Server
# Production Database (optional)
# Scheduling
# Utilities
# Web Scraping
aiosmtplib>=3.0.2
aiosqlite>=0.20.0
alembic>=1.14.0
apscheduler>=3.10.4
asyncpg>=0.30.0
passlib[bcrypt]>=1.7.4
bcrypt>=4.0.0,<4.1
beautifulsoup4>=4.12.0
python-jose[cryptography]>=3.3.0
# Validation & Settings
pydantic[email]>=2.10.0
pydantic-settings>=2.6.0
python-dotenv>=1.0.1
# Domain Checking
python-whois>=0.9.4
whodap>=0.1.12
dnspython>=2.7.0
fastapi>=0.115.0
# Web Scraping
httpx>=0.28.0
jinja2>=3.1.2
beautifulsoup4>=4.12.0
lxml>=5.0.0
# Scheduling
apscheduler>=3.10.4
# Email (SMTP)
aiosmtplib>=3.0.2
jinja2>=3.1.2
# Payments
stripe>=7.0.0
passlib[bcrypt]>=1.7.4
pydantic-settings>=2.6.0
pydantic[email]>=2.10.0
python-dotenv>=1.0.1
python-jose[cryptography]>=3.3.0
python-multipart>=0.0.12
python-whois>=0.9.4
sqlalchemy>=2.0.35
uvicorn[standard]>=0.32.0
whodap>=0.1.12
# Rate Limiting
slowapi>=0.1.9
# Production Database (optional)
# asyncpg>=0.30.0 # Already included above

View File

@ -3,7 +3,8 @@
import { useState } from 'react'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { Mail, MessageSquare, Clock, Send, Loader2, CheckCircle, MapPin, Building } from 'lucide-react'
import { api } from '@/lib/api'
import { Mail, MessageSquare, Clock, Send, Loader2, CheckCircle, MapPin, Building, AlertCircle } from 'lucide-react'
import Link from 'next/link'
const contactMethods = [
@ -46,7 +47,8 @@ const faqs = [
]
export default function ContactPage() {
const [formState, setFormState] = useState<'idle' | 'loading' | 'success'>('idle')
const [formState, setFormState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const [error, setError] = useState<string | null>(null)
const [formData, setFormData] = useState({
name: '',
email: '',
@ -57,16 +59,26 @@ export default function ContactPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setFormState('loading')
setError(null)
// Simulate form submission
await new Promise(resolve => setTimeout(resolve, 1500))
setFormState('success')
// Reset after showing success
setTimeout(() => {
setFormState('idle')
setFormData({ name: '', email: '', subject: '', message: '' })
}, 3000)
try {
await api.submitContact(
formData.name,
formData.email,
formData.subject,
formData.message
)
setFormState('success')
// Reset after showing success
setTimeout(() => {
setFormState('idle')
setFormData({ name: '', email: '', subject: '', message: '' })
}, 5000)
} catch (err: any) {
setFormState('error')
setError(err.message || 'Failed to send message. Please try again.')
}
}
return (
@ -132,11 +144,17 @@ export default function ContactPage() {
</div>
<h3 className="text-body-lg font-medium text-foreground mb-2">Message Sent!</h3>
<p className="text-body-sm text-foreground-muted">
We'll get back to you within 24 hours.
We've sent you a confirmation email. We'll get back to you within 24 hours.
</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{formState === 'error' && error && (
<div className="p-4 bg-danger/10 border border-danger/20 rounded-xl flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-danger flex-shrink-0" />
<p className="text-body-sm text-danger">{error}</p>
</div>
)}
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="text-ui-sm text-foreground-muted mb-2 block">Name</label>

View File

@ -0,0 +1,126 @@
'use client'
import { useState } from 'react'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { api } from '@/lib/api'
import { Mail, ArrowLeft, CheckCircle, AlertCircle } from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
await api.forgotPassword(email)
setSuccess(true)
} catch (err: any) {
// Always show success message (security: don't reveal if email exists)
setSuccess(true)
} finally {
setLoading(false)
}
}
return (
<>
<Header />
<main className="min-h-screen flex items-center justify-center p-4 pt-24">
<div className="w-full max-w-md">
<Link
href="/login"
className="inline-flex items-center gap-2 text-foreground-muted hover:text-foreground mb-8 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to login
</Link>
{success ? (
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center">
<div className="w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircle className="w-8 h-8 text-accent" />
</div>
<h1 className="text-display-sm font-bold text-foreground mb-4">
Check your email
</h1>
<p className="text-foreground-muted mb-6">
If an account with <strong className="text-foreground">{email}</strong> exists,
we've sent a password reset link. The link expires in 1 hour.
</p>
<p className="text-body-sm text-foreground-subtle">
Didn't receive the email? Check your spam folder or{' '}
<button
onClick={() => setSuccess(false)}
className="text-accent hover:underline"
>
try again
</button>
</p>
</div>
) : (
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8">
<div className="text-center mb-8">
<h1 className="text-display-sm font-bold text-foreground mb-2">
Forgot your password?
</h1>
<p className="text-foreground-muted">
Enter your email and we'll send you a reset link.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="flex items-center gap-3 p-4 bg-danger/10 border border-danger/20 rounded-xl text-danger">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<p className="text-body-sm">{error}</p>
</div>
)}
<div>
<label htmlFor="email" className="block text-body-sm font-medium text-foreground mb-2">
Email address
</label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-muted" />
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="you@example.com"
className="w-full pl-12 pr-4 py-3 bg-background-tertiary border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className={clsx(
'w-full py-3 px-4 bg-accent text-background font-medium rounded-xl transition-all',
loading
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-accent-hover active:scale-[0.98]'
)}
>
{loading ? 'Sending...' : 'Send reset link'}
</button>
</form>
</div>
)}
</div>
</main>
<Footer />
</>
)
}

View File

@ -108,6 +108,15 @@ export default function LoginPage() {
</div>
</div>
<div className="flex justify-end">
<Link
href="/forgot-password"
className="text-body-xs sm:text-body-sm text-foreground-muted hover:text-accent transition-colors duration-300"
>
Forgot password?
</Link>
</div>
<button
type="submit"
disabled={loading}

View File

@ -0,0 +1,225 @@
'use client'
import { useState, useEffect, Suspense } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { api } from '@/lib/api'
import { Lock, Eye, EyeOff, CheckCircle, AlertCircle, ArrowLeft } from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
function ResetPasswordContent() {
const searchParams = useSearchParams()
const router = useRouter()
const token = searchParams.get('token')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!token) {
setError('Invalid or missing reset token. Please request a new password reset link.')
}
}, [token])
const validatePassword = (pwd: string): string | null => {
if (pwd.length < 8) {
return 'Password must be at least 8 characters'
}
return null
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const passwordError = validatePassword(password)
if (passwordError) {
setError(passwordError)
return
}
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
if (!token) {
setError('Invalid reset token')
return
}
setLoading(true)
setError(null)
try {
await api.resetPassword(token, password)
setSuccess(true)
// Redirect to login after 3 seconds
setTimeout(() => {
router.push('/login?reset=success')
}, 3000)
} catch (err: any) {
setError(err.message || 'Failed to reset password. The link may have expired.')
} finally {
setLoading(false)
}
}
const passwordStrength = (pwd: string): { label: string; color: string; width: string } => {
if (pwd.length === 0) return { label: '', color: 'bg-transparent', width: 'w-0' }
if (pwd.length < 6) return { label: 'Weak', color: 'bg-danger', width: 'w-1/4' }
if (pwd.length < 8) return { label: 'Fair', color: 'bg-warning', width: 'w-2/4' }
if (pwd.length < 12) return { label: 'Good', color: 'bg-accent', width: 'w-3/4' }
return { label: 'Strong', color: 'bg-accent', width: 'w-full' }
}
const strength = passwordStrength(password)
return (
<>
<Header />
<main className="min-h-screen flex items-center justify-center p-4 pt-24">
<div className="w-full max-w-md">
<Link
href="/login"
className="inline-flex items-center gap-2 text-foreground-muted hover:text-foreground mb-8 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to login
</Link>
{success ? (
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center">
<div className="w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircle className="w-8 h-8 text-accent" />
</div>
<h1 className="text-display-sm font-bold text-foreground mb-4">
Password reset successful
</h1>
<p className="text-foreground-muted mb-6">
Your password has been reset. Redirecting you to login...
</p>
<Link
href="/login"
className="inline-flex items-center justify-center px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
>
Go to login
</Link>
</div>
) : (
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8">
<div className="text-center mb-8">
<h1 className="text-display-sm font-bold text-foreground mb-2">
Reset your password
</h1>
<p className="text-foreground-muted">
Enter your new password below.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="flex items-center gap-3 p-4 bg-danger/10 border border-danger/20 rounded-xl text-danger">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<p className="text-body-sm">{error}</p>
</div>
)}
<div>
<label htmlFor="password" className="block text-body-sm font-medium text-foreground mb-2">
New password
</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-muted" />
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
placeholder="At least 8 characters"
className="w-full pl-12 pr-12 py-3 bg-background-tertiary border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-foreground-muted hover:text-foreground"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{/* Password strength indicator */}
{password && (
<div className="mt-2">
<div className="h-1 bg-background-tertiary rounded-full overflow-hidden">
<div className={clsx('h-full transition-all', strength.color, strength.width)} />
</div>
<p className="text-ui-xs text-foreground-muted mt-1">{strength.label}</p>
</div>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-body-sm font-medium text-foreground mb-2">
Confirm password
</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-muted" />
<input
id="confirmPassword"
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
placeholder="Confirm your password"
className="w-full pl-12 pr-4 py-3 bg-background-tertiary border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all"
/>
</div>
{confirmPassword && password !== confirmPassword && (
<p className="text-ui-xs text-danger mt-1">Passwords do not match</p>
)}
</div>
<button
type="submit"
disabled={loading || !token}
className={clsx(
'w-full py-3 px-4 bg-accent text-background font-medium rounded-xl transition-all',
(loading || !token)
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-accent-hover active:scale-[0.98]'
)}
>
{loading ? 'Resetting...' : 'Reset password'}
</button>
</form>
</div>
)}
</div>
</main>
<Footer />
</>
)
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={
<main className="min-h-screen flex items-center justify-center">
<div className="animate-pulse text-foreground-muted">Loading...</div>
</main>
}>
<ResetPasswordContent />
</Suspense>
)
}

View File

@ -0,0 +1,128 @@
'use client'
import { useState, useEffect, Suspense } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { api } from '@/lib/api'
import { Mail, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'
import Link from 'next/link'
function VerifyEmailContent() {
const searchParams = useSearchParams()
const router = useRouter()
const token = searchParams.get('token')
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!token) {
setStatus('error')
setError('Invalid or missing verification token.')
return
}
const verifyEmail = async () => {
try {
await api.verifyEmail(token)
setStatus('success')
// Redirect to login after 3 seconds
setTimeout(() => {
router.push('/login?verified=true')
}, 3000)
} catch (err: any) {
setStatus('error')
setError(err.message || 'Failed to verify email. The link may have expired.')
}
}
verifyEmail()
}, [token, router])
return (
<>
<Header />
<main className="min-h-screen flex items-center justify-center p-4 pt-24">
<div className="w-full max-w-md">
{status === 'loading' && (
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center">
<div className="w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-6">
<Loader2 className="w-8 h-8 text-accent animate-spin" />
</div>
<h1 className="text-display-sm font-bold text-foreground mb-4">
Verifying your email...
</h1>
<p className="text-foreground-muted">
Please wait while we verify your email address.
</p>
</div>
)}
{status === 'success' && (
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center">
<div className="w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircle className="w-8 h-8 text-accent" />
</div>
<h1 className="text-display-sm font-bold text-foreground mb-4">
Email verified!
</h1>
<p className="text-foreground-muted mb-6">
Your email has been verified successfully. Redirecting you to login...
</p>
<Link
href="/login"
className="inline-flex items-center justify-center px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
>
Go to login
</Link>
</div>
)}
{status === 'error' && (
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center">
<div className="w-16 h-16 bg-danger/10 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertCircle className="w-8 h-8 text-danger" />
</div>
<h1 className="text-display-sm font-bold text-foreground mb-4">
Verification failed
</h1>
<p className="text-foreground-muted mb-6">
{error}
</p>
<div className="space-y-3">
<Link
href="/login"
className="inline-flex items-center justify-center w-full px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
>
Go to login
</Link>
<button
onClick={() => window.location.reload()}
className="inline-flex items-center justify-center w-full px-6 py-3 bg-background-tertiary text-foreground font-medium rounded-xl hover:bg-background-secondary transition-all border border-border"
>
Try again
</button>
</div>
</div>
)}
</div>
</main>
<Footer />
</>
)
}
export default function VerifyEmailPage() {
return (
<Suspense fallback={
<main className="min-h-screen flex items-center justify-center">
<div className="animate-pulse text-foreground-muted">Loading...</div>
</main>
}>
<VerifyEmailContent />
</Suspense>
)
}

View File

@ -106,6 +106,83 @@ class ApiClient {
}>('/auth/me')
}
// Password Reset
async forgotPassword(email: string) {
return this.request<{ message: string; success: boolean }>('/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email }),
})
}
async resetPassword(token: string, newPassword: string) {
return this.request<{ message: string; success: boolean }>('/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, new_password: newPassword }),
})
}
// Email Verification
async verifyEmail(token: string) {
return this.request<{ message: string; success: boolean }>('/auth/verify-email', {
method: 'POST',
body: JSON.stringify({ token }),
})
}
async resendVerification(email: string) {
return this.request<{ message: string; success: boolean }>('/auth/resend-verification', {
method: 'POST',
body: JSON.stringify({ email }),
})
}
// Contact Form
async submitContact(name: string, email: string, subject: string, message: string) {
return this.request<{ message: string; success: boolean }>('/contact', {
method: 'POST',
body: JSON.stringify({ name, email, subject, message }),
})
}
// Newsletter
async subscribeNewsletter(email: string) {
return this.request<{ message: string; success: boolean }>('/contact/newsletter/subscribe', {
method: 'POST',
body: JSON.stringify({ email }),
})
}
async unsubscribeNewsletter(email: string, token?: string) {
return this.request<{ message: string; success: boolean }>('/contact/newsletter/unsubscribe', {
method: 'POST',
body: JSON.stringify({ email, token }),
})
}
// Subscription - Stripe Integration
async createCheckoutSession(plan: string, successUrl?: string, cancelUrl?: string) {
return this.request<{ checkout_url: string; session_id: string }>('/subscription/checkout', {
method: 'POST',
body: JSON.stringify({
plan,
success_url: successUrl,
cancel_url: cancelUrl,
}),
})
}
async createPortalSession() {
return this.request<{ portal_url: string }>('/subscription/portal', {
method: 'POST',
})
}
async cancelSubscription() {
return this.request<{ status: string; message: string; new_tier: string }>('/subscription/cancel', {
method: 'POST',
})
}
// Domain Check (public)
async checkDomain(domain: string, quick = false) {
return this.request<{