feat: Complete all missing functional features
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Dashboard Improvements: - Portfolio: Sell Domain Modal mit Profit-Vorschau - Portfolio: Edit Domain Modal für alle Felder - Watchlist: Notification Toggle Button (Bell Icon) - Neue Handler-Funktionen für alle Aktionen New Pages: - /settings - Profile, Notifications, Billing, Security - /blog/[slug] - Blog Detail Page mit Share-Buttons - /unsubscribe - Newsletter Unsubscribe Seite Navigation Updates: - Settings Icon im Header für eingeloggte User - Unsubscribe Link im Footer (Legal Section) API Additions: - updateDomainNotify() für Watchlist-Benachrichtigungen
This commit is contained in:
0
.github/workflows/deploy.yml
vendored
Normal file → Executable file
0
.github/workflows/deploy.yml
vendored
Normal file → Executable file
449
frontend/src/app/blog/[slug]/page.tsx
Normal file
449
frontend/src/app/blog/[slug]/page.tsx
Normal file
@ -0,0 +1,449 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { ArrowLeft, Calendar, Clock, User, Share2, Twitter, Linkedin, Link as LinkIcon, BookOpen, ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
|
||||
// Sample blog content - in production this would come from a CMS or API
|
||||
const blogPosts: Record<string, {
|
||||
title: string
|
||||
excerpt: string
|
||||
content: string
|
||||
category: string
|
||||
date: string
|
||||
readTime: string
|
||||
author: string
|
||||
}> = {
|
||||
'complete-guide-domain-investing-2025': {
|
||||
title: 'The Complete Guide to Domain Investing in 2025',
|
||||
excerpt: 'Everything you need to know about finding, evaluating, and acquiring valuable domains in today\'s market.',
|
||||
category: 'Guide',
|
||||
date: 'Dec 5, 2025',
|
||||
readTime: '12 min read',
|
||||
author: 'pounce Team',
|
||||
content: `
|
||||
# The Complete Guide to Domain Investing in 2025
|
||||
|
||||
Domain investing has evolved significantly over the past decade. What was once a niche hobby has become a sophisticated market with professional investors, data-driven strategies, and substantial returns.
|
||||
|
||||
## Understanding the Domain Market
|
||||
|
||||
The domain market operates on fundamental principles of supply and demand. Premium domains - those that are short, memorable, and keyword-rich - command higher prices because they're scarce and valuable for branding.
|
||||
|
||||
### Key Factors That Determine Domain Value
|
||||
|
||||
1. **Length** - Shorter domains (2-4 characters) are exponentially more valuable
|
||||
2. **TLD** - .com remains king, but .io, .ai, and country codes have grown in value
|
||||
3. **Keywords** - Domains containing valuable keywords command premiums
|
||||
4. **Brandability** - Easy to pronounce, spell, and remember
|
||||
5. **History** - Clean history with no spam associations
|
||||
|
||||
## Finding Undervalued Domains
|
||||
|
||||
The key to successful domain investing is finding domains that are undervalued relative to their potential. Here are strategies:
|
||||
|
||||
### Expired Domain Hunting
|
||||
Monitor domain expiration lists for previously registered domains. Tools like pounce make this easy by alerting you when domains on your watchlist become available.
|
||||
|
||||
### Trend Spotting
|
||||
Stay ahead of industry trends. Domains related to emerging technologies often appreciate rapidly.
|
||||
|
||||
### Geographic Opportunities
|
||||
Country-code TLDs (ccTLDs) can be undervalued in their home markets but valuable internationally.
|
||||
|
||||
## Building a Portfolio
|
||||
|
||||
Diversification is key. Don't put all your capital into a single premium domain. Instead:
|
||||
|
||||
- Mix price points (some high-value, some speculative)
|
||||
- Diversify across TLDs
|
||||
- Balance keyword domains with brandable domains
|
||||
- Track your portfolio's performance with tools like pounce
|
||||
|
||||
## Selling Strategies
|
||||
|
||||
When it's time to sell:
|
||||
|
||||
1. **Marketplace Listings** - Sedo, Afternic, Dan.com
|
||||
2. **Direct Outreach** - Contact companies that might benefit
|
||||
3. **Auction Platforms** - GoDaddy Auctions, NameJet
|
||||
4. **Broker Services** - For premium domains over $50k
|
||||
|
||||
## Conclusion
|
||||
|
||||
Domain investing in 2025 requires data, patience, and strategy. Use tools like pounce to monitor opportunities, track your portfolio, and make informed decisions.
|
||||
|
||||
Happy investing!
|
||||
`,
|
||||
},
|
||||
'understanding-tld-pricing-trends': {
|
||||
title: 'Understanding TLD Pricing Trends',
|
||||
excerpt: 'How domain extension prices fluctuate and what it means for your portfolio.',
|
||||
category: 'Market Analysis',
|
||||
date: 'Dec 3, 2025',
|
||||
readTime: '5 min read',
|
||||
author: 'pounce Team',
|
||||
content: `
|
||||
# Understanding TLD Pricing Trends
|
||||
|
||||
TLD (Top-Level Domain) pricing isn't static. Registry operators regularly adjust prices based on market conditions, and these changes can significantly impact your domain investment strategy.
|
||||
|
||||
## Why TLD Prices Change
|
||||
|
||||
### Registry Price Increases
|
||||
Registries like Verisign (.com, .net) have contractual rights to increase prices. In 2024, .com prices increased by approximately 7%.
|
||||
|
||||
### Market Demand
|
||||
New TLDs may start cheap to encourage adoption, then increase as demand grows. .io and .ai are prime examples.
|
||||
|
||||
### Promotional Pricing
|
||||
Registrars often offer first-year discounts, but renewal prices can be significantly higher.
|
||||
|
||||
## How to Track Pricing
|
||||
|
||||
Use pounce's TLD pricing intelligence to:
|
||||
- Compare prices across registrars
|
||||
- Track historical price trends
|
||||
- Set alerts for price drops
|
||||
- Identify the cheapest renewal options
|
||||
|
||||
## Strategic Implications
|
||||
|
||||
Understanding pricing trends helps you:
|
||||
1. Time your purchases for maximum savings
|
||||
2. Budget accurately for renewals
|
||||
3. Identify undervalued TLDs before price increases
|
||||
4. Avoid TLDs with volatile pricing
|
||||
|
||||
Stay informed, and your portfolio will thank you.
|
||||
`,
|
||||
},
|
||||
'whois-privacy:-what-you-need-to-know': {
|
||||
title: 'WHOIS Privacy: What You Need to Know',
|
||||
excerpt: 'A deep dive into domain privacy protection and why it matters.',
|
||||
category: 'Security',
|
||||
date: 'Nov 28, 2025',
|
||||
readTime: '4 min read',
|
||||
author: 'pounce Team',
|
||||
content: `
|
||||
# WHOIS Privacy: What You Need to Know
|
||||
|
||||
When you register a domain, your personal information becomes part of the public WHOIS database. Here's what you need to understand about privacy protection.
|
||||
|
||||
## What is WHOIS?
|
||||
|
||||
WHOIS is a public database containing registration information for every domain. It includes:
|
||||
- Registrant name and address
|
||||
- Email and phone number
|
||||
- Registration and expiration dates
|
||||
- Nameservers
|
||||
|
||||
## Why Privacy Matters
|
||||
|
||||
### Spam Prevention
|
||||
Public WHOIS data is harvested by spammers for email lists.
|
||||
|
||||
### Identity Protection
|
||||
Your personal address shouldn't be publicly searchable.
|
||||
|
||||
### Business Confidentiality
|
||||
Competitors can see what domains you're acquiring.
|
||||
|
||||
## WHOIS Privacy Solutions
|
||||
|
||||
Most registrars offer WHOIS privacy (also called "ID Protection") that replaces your information with the registrar's proxy service.
|
||||
|
||||
### Costs
|
||||
- Some registrars include it free (Cloudflare, Porkbun)
|
||||
- Others charge $5-15/year
|
||||
- Factor this into your domain costs
|
||||
|
||||
## GDPR Impact
|
||||
|
||||
Since GDPR, European registrant data is often redacted by default. However, this varies by TLD and registrar.
|
||||
|
||||
## Our Recommendation
|
||||
|
||||
Always enable WHOIS privacy unless you have a specific business reason not to. The small cost is worth the protection.
|
||||
`,
|
||||
},
|
||||
'quick-wins:-domain-flipping-strategies': {
|
||||
title: 'Quick Wins: Domain Flipping Strategies',
|
||||
excerpt: 'Proven tactics for finding and selling domains at a profit.',
|
||||
category: 'Strategy',
|
||||
date: 'Nov 22, 2025',
|
||||
readTime: '7 min read',
|
||||
author: 'pounce Team',
|
||||
content: `
|
||||
# Quick Wins: Domain Flipping Strategies
|
||||
|
||||
Domain flipping - buying domains at low prices and selling them for a profit - can be lucrative when done right. Here are proven strategies.
|
||||
|
||||
## The Basics
|
||||
|
||||
Successful flipping requires:
|
||||
1. Finding undervalued domains
|
||||
2. Holding costs management
|
||||
3. Effective sales channels
|
||||
4. Patience and persistence
|
||||
|
||||
## Strategy 1: Expired Domain Hunting
|
||||
|
||||
Set up alerts on pounce for:
|
||||
- Brandable names in popular niches
|
||||
- Keyword domains with search volume
|
||||
- Short domains (2-4 letters)
|
||||
- Premium TLDs (.com, .io, .ai)
|
||||
|
||||
## Strategy 2: Trend Riding
|
||||
|
||||
Monitor:
|
||||
- Tech news for emerging terms
|
||||
- Trademark filings for new brands
|
||||
- Industry reports for growing sectors
|
||||
|
||||
Register relevant domains before they become valuable.
|
||||
|
||||
## Strategy 3: Typo Domains
|
||||
|
||||
(Proceed with caution - trademark issues exist)
|
||||
Minor misspellings of popular brands can receive traffic.
|
||||
|
||||
## Strategy 4: Geographic Plays
|
||||
|
||||
Register city + service combinations:
|
||||
- austinplumbers.com
|
||||
- denverrealestate.com
|
||||
|
||||
## Selling Tips
|
||||
|
||||
1. Price reasonably (10-20x registration cost is realistic)
|
||||
2. Use multiple marketplaces
|
||||
3. Respond quickly to inquiries
|
||||
4. Consider installment payments for higher prices
|
||||
|
||||
## Risk Management
|
||||
|
||||
- Don't overextend on speculative registrations
|
||||
- Set a renewal budget and stick to it
|
||||
- Track ROI on every domain
|
||||
- Drop non-performers after 1-2 years
|
||||
|
||||
Happy flipping!
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
export default function BlogPostPage() {
|
||||
const params = useParams()
|
||||
const slug = params.slug as string
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const post = blogPosts[slug]
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative flex flex-col">
|
||||
<Header />
|
||||
<main className="relative pt-32 sm:pt-36 pb-20 px-4 sm:px-6 flex-1">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<h1 className="text-heading-lg font-medium text-foreground mb-4">Post Not Found</h1>
|
||||
<p className="text-body text-foreground-muted mb-8">
|
||||
The blog post you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center gap-2 text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Blog
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleShare = (platform: 'twitter' | 'linkedin') => {
|
||||
const url = encodeURIComponent(window.location.href)
|
||||
const title = encodeURIComponent(post.title)
|
||||
|
||||
const shareUrls = {
|
||||
twitter: `https://twitter.com/intent/tweet?text=${title}&url=${url}`,
|
||||
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${url}`,
|
||||
}
|
||||
|
||||
window.open(shareUrls[platform], '_blank', 'width=600,height=400')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative flex flex-col">
|
||||
{/* Ambient glow */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="relative pt-32 sm:pt-36 pb-20 px-4 sm:px-6 flex-1">
|
||||
<article className="max-w-3xl mx-auto">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center gap-2 text-body-sm text-foreground-muted hover:text-foreground transition-colors mb-8"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Blog
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-10 animate-fade-in">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="px-2.5 py-1 bg-accent/10 text-accent text-ui-xs font-medium rounded-full">
|
||||
{post.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="font-display text-[2rem] sm:text-[2.5rem] md:text-[3rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-6">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<p className="text-body-lg text-foreground-muted mb-6">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-body-sm text-foreground-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<User className="w-4 h-4" />
|
||||
{post.author}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{post.date}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
{post.readTime}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="prose prose-invert prose-lg max-w-none animate-slide-up">
|
||||
{post.content.split('\n').map((line, i) => {
|
||||
if (line.startsWith('# ')) {
|
||||
return <h1 key={i} className="text-heading-lg font-display text-foreground mt-8 mb-4">{line.slice(2)}</h1>
|
||||
}
|
||||
if (line.startsWith('## ')) {
|
||||
return <h2 key={i} className="text-heading-md font-medium text-foreground mt-8 mb-4">{line.slice(3)}</h2>
|
||||
}
|
||||
if (line.startsWith('### ')) {
|
||||
return <h3 key={i} className="text-heading-sm font-medium text-foreground mt-6 mb-3">{line.slice(4)}</h3>
|
||||
}
|
||||
if (line.startsWith('- ')) {
|
||||
return <li key={i} className="text-body text-foreground-muted ml-4">{line.slice(2)}</li>
|
||||
}
|
||||
if (line.match(/^\d+\. /)) {
|
||||
return <li key={i} className="text-body text-foreground-muted ml-4 list-decimal">{line.replace(/^\d+\. /, '')}</li>
|
||||
}
|
||||
if (line.startsWith('**') && line.endsWith('**')) {
|
||||
return <p key={i} className="text-body font-medium text-foreground my-2">{line.slice(2, -2)}</p>
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
return <br key={i} />
|
||||
}
|
||||
return <p key={i} className="text-body text-foreground-muted my-4 leading-relaxed">{line}</p>
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Share */}
|
||||
<div className="mt-12 pt-8 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-body-sm text-foreground-muted">Share this article</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleShare('twitter')}
|
||||
className="p-2.5 text-foreground-subtle hover:text-foreground hover:bg-background-secondary rounded-lg transition-all"
|
||||
title="Share on Twitter"
|
||||
>
|
||||
<Twitter className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleShare('linkedin')}
|
||||
className="p-2.5 text-foreground-subtle hover:text-foreground hover:bg-background-secondary rounded-lg transition-all"
|
||||
title="Share on LinkedIn"
|
||||
>
|
||||
<Linkedin className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="p-2.5 text-foreground-subtle hover:text-foreground hover:bg-background-secondary rounded-lg transition-all"
|
||||
title="Copy link"
|
||||
>
|
||||
<LinkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
{copied && (
|
||||
<span className="text-ui-xs text-accent">Copied!</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related posts */}
|
||||
<div className="mt-12 pt-8 border-t border-border">
|
||||
<h3 className="text-heading-sm font-medium text-foreground mb-6">Continue Reading</h3>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{Object.entries(blogPosts)
|
||||
.filter(([s]) => s !== slug)
|
||||
.slice(0, 2)
|
||||
.map(([postSlug, relatedPost]) => (
|
||||
<Link
|
||||
key={postSlug}
|
||||
href={`/blog/${postSlug}`}
|
||||
className="p-5 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover transition-all group"
|
||||
>
|
||||
<span className="text-ui-xs text-accent mb-2 block">{relatedPost.category}</span>
|
||||
<h4 className="text-body font-medium text-foreground mb-2 group-hover:text-accent transition-colors">
|
||||
{relatedPost.title}
|
||||
</h4>
|
||||
<p className="text-body-sm text-foreground-muted line-clamp-2">{relatedPost.excerpt}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-12 p-8 bg-background-secondary/50 border border-border rounded-2xl text-center">
|
||||
<BookOpen className="w-8 h-8 text-accent mx-auto mb-4" />
|
||||
<h3 className="text-heading-sm font-medium text-foreground mb-2">
|
||||
Ready to start investing?
|
||||
</h3>
|
||||
<p className="text-body text-foreground-muted mb-6">
|
||||
Track domains, monitor prices, and build your portfolio with pounce.
|
||||
</p>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Get Started Free
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -95,6 +95,31 @@ export default function DashboardPage() {
|
||||
})
|
||||
const [addingPortfolio, setAddingPortfolio] = useState(false)
|
||||
|
||||
// Edit Portfolio Modal state
|
||||
const [showEditPortfolioModal, setShowEditPortfolioModal] = useState(false)
|
||||
const [editingPortfolioDomain, setEditingPortfolioDomain] = useState<PortfolioDomain | null>(null)
|
||||
const [editPortfolioForm, setEditPortfolioForm] = useState({
|
||||
purchase_price: '',
|
||||
purchase_date: '',
|
||||
registrar: '',
|
||||
renewal_date: '',
|
||||
renewal_cost: '',
|
||||
notes: '',
|
||||
})
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
|
||||
// Sell Domain Modal state
|
||||
const [showSellModal, setShowSellModal] = useState(false)
|
||||
const [sellingDomain, setSellingDomain] = useState<PortfolioDomain | null>(null)
|
||||
const [sellForm, setSellForm] = useState({
|
||||
sale_date: new Date().toISOString().split('T')[0],
|
||||
sale_price: '',
|
||||
})
|
||||
const [processingSale, setProcessingSale] = useState(false)
|
||||
|
||||
// Notification toggle state
|
||||
const [togglingNotifyId, setTogglingNotifyId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [checkAuth])
|
||||
@ -255,6 +280,88 @@ export default function DashboardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle domain notification
|
||||
const handleToggleNotify = async (domainId: number, currentNotify: boolean) => {
|
||||
setTogglingNotifyId(domainId)
|
||||
try {
|
||||
await api.updateDomainNotify(domainId, !currentNotify)
|
||||
// Refresh domains list
|
||||
const { fetchDomains } = useStore.getState()
|
||||
await fetchDomains()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update notification setting')
|
||||
} finally {
|
||||
setTogglingNotifyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Open Edit Portfolio Modal
|
||||
const handleOpenEditPortfolio = (domain: PortfolioDomain) => {
|
||||
setEditingPortfolioDomain(domain)
|
||||
setEditPortfolioForm({
|
||||
purchase_price: domain.purchase_price?.toString() || '',
|
||||
purchase_date: domain.purchase_date || '',
|
||||
registrar: domain.registrar || '',
|
||||
renewal_date: domain.renewal_date || '',
|
||||
renewal_cost: domain.renewal_cost?.toString() || '',
|
||||
notes: domain.notes || '',
|
||||
})
|
||||
setShowEditPortfolioModal(true)
|
||||
}
|
||||
|
||||
// Save Edit Portfolio
|
||||
const handleSaveEditPortfolio = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!editingPortfolioDomain) return
|
||||
|
||||
setSavingEdit(true)
|
||||
try {
|
||||
await api.updatePortfolioDomain(editingPortfolioDomain.id, {
|
||||
purchase_price: editPortfolioForm.purchase_price ? parseFloat(editPortfolioForm.purchase_price) : undefined,
|
||||
purchase_date: editPortfolioForm.purchase_date || undefined,
|
||||
registrar: editPortfolioForm.registrar || undefined,
|
||||
renewal_date: editPortfolioForm.renewal_date || undefined,
|
||||
renewal_cost: editPortfolioForm.renewal_cost ? parseFloat(editPortfolioForm.renewal_cost) : undefined,
|
||||
notes: editPortfolioForm.notes || undefined,
|
||||
})
|
||||
setShowEditPortfolioModal(false)
|
||||
setEditingPortfolioDomain(null)
|
||||
loadPortfolio()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update domain')
|
||||
} finally {
|
||||
setSavingEdit(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Open Sell Modal
|
||||
const handleOpenSellModal = (domain: PortfolioDomain) => {
|
||||
setSellingDomain(domain)
|
||||
setSellForm({
|
||||
sale_date: new Date().toISOString().split('T')[0],
|
||||
sale_price: '',
|
||||
})
|
||||
setShowSellModal(true)
|
||||
}
|
||||
|
||||
// Process Sale
|
||||
const handleSellDomain = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!sellingDomain || !sellForm.sale_price) return
|
||||
|
||||
setProcessingSale(true)
|
||||
try {
|
||||
await api.markDomainSold(sellingDomain.id, sellForm.sale_date, parseFloat(sellForm.sale_price))
|
||||
setShowSellModal(false)
|
||||
setSellingDomain(null)
|
||||
loadPortfolio()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to mark domain as sold')
|
||||
} finally {
|
||||
setProcessingSale(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return 'Not checked yet'
|
||||
const date = new Date(dateStr)
|
||||
@ -574,6 +681,19 @@ export default function DashboardPage() {
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleNotify(domain.id, domain.notify)}
|
||||
disabled={togglingNotifyId === domain.id}
|
||||
className={clsx(
|
||||
"p-2 rounded-lg transition-all",
|
||||
domain.notify
|
||||
? "text-accent hover:text-accent-hover hover:bg-accent-muted"
|
||||
: "text-foreground-subtle hover:text-foreground hover:bg-background-tertiary"
|
||||
)}
|
||||
title={domain.notify ? "Notifications on" : "Notifications off"}
|
||||
>
|
||||
<Bell className={clsx("w-4 h-4", domain.notify && "fill-current", togglingNotifyId === domain.id && "animate-pulse")} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRefresh(domain.id)}
|
||||
disabled={refreshingId === domain.id}
|
||||
@ -745,6 +865,22 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{domain.status !== 'sold' && (
|
||||
<button
|
||||
onClick={() => handleOpenSellModal(domain)}
|
||||
className="p-2 text-foreground-subtle hover:text-accent hover:bg-accent-muted rounded-lg transition-all"
|
||||
title="Mark as sold"
|
||||
>
|
||||
<DollarSign className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleOpenEditPortfolio(domain)}
|
||||
className="p-2 text-foreground-subtle hover:text-foreground hover:bg-background-tertiary rounded-lg transition-all"
|
||||
title="Edit details"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRefreshPortfolioValue(domain.id)}
|
||||
disabled={refreshingPortfolioId === domain.id}
|
||||
@ -1066,6 +1202,242 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Portfolio Domain Modal */}
|
||||
{showEditPortfolioModal && editingPortfolioDomain && (
|
||||
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-background-secondary border border-border rounded-2xl max-w-lg w-full max-h-[90vh] overflow-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-body-lg font-medium text-foreground">Edit Domain</h3>
|
||||
<p className="text-body-sm text-foreground-muted font-mono">{editingPortfolioDomain.domain}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEditPortfolioModal(false)
|
||||
setEditingPortfolioDomain(null)
|
||||
}}
|
||||
className="p-2 text-foreground-subtle hover:text-foreground hover:bg-background-tertiary rounded-lg transition-all"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveEditPortfolio} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-ui-sm text-foreground-muted mb-2">Purchase Price</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editPortfolioForm.purchase_price}
|
||||
onChange={(e) => setEditPortfolioForm({ ...editPortfolioForm, purchase_price: e.target.value })}
|
||||
placeholder="$0.00"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground
|
||||
placeholder:text-foreground-subtle focus:outline-none focus:border-border-hover transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-ui-sm text-foreground-muted mb-2">Purchase Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editPortfolioForm.purchase_date}
|
||||
onChange={(e) => setEditPortfolioForm({ ...editPortfolioForm, purchase_date: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground
|
||||
focus:outline-none focus:border-border-hover transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-ui-sm text-foreground-muted mb-2">Registrar</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editPortfolioForm.registrar}
|
||||
onChange={(e) => setEditPortfolioForm({ ...editPortfolioForm, registrar: e.target.value })}
|
||||
placeholder="e.g., Namecheap, GoDaddy"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground
|
||||
placeholder:text-foreground-subtle focus:outline-none focus:border-border-hover transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-ui-sm text-foreground-muted mb-2">Renewal Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editPortfolioForm.renewal_date}
|
||||
onChange={(e) => setEditPortfolioForm({ ...editPortfolioForm, renewal_date: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground
|
||||
focus:outline-none focus:border-border-hover transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-ui-sm text-foreground-muted mb-2">Renewal Cost</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editPortfolioForm.renewal_cost}
|
||||
onChange={(e) => setEditPortfolioForm({ ...editPortfolioForm, renewal_cost: e.target.value })}
|
||||
placeholder="$0.00"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground
|
||||
placeholder:text-foreground-subtle focus:outline-none focus:border-border-hover transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-ui-sm text-foreground-muted mb-2">Notes</label>
|
||||
<textarea
|
||||
value={editPortfolioForm.notes}
|
||||
onChange={(e) => setEditPortfolioForm({ ...editPortfolioForm, notes: e.target.value })}
|
||||
placeholder="Any notes about this domain..."
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground
|
||||
placeholder:text-foreground-subtle focus:outline-none focus:border-border-hover transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowEditPortfolioModal(false)
|
||||
setEditingPortfolioDomain(null)
|
||||
}}
|
||||
className="flex-1 py-3 bg-background-tertiary text-foreground text-ui font-medium rounded-xl
|
||||
hover:bg-background transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingEdit}
|
||||
className="flex-1 py-3 bg-foreground text-background text-ui font-medium rounded-xl
|
||||
hover:bg-foreground/90 disabled:opacity-40 disabled:cursor-not-allowed transition-all
|
||||
flex items-center justify-center gap-2"
|
||||
>
|
||||
{savingEdit ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sell Domain Modal */}
|
||||
{showSellModal && sellingDomain && (
|
||||
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-background-secondary border border-border rounded-2xl max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-body-lg font-medium text-foreground">Mark as Sold</h3>
|
||||
<p className="text-body-sm text-foreground-muted font-mono">{sellingDomain.domain}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSellModal(false)
|
||||
setSellingDomain(null)
|
||||
}}
|
||||
className="p-2 text-foreground-subtle hover:text-foreground hover:bg-background-tertiary rounded-lg transition-all"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Current value info */}
|
||||
{sellingDomain.estimated_value && (
|
||||
<div className="mb-6 p-4 bg-background-tertiary rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-body-sm text-foreground-muted">Estimated Value</span>
|
||||
<span className="text-body font-medium text-foreground">{formatCurrency(sellingDomain.estimated_value)}</span>
|
||||
</div>
|
||||
{sellingDomain.purchase_price && (
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-body-sm text-foreground-muted">Purchase Price</span>
|
||||
<span className="text-body text-foreground-muted">{formatCurrency(sellingDomain.purchase_price)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSellDomain} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-ui-sm text-foreground-muted mb-2">Sale Price *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={sellForm.sale_price}
|
||||
onChange={(e) => setSellForm({ ...sellForm, sale_price: e.target.value })}
|
||||
placeholder="Enter sale price..."
|
||||
required
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground
|
||||
placeholder:text-foreground-subtle focus:outline-none focus:border-border-hover transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-ui-sm text-foreground-muted mb-2">Sale Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={sellForm.sale_date}
|
||||
onChange={(e) => setSellForm({ ...sellForm, sale_date: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground
|
||||
focus:outline-none focus:border-border-hover transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Profit preview */}
|
||||
{sellForm.sale_price && sellingDomain.purchase_price && (
|
||||
<div className="p-4 bg-accent-muted rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-body-sm text-foreground-muted">Profit</span>
|
||||
<span className={clsx(
|
||||
"text-body-lg font-medium",
|
||||
parseFloat(sellForm.sale_price) - sellingDomain.purchase_price >= 0 ? "text-accent" : "text-danger"
|
||||
)}>
|
||||
{parseFloat(sellForm.sale_price) - sellingDomain.purchase_price >= 0 ? '+' : ''}
|
||||
{formatCurrency(parseFloat(sellForm.sale_price) - sellingDomain.purchase_price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowSellModal(false)
|
||||
setSellingDomain(null)
|
||||
}}
|
||||
className="flex-1 py-3 bg-background-tertiary text-foreground text-ui font-medium rounded-xl
|
||||
hover:bg-background transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processingSale || !sellForm.sale_price}
|
||||
className="flex-1 py-3 bg-accent text-background text-ui font-medium rounded-xl
|
||||
hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed transition-all
|
||||
flex items-center justify-center gap-2"
|
||||
>
|
||||
{processingSale ? <Loader2 className="w-4 h-4 animate-spin" /> : <DollarSign className="w-4 h-4" />}
|
||||
Confirm Sale
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
500
frontend/src/app/settings/page.tsx
Normal file
500
frontend/src/app/settings/page.tsx
Normal file
@ -0,0 +1,500 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api, PriceAlert } from '@/lib/api'
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Bell,
|
||||
CreditCard,
|
||||
Shield,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
Crown,
|
||||
Zap,
|
||||
Settings,
|
||||
Key,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type SettingsTab = 'profile' | 'notifications' | 'billing' | 'security'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter()
|
||||
const { user, isAuthenticated, isLoading, checkAuth, subscription } = useStore()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('profile')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Profile form
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
})
|
||||
|
||||
// Price alerts
|
||||
const [priceAlerts, setPriceAlerts] = useState<PriceAlert[]>([])
|
||||
const [loadingAlerts, setLoadingAlerts] = useState(false)
|
||||
const [deletingAlertId, setDeletingAlertId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [checkAuth])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setProfileForm({
|
||||
name: user.name || '',
|
||||
email: user.email || '',
|
||||
})
|
||||
}
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && activeTab === 'notifications') {
|
||||
loadPriceAlerts()
|
||||
}
|
||||
}, [isAuthenticated, activeTab])
|
||||
|
||||
const loadPriceAlerts = async () => {
|
||||
setLoadingAlerts(true)
|
||||
try {
|
||||
const alerts = await api.getPriceAlerts()
|
||||
setPriceAlerts(alerts)
|
||||
} catch (err) {
|
||||
console.error('Failed to load alerts:', err)
|
||||
} finally {
|
||||
setLoadingAlerts(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
// In a real app, this would call an API to update the user
|
||||
// For now, we'll just show success
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
setSuccess('Profile updated successfully')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update profile')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePriceAlert = async (tld: string, alertId: number) => {
|
||||
setDeletingAlertId(alertId)
|
||||
try {
|
||||
await api.deletePriceAlert(tld)
|
||||
setPriceAlerts(prev => prev.filter(a => a.id !== alertId))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete alert')
|
||||
} finally {
|
||||
setDeletingAlertId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenBillingPortal = async () => {
|
||||
try {
|
||||
const { portal_url } = await api.createPortalSession()
|
||||
window.location.href = portal_url
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to open billing portal')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||
const isProOrHigher = ['Trader', 'Tycoon', 'Professional', 'Enterprise'].includes(tierName)
|
||||
|
||||
const tabs = [
|
||||
{ id: 'profile' as const, label: 'Profile', icon: User },
|
||||
{ id: 'notifications' as const, label: 'Notifications', icon: Bell },
|
||||
{ id: 'billing' as const, label: 'Billing', icon: CreditCard },
|
||||
{ id: 'security' as const, label: 'Security', icon: Shield },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative flex flex-col">
|
||||
{/* Ambient glow */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/3 w-[500px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="relative pt-32 sm:pt-36 pb-20 px-4 sm:px-6 flex-1">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8 animate-fade-in">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Settings className="w-6 h-6 text-accent" />
|
||||
<h1 className="font-display text-[2rem] sm:text-[2.5rem] leading-[1.1] tracking-[-0.03em] text-foreground">
|
||||
Settings
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-body text-foreground-muted">
|
||||
Manage your account preferences and notifications
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-danger-muted border border-danger/20 rounded-xl flex items-center gap-3 animate-fade-in">
|
||||
<AlertCircle className="w-5 h-5 text-danger shrink-0" />
|
||||
<p className="text-body-sm text-danger flex-1">{error}</p>
|
||||
<button onClick={() => setError(null)} className="text-danger hover:text-danger/80">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 p-4 bg-accent-muted border border-accent/20 rounded-xl flex items-center gap-3 animate-fade-in">
|
||||
<Check className="w-5 h-5 text-accent shrink-0" />
|
||||
<p className="text-body-sm text-accent flex-1">{success}</p>
|
||||
<button onClick={() => setSuccess(null)} className="text-accent hover:text-accent/80">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Sidebar */}
|
||||
<div className="lg:w-64 shrink-0">
|
||||
<nav className="p-2 bg-background-secondary/50 border border-border rounded-xl">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-4 py-3 text-ui-sm font-medium rounded-lg transition-all",
|
||||
activeTab === tab.id
|
||||
? "bg-foreground text-background"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-background-tertiary"
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Plan info */}
|
||||
<div className="mt-4 p-4 bg-background-secondary/30 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{isProOrHigher ? <Crown className="w-4 h-4 text-accent" /> : <Zap className="w-4 h-4 text-foreground-muted" />}
|
||||
<span className="text-body-sm font-medium text-foreground">{tierName} Plan</span>
|
||||
</div>
|
||||
<p className="text-body-xs text-foreground-muted mb-3">
|
||||
{subscription?.domains_used || 0} / {subscription?.domain_limit || 5} domains
|
||||
</p>
|
||||
{!isProOrHigher && (
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="flex items-center justify-center gap-2 w-full py-2 bg-accent text-background text-ui-xs font-medium rounded-lg hover:bg-accent-hover transition-all"
|
||||
>
|
||||
Upgrade
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Profile Tab */}
|
||||
{activeTab === 'profile' && (
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl animate-fade-in">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-6">Profile Information</h2>
|
||||
|
||||
<form onSubmit={handleSaveProfile} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-ui-sm text-foreground-muted mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileForm.name}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, name: e.target.value })}
|
||||
placeholder="Your name"
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground
|
||||
placeholder:text-foreground-subtle focus:outline-none focus:border-border-hover transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-ui-sm text-foreground-muted mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={profileForm.email}
|
||||
disabled
|
||||
className="w-full px-4 py-3 bg-background-tertiary border border-border rounded-xl text-body text-foreground-muted cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-ui-xs text-foreground-subtle mt-1">Email cannot be changed</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-6 py-3 bg-foreground text-background text-ui font-medium rounded-xl
|
||||
hover:bg-foreground/90 disabled:opacity-50 transition-all flex items-center gap-2"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
Save Changes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notifications Tab */}
|
||||
{activeTab === 'notifications' && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-4">Email Preferences</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center justify-between p-4 bg-background-tertiary rounded-xl cursor-pointer">
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">Domain Availability</p>
|
||||
<p className="text-body-xs text-foreground-muted">Get notified when watched domains become available</p>
|
||||
</div>
|
||||
<input type="checkbox" defaultChecked className="w-5 h-5 accent-accent" />
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between p-4 bg-background-tertiary rounded-xl cursor-pointer">
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">Price Alerts</p>
|
||||
<p className="text-body-xs text-foreground-muted">Get notified when TLD prices change</p>
|
||||
</div>
|
||||
<input type="checkbox" defaultChecked className="w-5 h-5 accent-accent" />
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between p-4 bg-background-tertiary rounded-xl cursor-pointer">
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">Weekly Digest</p>
|
||||
<p className="text-body-xs text-foreground-muted">Receive a weekly summary of your portfolio</p>
|
||||
</div>
|
||||
<input type="checkbox" className="w-5 h-5 accent-accent" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Price Alerts */}
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-4">Active Price Alerts</h2>
|
||||
|
||||
{loadingAlerts ? (
|
||||
<div className="py-8 flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-accent" />
|
||||
</div>
|
||||
) : priceAlerts.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<Bell className="w-8 h-8 text-foreground-subtle mx-auto mb-3" />
|
||||
<p className="text-body-sm text-foreground-muted mb-3">No price alerts set</p>
|
||||
<Link
|
||||
href="/tld-pricing"
|
||||
className="text-accent hover:text-accent-hover text-body-sm"
|
||||
>
|
||||
Browse TLD prices →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{priceAlerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-center justify-between p-4 bg-background-tertiary rounded-xl"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
alert.is_active ? "bg-accent" : "bg-foreground-subtle"
|
||||
)} />
|
||||
<div>
|
||||
<Link
|
||||
href={`/tld-pricing/${alert.tld}`}
|
||||
className="text-body-sm font-mono font-medium text-foreground hover:text-accent transition-colors"
|
||||
>
|
||||
.{alert.tld}
|
||||
</Link>
|
||||
<p className="text-body-xs text-foreground-muted">
|
||||
Alert on {alert.threshold_percent}% change
|
||||
{alert.target_price && ` or below $${alert.target_price}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeletePriceAlert(alert.tld, alert.id)}
|
||||
disabled={deletingAlertId === alert.id}
|
||||
className="p-2 text-foreground-subtle hover:text-danger hover:bg-danger-muted rounded-lg transition-all"
|
||||
>
|
||||
{deletingAlertId === alert.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing Tab */}
|
||||
{activeTab === 'billing' && (
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl animate-fade-in">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-6">Subscription & Billing</h2>
|
||||
|
||||
<div className="p-4 bg-background-tertiary rounded-xl mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-body font-medium text-foreground">{tierName} Plan</p>
|
||||
<p className="text-body-sm text-foreground-muted">
|
||||
{subscription?.check_frequency || 'Daily'} checks · {subscription?.domain_limit || 5} domains
|
||||
</p>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
"px-3 py-1 text-ui-xs font-medium rounded-full",
|
||||
isProOrHigher ? "bg-accent-muted text-accent" : "bg-background text-foreground-muted"
|
||||
)}>
|
||||
{isProOrHigher ? 'Active' : 'Free'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isProOrHigher ? (
|
||||
<button
|
||||
onClick={handleOpenBillingPortal}
|
||||
className="w-full py-3 bg-background text-foreground text-ui font-medium rounded-xl
|
||||
hover:bg-background-secondary transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Manage Subscription
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="w-full py-3 bg-accent text-background text-ui font-medium rounded-xl
|
||||
hover:bg-accent-hover transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Upgrade Plan
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-body-sm font-medium text-foreground">Plan Features</h3>
|
||||
<ul className="space-y-2">
|
||||
{subscription?.features && Object.entries(subscription.features).map(([key, value]) => (
|
||||
<li key={key} className="flex items-center gap-2 text-body-sm">
|
||||
{value ? (
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
) : (
|
||||
<span className="w-4 h-4 text-foreground-subtle">—</span>
|
||||
)}
|
||||
<span className={value ? 'text-foreground' : 'text-foreground-muted'}>
|
||||
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security Tab */}
|
||||
{activeTab === 'security' && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-4">Password</h2>
|
||||
<p className="text-body-sm text-foreground-muted mb-4">
|
||||
Change your password or reset it if you've forgotten it.
|
||||
</p>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-background-tertiary text-foreground text-ui-sm font-medium rounded-lg
|
||||
hover:bg-background transition-all"
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
Change Password
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-4">Account Security</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-4 bg-background-tertiary rounded-xl">
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">Email Verified</p>
|
||||
<p className="text-body-xs text-foreground-muted">Your email address has been verified</p>
|
||||
</div>
|
||||
<Check className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-background-tertiary rounded-xl">
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">Two-Factor Authentication</p>
|
||||
<p className="text-body-xs text-foreground-muted">Coming soon</p>
|
||||
</div>
|
||||
<span className="text-ui-xs px-2 py-1 bg-background text-foreground-muted rounded-full">Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-danger-muted/30 border border-danger/20 rounded-xl">
|
||||
<h2 className="text-body-lg font-medium text-danger mb-2">Danger Zone</h2>
|
||||
<p className="text-body-sm text-foreground-muted mb-4">
|
||||
Permanently delete your account and all associated data.
|
||||
</p>
|
||||
<button
|
||||
className="px-4 py-2 bg-danger text-white text-ui-sm font-medium rounded-lg hover:bg-danger/90 transition-all"
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
141
frontend/src/app/unsubscribe/page.tsx
Normal file
141
frontend/src/app/unsubscribe/page.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { api } from '@/lib/api'
|
||||
import { Mail, Loader2, CheckCircle, AlertCircle, ArrowLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function UnsubscribePage() {
|
||||
const searchParams = useSearchParams()
|
||||
const emailFromUrl = searchParams.get('email') || ''
|
||||
const tokenFromUrl = searchParams.get('token') || ''
|
||||
|
||||
const [email, setEmail] = useState(emailFromUrl)
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const handleUnsubscribe = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
setStatus('error')
|
||||
setMessage('Please enter a valid email address')
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('loading')
|
||||
setMessage('')
|
||||
|
||||
try {
|
||||
await api.unsubscribeNewsletter(email, tokenFromUrl || undefined)
|
||||
setStatus('success')
|
||||
setMessage('You have been successfully unsubscribed from our newsletter.')
|
||||
} catch (err: any) {
|
||||
setStatus('error')
|
||||
setMessage(err.message || 'Failed to unsubscribe. Please try again or contact support.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative flex flex-col">
|
||||
{/* Ambient glow */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-[400px] h-[300px] bg-accent/[0.02] rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="relative pt-32 sm:pt-36 pb-20 px-4 sm:px-6 flex-1 flex items-center justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-body-sm text-foreground-muted hover:text-foreground transition-colors mb-8"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Home
|
||||
</Link>
|
||||
|
||||
<div className="p-8 bg-background-secondary/50 border border-border rounded-2xl animate-fade-in">
|
||||
{/* Icon */}
|
||||
<div className="w-16 h-16 bg-background-tertiary rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<Mail className="w-8 h-8 text-foreground-muted" />
|
||||
</div>
|
||||
|
||||
{status === 'success' ? (
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-accent/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-6 h-6 text-accent" />
|
||||
</div>
|
||||
<h1 className="text-heading-sm font-medium text-foreground mb-2">Unsubscribed</h1>
|
||||
<p className="text-body text-foreground-muted mb-6">{message}</p>
|
||||
<p className="text-body-sm text-foreground-subtle">
|
||||
Changed your mind?{' '}
|
||||
<Link href="/blog" className="text-accent hover:text-accent-hover">
|
||||
Subscribe again
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-heading-sm font-medium text-foreground mb-2">Unsubscribe</h1>
|
||||
<p className="text-body text-foreground-muted">
|
||||
Enter your email to unsubscribe from our newsletter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUnsubscribe} className="space-y-4">
|
||||
{status === 'error' && message && (
|
||||
<div className="p-4 bg-danger-muted border border-danger/20 rounded-xl flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-danger shrink-0" />
|
||||
<p className="text-body-sm text-danger">{message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-ui-sm text-foreground-muted mb-2">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-body text-foreground
|
||||
placeholder:text-foreground-subtle focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'loading'}
|
||||
className="w-full py-3.5 bg-foreground text-background text-ui font-medium rounded-xl
|
||||
hover:bg-foreground/90 disabled:opacity-50 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Unsubscribe'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-body-xs text-foreground-subtle">
|
||||
You will no longer receive marketing emails from pounce.
|
||||
<br />
|
||||
Transactional emails (like alerts) are not affected.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -125,6 +125,11 @@ export function Footer() {
|
||||
Imprint
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/unsubscribe" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
Unsubscribe
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { LogOut, LayoutDashboard, Menu, X } from 'lucide-react'
|
||||
import { LogOut, LayoutDashboard, Menu, X, Settings } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
// Logo Component - Puma Image
|
||||
@ -83,6 +83,14 @@ export function Header() {
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/settings"
|
||||
className="p-2 text-foreground-subtle hover:text-foreground hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3 ml-2 pl-3 sm:pl-4 border-l border-border">
|
||||
<span className="text-ui-sm sm:text-ui text-foreground-subtle hidden md:inline">
|
||||
{user?.email}
|
||||
|
||||
@ -251,6 +251,17 @@ class ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
async updateDomainNotify(id: number, notify: boolean) {
|
||||
return this.request<{
|
||||
id: number
|
||||
name: string
|
||||
notify: boolean
|
||||
}>(`/domains/${id}/notify`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ notify }),
|
||||
})
|
||||
}
|
||||
|
||||
// Subscription
|
||||
async getSubscription() {
|
||||
return this.request<{
|
||||
|
||||
Reference in New Issue
Block a user