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:
59
README.md
59
README.md
@ -40,6 +40,13 @@ A professional full-stack application for monitoring domain name availability wi
|
|||||||
- **Authentication** — Secure JWT-based auth with subscription tiers
|
- **Authentication** — Secure JWT-based auth with subscription tiers
|
||||||
- **Dashboard** — Personal watchlist with status indicators and actions
|
- **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)
|
### TLD Detail Page (Professional)
|
||||||
- **Price Hero** — Instant view of cheapest price with direct registration link
|
- **Price Hero** — Instant view of cheapest price with direct registration link
|
||||||
- **Price Alert System** — Subscribe to email notifications for price changes
|
- **Price Alert System** — Subscribe to email notifications for price changes
|
||||||
@ -161,10 +168,20 @@ pounce/
|
|||||||
| `/` | Homepage with domain checker, features, pricing preview | No |
|
| `/` | Homepage with domain checker, features, pricing preview | No |
|
||||||
| `/login` | User login | No |
|
| `/login` | User login | No |
|
||||||
| `/register` | User registration | 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 |
|
| `/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 price overview with trends | No* |
|
||||||
| `/tld-pricing/[tld]` | TLD detail with registrar comparison | Yes |
|
| `/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
|
*Unauthenticated users see limited data with shimmer effects
|
||||||
|
|
||||||
@ -244,9 +261,14 @@ npm run dev
|
|||||||
### Authentication
|
### Authentication
|
||||||
| Method | Endpoint | Description |
|
| 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) |
|
| POST | `/api/v1/auth/login` | Login (returns JWT) |
|
||||||
| GET | `/api/v1/auth/me` | Get current user |
|
| 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
|
### Domain Check
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
@ -293,11 +315,28 @@ This ensures identical prices on:
|
|||||||
- Compare page (`/{tld}/compare`)
|
- Compare page (`/{tld}/compare`)
|
||||||
- Trending cards (`/trending`)
|
- Trending cards (`/trending`)
|
||||||
|
|
||||||
### Subscription
|
### Subscription & Payments
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| GET | `/api/v1/subscription` | Get current subscription |
|
| 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)
|
### Smart Pounce (Auctions)
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
@ -342,13 +381,23 @@ This ensures identical prices on:
|
|||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///./domainwatch.db` |
|
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///./domainwatch.db` |
|
||||||
| `SECRET_KEY` | JWT signing key (min 32 chars) | **Required** |
|
| `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) |
|
| `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` |
|
| `SCHEDULER_CHECK_INTERVAL_HOURS` | Domain check interval | `24` |
|
||||||
|
| **SMTP Settings** | | |
|
||||||
| `SMTP_HOST` | Email server host | Optional |
|
| `SMTP_HOST` | Email server host | Optional |
|
||||||
| `SMTP_PORT` | Email server port | `587` |
|
| `SMTP_PORT` | Email server port | `587` |
|
||||||
| `SMTP_USER` | Email username | Optional |
|
| `SMTP_USER` | Email username | Optional |
|
||||||
| `SMTP_PASSWORD` | Email password | 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)
|
### Frontend (.env.local)
|
||||||
|
|
||||||
|
|||||||
@ -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.tld_prices import router as tld_prices_router
|
||||||
from app.api.portfolio import router as portfolio_router
|
from app.api.portfolio import router as portfolio_router
|
||||||
from app.api.auctions import router as auctions_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()
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
# Core API endpoints
|
||||||
api_router.include_router(auth_router, prefix="/auth", tags=["Authentication"])
|
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(check_router, prefix="/check", tags=["Domain Check"])
|
||||||
api_router.include_router(domains_router, prefix="/domains", tags=["Domain Management"])
|
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(tld_prices_router, prefix="/tld-prices", tags=["TLD Prices"])
|
||||||
api_router.include_router(portfolio_router, prefix="/portfolio", tags=["Portfolio"])
|
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(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"])
|
||||||
|
|||||||
@ -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.api.deps import Database, CurrentUser
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.schemas.auth import UserCreate, UserLogin, UserResponse, Token
|
from app.schemas.auth import UserCreate, UserLogin, UserResponse, Token
|
||||||
from app.services.auth import AuthService
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Rate limiter for auth endpoints
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
settings = get_settings()
|
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)
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def register(user_data: UserCreate, db: Database):
|
async def register(
|
||||||
"""Register a new user."""
|
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
|
# Check if user exists
|
||||||
existing_user = await AuthService.get_user_by_email(db, user_data.email)
|
existing_user = await AuthService.get_user_by_email(db, user_data.email)
|
||||||
if existing_user:
|
if existing_user:
|
||||||
@ -31,12 +99,35 @@ async def register(user_data: UserCreate, db: Database):
|
|||||||
name=user_data.name,
|
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
|
return user
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
async def login(user_data: UserLogin, db: Database):
|
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)
|
user = await AuthService.authenticate_user(db, user_data.email, user_data.password)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
@ -46,6 +137,14 @@ async def login(user_data: UserLogin, db: Database):
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
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_expires = timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
access_token = AuthService.create_access_token(
|
access_token = AuthService.create_access_token(
|
||||||
data={"sub": str(user.id), "email": user.email},
|
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)
|
@router.put("/me", response_model=UserResponse)
|
||||||
async def update_current_user(
|
async def update_current_user(
|
||||||
|
update_data: UpdateUserRequest,
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: Database,
|
db: Database,
|
||||||
name: str = None,
|
|
||||||
):
|
):
|
||||||
"""Update current user information."""
|
"""Update current user information."""
|
||||||
if name is not None:
|
if update_data.name is not None:
|
||||||
current_user.name = name
|
current_user.name = update_data.name
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(current_user)
|
await db.refresh(current_user)
|
||||||
|
|
||||||
return 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
236
backend/app/api/contact.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
||||||
@ -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 sqlalchemy import select, func
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from app.api.deps import Database, CurrentUser
|
from app.api.deps import Database, CurrentUser
|
||||||
from app.models.domain import Domain
|
from app.models.domain import Domain
|
||||||
|
from app.models.user import User
|
||||||
from app.models.subscription import Subscription, SubscriptionTier, TIER_CONFIG
|
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()
|
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)
|
@router.get("", response_model=SubscriptionResponse)
|
||||||
async def get_subscription(
|
async def get_subscription(
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
@ -22,18 +60,23 @@ async def get_subscription(
|
|||||||
subscription = result.scalar_one_or_none()
|
subscription = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not subscription:
|
if not subscription:
|
||||||
raise HTTPException(
|
# Create default Scout subscription
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
subscription = Subscription(
|
||||||
detail="No subscription found",
|
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
|
# Count domains used
|
||||||
domain_count = await db.execute(
|
domain_count = await db.execute(
|
||||||
select(func.count(Domain.id)).where(Domain.user_id == current_user.id)
|
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
|
config = subscription.config
|
||||||
|
|
||||||
return SubscriptionResponse(
|
return SubscriptionResponse(
|
||||||
@ -59,43 +102,53 @@ async def get_subscription_tiers():
|
|||||||
for tier_enum, config in TIER_CONFIG.items():
|
for tier_enum, config in TIER_CONFIG.items():
|
||||||
feature_list = []
|
feature_list = []
|
||||||
|
|
||||||
# Build feature list for display
|
|
||||||
feature_list.append(f"{config['domain_limit']} domains in watchlist")
|
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")
|
feature_list.append("Hourly availability checks")
|
||||||
else:
|
else:
|
||||||
feature_list.append("Daily availability checks")
|
feature_list.append("Daily availability checks")
|
||||||
|
|
||||||
if config["features"]["priority_alerts"]:
|
if config["features"].get("sms_alerts"):
|
||||||
feature_list.append("Priority email notifications")
|
feature_list.append("SMS & Telegram notifications")
|
||||||
else:
|
elif config["features"].get("email_alerts"):
|
||||||
feature_list.append("Email notifications")
|
feature_list.append("Email notifications")
|
||||||
|
|
||||||
if config["features"]["full_whois"]:
|
if config["features"].get("domain_valuation"):
|
||||||
feature_list.append("Full WHOIS data")
|
feature_list.append("Domain valuation")
|
||||||
else:
|
|
||||||
feature_list.append("Basic WHOIS data")
|
if config["features"].get("market_insights"):
|
||||||
|
feature_list.append("Full market insights")
|
||||||
|
|
||||||
if config["history_days"] == -1:
|
if config["history_days"] == -1:
|
||||||
feature_list.append("Unlimited check history")
|
feature_list.append("Unlimited check history")
|
||||||
elif config["history_days"] > 0:
|
elif config["history_days"] > 0:
|
||||||
feature_list.append(f"{config['history_days']}-day check history")
|
feature_list.append(f"{config['history_days']}-day check history")
|
||||||
|
|
||||||
if config["features"]["expiration_tracking"]:
|
if config["features"].get("api_access"):
|
||||||
feature_list.append("Expiration date tracking")
|
|
||||||
|
|
||||||
if config["features"]["api_access"]:
|
|
||||||
feature_list.append("REST API access")
|
feature_list.append("REST API access")
|
||||||
|
|
||||||
if config["features"]["webhooks"]:
|
if config["features"].get("bulk_tools"):
|
||||||
feature_list.append("Webhook integrations")
|
feature_list.append("Bulk import/export tools")
|
||||||
|
|
||||||
|
if config["features"].get("seo_metrics"):
|
||||||
|
feature_list.append("SEO metrics (DA, backlinks)")
|
||||||
|
|
||||||
tiers.append({
|
tiers.append({
|
||||||
"id": tier_enum.value,
|
"id": tier_enum.value,
|
||||||
"name": config["name"],
|
"name": config["name"],
|
||||||
"domain_limit": config["domain_limit"],
|
"domain_limit": config["domain_limit"],
|
||||||
|
"portfolio_limit": config.get("portfolio_limit", 0),
|
||||||
"price": config["price"],
|
"price": config["price"],
|
||||||
|
"currency": config.get("currency", "EUR"),
|
||||||
"check_frequency": config["check_frequency"],
|
"check_frequency": config["check_frequency"],
|
||||||
"features": feature_list,
|
"features": feature_list,
|
||||||
"feature_flags": config["features"],
|
"feature_flags": config["features"],
|
||||||
@ -113,10 +166,17 @@ async def get_my_features(current_user: CurrentUser, db: Database):
|
|||||||
subscription = result.scalar_one_or_none()
|
subscription = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not subscription:
|
if not subscription:
|
||||||
raise HTTPException(
|
# Default to Scout
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
config = TIER_CONFIG[SubscriptionTier.SCOUT]
|
||||||
detail="No subscription found",
|
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
|
config = subscription.config
|
||||||
|
|
||||||
@ -124,7 +184,162 @@ async def get_my_features(current_user: CurrentUser, db: Database):
|
|||||||
"tier": subscription.tier.value,
|
"tier": subscription.tier.value,
|
||||||
"tier_name": config["name"],
|
"tier_name": config["name"],
|
||||||
"domain_limit": config["domain_limit"],
|
"domain_limit": config["domain_limit"],
|
||||||
|
"portfolio_limit": config.get("portfolio_limit", 0),
|
||||||
"check_frequency": config["check_frequency"],
|
"check_frequency": config["check_frequency"],
|
||||||
"history_days": config["history_days"],
|
"history_days": config["history_days"],
|
||||||
"features": config["features"],
|
"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",
|
||||||
|
}
|
||||||
|
|||||||
73
backend/app/api/webhooks.py
Normal file
73
backend/app/api/webhooks.py
Normal 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",
|
||||||
|
)
|
||||||
|
|
||||||
@ -1,9 +1,14 @@
|
|||||||
"""FastAPI application entry point."""
|
"""FastAPI application entry point."""
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.api import api_router
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
@ -19,6 +24,13 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
settings = get_settings()
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
@ -44,21 +56,73 @@ async def lifespan(app: FastAPI):
|
|||||||
# Create FastAPI application
|
# Create FastAPI application
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.app_name,
|
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",
|
version="1.0.0",
|
||||||
lifespan=lifespan,
|
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
|
# Configure CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[
|
allow_origins=ALLOWED_ORIGINS,
|
||||||
"http://localhost:3000",
|
|
||||||
"http://127.0.0.1:3000",
|
|
||||||
"http://10.42.0.73:3000",
|
|
||||||
# Add production origins here
|
|
||||||
],
|
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@ -70,16 +134,35 @@ app.include_router(api_router, prefix="/api/v1")
|
|||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
"""Root endpoint."""
|
"""Root endpoint - API info."""
|
||||||
return {
|
return {
|
||||||
"name": settings.app_name,
|
"name": settings.app_name,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
|
"docs": "/docs",
|
||||||
|
"health": "/health",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint for monitoring."""
|
||||||
return {"status": "healthy"}
|
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
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from app.models.subscription import Subscription
|
|||||||
from app.models.tld_price import TLDPrice, TLDInfo
|
from app.models.tld_price import TLDPrice, TLDInfo
|
||||||
from app.models.portfolio import PortfolioDomain, DomainValuation
|
from app.models.portfolio import PortfolioDomain, DomainValuation
|
||||||
from app.models.auction import DomainAuction, AuctionScrapeLog
|
from app.models.auction import DomainAuction, AuctionScrapeLog
|
||||||
|
from app.models.newsletter import NewsletterSubscriber
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@ -17,4 +18,5 @@ __all__ = [
|
|||||||
"DomainValuation",
|
"DomainValuation",
|
||||||
"DomainAuction",
|
"DomainAuction",
|
||||||
"AuctionScrapeLog",
|
"AuctionScrapeLog",
|
||||||
|
"NewsletterSubscriber",
|
||||||
]
|
]
|
||||||
|
|||||||
34
backend/app/models/newsletter.py
Normal file
34
backend/app/models/newsletter.py
Normal 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})>"
|
||||||
|
|
||||||
@ -26,11 +26,20 @@ class User(Base):
|
|||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
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
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||||
)
|
)
|
||||||
|
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
domains: Mapped[List["Domain"]] = relationship(
|
domains: Mapped[List["Domain"]] = relationship(
|
||||||
@ -46,3 +55,11 @@ class User(Base):
|
|||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<User {self.email}>"
|
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
|
||||||
|
|||||||
@ -8,6 +8,8 @@ Email Types:
|
|||||||
- Price change notifications
|
- Price change notifications
|
||||||
- Subscription confirmations
|
- Subscription confirmations
|
||||||
- Password reset
|
- Password reset
|
||||||
|
- Email verification
|
||||||
|
- Contact form messages
|
||||||
- Weekly digests
|
- Weekly digests
|
||||||
|
|
||||||
Environment Variables Required:
|
Environment Variables Required:
|
||||||
@ -20,8 +22,7 @@ Environment Variables Required:
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import asyncio
|
from typing import Optional, List
|
||||||
from typing import Optional, List, Dict, Any
|
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -42,166 +43,281 @@ SMTP_CONFIG = {
|
|||||||
"use_tls": os.getenv("SMTP_USE_TLS", "true").lower() == "true",
|
"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 = {
|
# Base email wrapper template
|
||||||
"domain_available": """
|
BASE_TEMPLATE = """
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<style>
|
<style>
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
|
body {
|
||||||
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
|
background: #0a0a0a;
|
||||||
.domain { font-family: monospace; font-size: 28px; color: #00d4aa; margin: 16px 0; }
|
color: #e5e5e5;
|
||||||
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
|
padding: 20px;
|
||||||
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="logo">🐆 pounce</div>
|
<div class="logo">🐆 pounce</div>
|
||||||
<h1 style="color: #fff; margin: 0;">Domain Available!</h1>
|
{{ content }}
|
||||||
<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>
|
|
||||||
<div class="footer">
|
<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>© {{ 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>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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": """
|
"price_alert": """
|
||||||
<!DOCTYPE html>
|
<h1>Price Alert: <span style="color: #00d4aa;">.{{ tld }}</span></h1>
|
||||||
<html>
|
<p style="font-size: 20px;">
|
||||||
<head>
|
{% if change_percent < 0 %}
|
||||||
<style>
|
<span class="decrease">↓ Price dropped {{ change_percent|abs }}%</span>
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
|
{% else %}
|
||||||
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
|
<span class="increase">↑ Price increased {{ change_percent }}%</span>
|
||||||
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
|
{% endif %}
|
||||||
.tld { font-family: monospace; font-size: 24px; color: #00d4aa; }
|
</p>
|
||||||
.price-change { font-size: 20px; margin: 16px 0; }
|
<div class="info-box">
|
||||||
.decrease { color: #00d4aa; }
|
<p><strong>Old price:</strong> ${{ old_price }}</p>
|
||||||
.increase { color: #ef4444; }
|
<p><strong>New price:</strong> ${{ new_price }}</p>
|
||||||
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
|
<p><strong>Cheapest registrar:</strong> {{ registrar }}</p>
|
||||||
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
|
</div>
|
||||||
</style>
|
<a href="{{ tld_url }}" class="cta">View Details →</a>
|
||||||
</head>
|
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
||||||
<body>
|
You're receiving this because you set a price alert for .{{ tld }} on pounce.
|
||||||
<div class="container">
|
</p>
|
||||||
<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>
|
|
||||||
""",
|
""",
|
||||||
|
|
||||||
"subscription_confirmed": """
|
"subscription_confirmed": """
|
||||||
<!DOCTYPE html>
|
<h1>Welcome to {{ plan_name }}!</h1>
|
||||||
<html>
|
<p>Your subscription is now active. Here's what you can do:</p>
|
||||||
<head>
|
<div class="info-box">
|
||||||
<style>
|
<ul>
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
|
{% for feature in features %}
|
||||||
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
|
<li>{{ feature }}</li>
|
||||||
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
|
{% endfor %}
|
||||||
.plan { font-size: 28px; color: #00d4aa; margin: 16px 0; }
|
</ul>
|
||||||
.features { background: #252525; padding: 16px; border-radius: 8px; margin: 16px 0; }
|
</div>
|
||||||
.features li { margin: 8px 0; }
|
<a href="{{ dashboard_url }}" class="cta">Go to Dashboard →</a>
|
||||||
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
|
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
||||||
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
|
Questions? Reply to this email or contact support@pounce.ch
|
||||||
</style>
|
</p>
|
||||||
</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>
|
|
||||||
""",
|
""",
|
||||||
|
|
||||||
"weekly_digest": """
|
"weekly_digest": """
|
||||||
<!DOCTYPE html>
|
<h1>Your Weekly Digest</h1>
|
||||||
<html>
|
<p>Here's what happened with your monitored domains this week:</p>
|
||||||
<head>
|
|
||||||
<style>
|
<div class="stat">
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
|
<span>Domains Monitored</span>
|
||||||
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
|
<span class="stat-value">{{ total_domains }}</span>
|
||||||
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
|
</div>
|
||||||
.stat { background: #252525; padding: 16px; border-radius: 8px; margin: 8px 0; display: flex; justify-content: space-between; }
|
<div class="stat">
|
||||||
.stat-value { color: #00d4aa; font-size: 20px; font-weight: bold; }
|
<span>Status Changes</span>
|
||||||
.domain { font-family: monospace; color: #00d4aa; }
|
<span class="stat-value">{{ status_changes }}</span>
|
||||||
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
|
</div>
|
||||||
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
|
<div class="stat">
|
||||||
</style>
|
<span>Price Alerts</span>
|
||||||
</head>
|
<span class="stat-value">{{ price_alerts }}</span>
|
||||||
<body>
|
</div>
|
||||||
<div class="container">
|
|
||||||
<div class="logo">🐆 pounce</div>
|
{% if available_domains %}
|
||||||
<h1 style="color: #fff; margin: 0;">Your Weekly Digest</h1>
|
<h2>Domains Now Available</h2>
|
||||||
<p>Here's what happened with your monitored domains this week:</p>
|
<div class="info-box">
|
||||||
|
{% for domain in available_domains %}
|
||||||
<div class="stat">
|
<p class="highlight" style="margin: 8px 0;">{{ domain }}</p>
|
||||||
<span>Domains Monitored</span>
|
{% endfor %}
|
||||||
<span class="stat-value">{{ total_domains }}</span>
|
</div>
|
||||||
</div>
|
{% endif %}
|
||||||
<div class="stat">
|
|
||||||
<span>Status Changes</span>
|
<a href="{{ dashboard_url }}" class="cta">View Dashboard →</a>
|
||||||
<span class="stat-value">{{ status_changes }}</span>
|
""",
|
||||||
</div>
|
|
||||||
<div class="stat">
|
"password_reset": """
|
||||||
<span>Price Alerts</span>
|
<h1>Reset Your Password</h1>
|
||||||
<span class="stat-value">{{ price_alerts }}</span>
|
<p>Hi {{ user_name }},</p>
|
||||||
</div>
|
<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>
|
||||||
{% if available_domains %}
|
<p style="margin-top: 24px;">Or copy and paste this link into your browser:</p>
|
||||||
<h2 style="color: #fff; margin-top: 24px;">Domains Now Available</h2>
|
<code style="word-break: break-all;">{{ reset_url }}</code>
|
||||||
{% for domain in available_domains %}
|
<div class="info-box" style="margin-top: 24px;">
|
||||||
<p class="domain">{{ domain }}</p>
|
<p class="warning" style="margin: 0;">⚠️ This link expires in 1 hour.</p>
|
||||||
{% endfor %}
|
</div>
|
||||||
{% endif %}
|
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
||||||
|
If you didn't request a password reset, you can safely ignore this email.
|
||||||
<a href="{{ dashboard_url }}" class="cta">View Dashboard →</a>
|
Your password won't be changed.
|
||||||
<div class="footer">
|
</p>
|
||||||
<p>© {{ year }} pounce. All rights reserved.</p>
|
""",
|
||||||
</div>
|
|
||||||
</div>
|
"email_verification": """
|
||||||
</body>
|
<h1>Verify Your Email</h1>
|
||||||
</html>
|
<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 }} <{{ email }}></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"]
|
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
|
@staticmethod
|
||||||
async def send_email(
|
async def send_email(
|
||||||
to_email: str,
|
to_email: str,
|
||||||
@ -236,7 +364,7 @@ class EmailService:
|
|||||||
to_email: Recipient email address
|
to_email: Recipient email address
|
||||||
subject: Email subject
|
subject: Email subject
|
||||||
html_content: HTML body
|
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:
|
Returns:
|
||||||
True if sent successfully, False otherwise
|
True if sent successfully, False otherwise
|
||||||
@ -275,6 +403,8 @@ class EmailService:
|
|||||||
logger.error(f"Failed to send email to {to_email}: {e}")
|
logger.error(f"Failed to send email to {to_email}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# ============== Domain Alerts ==============
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def send_domain_available(
|
async def send_domain_available(
|
||||||
to_email: str,
|
to_email: str,
|
||||||
@ -283,13 +413,12 @@ class EmailService:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send domain available notification."""
|
"""Send domain available notification."""
|
||||||
if not register_url:
|
if not register_url:
|
||||||
register_url = f"https://pounce.ch/dashboard"
|
register_url = "https://pounce.ch/dashboard"
|
||||||
|
|
||||||
template = Template(TEMPLATES["domain_available"])
|
html = EmailService._render_email(
|
||||||
html = template.render(
|
"domain_available",
|
||||||
domain=domain,
|
domain=domain,
|
||||||
register_url=register_url,
|
register_url=register_url,
|
||||||
year=datetime.utcnow().year,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return await EmailService.send_email(
|
return await EmailService.send_email(
|
||||||
@ -310,15 +439,14 @@ class EmailService:
|
|||||||
"""Send TLD price change alert."""
|
"""Send TLD price change alert."""
|
||||||
change_percent = round(((new_price - old_price) / old_price) * 100, 1)
|
change_percent = round(((new_price - old_price) / old_price) * 100, 1)
|
||||||
|
|
||||||
template = Template(TEMPLATES["price_alert"])
|
html = EmailService._render_email(
|
||||||
html = template.render(
|
"price_alert",
|
||||||
tld=tld,
|
tld=tld,
|
||||||
old_price=f"{old_price:.2f}",
|
old_price=f"{old_price:.2f}",
|
||||||
new_price=f"{new_price:.2f}",
|
new_price=f"{new_price:.2f}",
|
||||||
change_percent=change_percent,
|
change_percent=change_percent,
|
||||||
registrar=registrar,
|
registrar=registrar,
|
||||||
tld_url=f"https://pounce.ch/tld-pricing/{tld}",
|
tld_url=f"https://pounce.ch/tld-pricing/{tld}",
|
||||||
year=datetime.utcnow().year,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
direction = "dropped" if change_percent < 0 else "increased"
|
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}.",
|
text_content=f".{tld} price {direction} from ${old_price:.2f} to ${new_price:.2f} at {registrar}.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ============== Subscription ==============
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def send_subscription_confirmed(
|
async def send_subscription_confirmed(
|
||||||
to_email: str,
|
to_email: str,
|
||||||
@ -336,12 +466,11 @@ class EmailService:
|
|||||||
features: List[str],
|
features: List[str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send subscription confirmation email."""
|
"""Send subscription confirmation email."""
|
||||||
template = Template(TEMPLATES["subscription_confirmed"])
|
html = EmailService._render_email(
|
||||||
html = template.render(
|
"subscription_confirmed",
|
||||||
plan_name=plan_name,
|
plan_name=plan_name,
|
||||||
features=features,
|
features=features,
|
||||||
dashboard_url="https://pounce.ch/dashboard",
|
dashboard_url="https://pounce.ch/dashboard",
|
||||||
year=datetime.utcnow().year,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return await EmailService.send_email(
|
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.",
|
text_content=f"Your {plan_name} subscription is now active. Visit https://pounce.ch/dashboard to get started.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ============== Digest ==============
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def send_weekly_digest(
|
async def send_weekly_digest(
|
||||||
to_email: str,
|
to_email: str,
|
||||||
@ -360,22 +491,128 @@ class EmailService:
|
|||||||
available_domains: List[str],
|
available_domains: List[str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send weekly summary digest."""
|
"""Send weekly summary digest."""
|
||||||
template = Template(TEMPLATES["weekly_digest"])
|
html = EmailService._render_email(
|
||||||
html = template.render(
|
"weekly_digest",
|
||||||
total_domains=total_domains,
|
total_domains=total_domains,
|
||||||
status_changes=status_changes,
|
status_changes=status_changes,
|
||||||
price_alerts=price_alerts,
|
price_alerts=price_alerts,
|
||||||
available_domains=available_domains,
|
available_domains=available_domains,
|
||||||
dashboard_url="https://pounce.ch/dashboard",
|
dashboard_url="https://pounce.ch/dashboard",
|
||||||
year=datetime.utcnow().year,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return await EmailService.send_email(
|
return await EmailService.send_email(
|
||||||
to_email=to_email,
|
to_email=to_email,
|
||||||
subject=f"📬 Your pounce Weekly Digest",
|
subject="📬 Your pounce Weekly Digest",
|
||||||
html_content=html,
|
html_content=html,
|
||||||
text_content=f"This week: {total_domains} domains monitored, {status_changes} status changes, {price_alerts} price alerts.",
|
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
|
# Global instance
|
||||||
|
|||||||
@ -23,7 +23,10 @@ SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-characters
|
|||||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||||
|
|
||||||
# CORS Origins (comma-separated)
|
# 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
|
# Stripe Payments
|
||||||
@ -33,6 +36,7 @@ STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
|
|||||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
||||||
|
|
||||||
# Price IDs from Stripe Dashboard (Products > Prices)
|
# 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_TRADER=price_xxxxxxxxxxxxxx
|
||||||
STRIPE_PRICE_TYCOON=price_xxxxxxxxxxxxxx
|
STRIPE_PRICE_TYCOON=price_xxxxxxxxxxxxxx
|
||||||
|
|
||||||
@ -65,6 +69,9 @@ SMTP_FROM_EMAIL=noreply@pounce.ch
|
|||||||
SMTP_FROM_NAME=pounce
|
SMTP_FROM_NAME=pounce
|
||||||
SMTP_USE_TLS=true
|
SMTP_USE_TLS=true
|
||||||
|
|
||||||
|
# Email for contact form submissions
|
||||||
|
CONTACT_EMAIL=support@pounce.ch
|
||||||
|
|
||||||
# =================================
|
# =================================
|
||||||
# Scheduler Settings
|
# Scheduler Settings
|
||||||
# =================================
|
# =================================
|
||||||
@ -86,5 +93,14 @@ ENVIRONMENT=development
|
|||||||
# Debug mode (disable in production!)
|
# Debug mode (disable in production!)
|
||||||
DEBUG=true
|
DEBUG=true
|
||||||
|
|
||||||
# Site URL (for email links)
|
# Site URL (for email links, password reset, etc.)
|
||||||
SITE_URL=http://localhost:3000
|
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
|
||||||
|
|||||||
@ -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
|
# Authentication
|
||||||
# Database
|
passlib[bcrypt]>=1.7.4
|
||||||
# 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
|
|
||||||
bcrypt>=4.0.0,<4.1
|
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
|
dnspython>=2.7.0
|
||||||
fastapi>=0.115.0
|
|
||||||
|
# Web Scraping
|
||||||
httpx>=0.28.0
|
httpx>=0.28.0
|
||||||
jinja2>=3.1.2
|
beautifulsoup4>=4.12.0
|
||||||
lxml>=5.0.0
|
lxml>=5.0.0
|
||||||
|
|
||||||
|
# Scheduling
|
||||||
|
apscheduler>=3.10.4
|
||||||
|
|
||||||
|
# Email (SMTP)
|
||||||
|
aiosmtplib>=3.0.2
|
||||||
|
jinja2>=3.1.2
|
||||||
|
|
||||||
# Payments
|
# Payments
|
||||||
stripe>=7.0.0
|
stripe>=7.0.0
|
||||||
passlib[bcrypt]>=1.7.4
|
|
||||||
pydantic-settings>=2.6.0
|
# Rate Limiting
|
||||||
pydantic[email]>=2.10.0
|
slowapi>=0.1.9
|
||||||
python-dotenv>=1.0.1
|
|
||||||
python-jose[cryptography]>=3.3.0
|
# Production Database (optional)
|
||||||
python-multipart>=0.0.12
|
# asyncpg>=0.30.0 # Already included above
|
||||||
python-whois>=0.9.4
|
|
||||||
sqlalchemy>=2.0.35
|
|
||||||
uvicorn[standard]>=0.32.0
|
|
||||||
whodap>=0.1.12
|
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Header } from '@/components/Header'
|
import { Header } from '@/components/Header'
|
||||||
import { Footer } from '@/components/Footer'
|
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'
|
import Link from 'next/link'
|
||||||
|
|
||||||
const contactMethods = [
|
const contactMethods = [
|
||||||
@ -46,7 +47,8 @@ const faqs = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function ContactPage() {
|
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({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@ -57,16 +59,26 @@ export default function ContactPage() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setFormState('loading')
|
setFormState('loading')
|
||||||
|
setError(null)
|
||||||
|
|
||||||
// Simulate form submission
|
try {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
await api.submitContact(
|
||||||
setFormState('success')
|
formData.name,
|
||||||
|
formData.email,
|
||||||
// Reset after showing success
|
formData.subject,
|
||||||
setTimeout(() => {
|
formData.message
|
||||||
setFormState('idle')
|
)
|
||||||
setFormData({ name: '', email: '', subject: '', message: '' })
|
setFormState('success')
|
||||||
}, 3000)
|
|
||||||
|
// 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 (
|
return (
|
||||||
@ -132,11 +144,17 @@ export default function ContactPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-body-lg font-medium text-foreground mb-2">Message Sent!</h3>
|
<h3 className="text-body-lg font-medium text-foreground mb-2">Message Sent!</h3>
|
||||||
<p className="text-body-sm text-foreground-muted">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<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 className="grid sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-ui-sm text-foreground-muted mb-2 block">Name</label>
|
<label className="text-ui-sm text-foreground-muted mb-2 block">Name</label>
|
||||||
|
|||||||
126
frontend/src/app/forgot-password/page.tsx
Normal file
126
frontend/src/app/forgot-password/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -108,6 +108,15 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
225
frontend/src/app/reset-password/page.tsx
Normal file
225
frontend/src/app/reset-password/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
128
frontend/src/app/verify-email/page.tsx
Normal file
128
frontend/src/app/verify-email/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -106,6 +106,83 @@ class ApiClient {
|
|||||||
}>('/auth/me')
|
}>('/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)
|
// Domain Check (public)
|
||||||
async checkDomain(domain: string, quick = false) {
|
async checkDomain(domain: string, quick = false) {
|
||||||
return this.request<{
|
return this.request<{
|
||||||
|
|||||||
Reference in New Issue
Block a user