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

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:
yves.gugger
2025-12-08 15:07:12 +01:00
parent 8de107f5ee
commit 3ae2ef45d9
8 changed files with 1487 additions and 1 deletions

0
.github/workflows/deploy.yml vendored Normal file → Executable file
View File

View 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>
)
}

View File

@ -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>

View 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>
)
}

View 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>
)
}

View File

@ -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>

View File

@ -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}

View File

@ -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<{