Initial commit: Lightning Lottery - Bitcoin Lightning Network powered lottery
Features: - Lightning Network payments via LNbits integration - Provably fair draws using CSPRNG - Random ticket number generation - Automatic payouts with retry/redraw logic - Nostr authentication (NIP-07) - Multiple draw cycles (hourly, daily, weekly, monthly) - PostgreSQL and SQLite database support - Real-time countdown and payment animations - Swagger API documentation - Docker support Stack: - Backend: Node.js, TypeScript, Express - Frontend: Next.js, React, TailwindCSS, Redux - Payments: LNbits
This commit is contained in:
37
front_end/.gitignore
vendored
Normal file
37
front_end/.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
31
front_end/Dockerfile
Normal file
31
front_end/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Production image
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Copy built app
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
|
||||
106
front_end/README.md
Normal file
106
front_end/README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Lightning Lottery Frontend
|
||||
|
||||
Next.js-based frontend for the Lightning Lottery system.
|
||||
|
||||
## Features
|
||||
|
||||
- **Responsive Design**: Mobile-first, works on all devices
|
||||
- **Real-time Updates**: Live countdown and automatic status polling
|
||||
- **Lightning Payments**: QR code invoice display
|
||||
- **Nostr Authentication**: Optional user login via NIP-07
|
||||
- **User Dashboard**: View tickets, wins, and statistics
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js 14 (App Router)
|
||||
- **Language**: TypeScript
|
||||
- **Styling**: TailwindCSS
|
||||
- **State Management**: Redux Toolkit
|
||||
- **QR Codes**: qrcode.react
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Backend API running
|
||||
|
||||
### Installation
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Configure environment:
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local with your configuration
|
||||
```
|
||||
|
||||
3. Run development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3001](http://localhost:3001)
|
||||
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `NEXT_PUBLIC_API_BASE_URL` - Backend API URL
|
||||
- `NEXT_PUBLIC_APP_BASE_URL` - Frontend public URL
|
||||
|
||||
## Pages
|
||||
|
||||
- `/` - Home page with current jackpot
|
||||
- `/buy` - Buy lottery tickets
|
||||
- `/tickets/[id]` - Ticket status page (public)
|
||||
- `/dashboard` - User dashboard (Nostr auth required)
|
||||
- `/dashboard/tickets` - User's ticket history
|
||||
- `/dashboard/wins` - User's wins
|
||||
|
||||
## Components
|
||||
|
||||
### Reusable Components
|
||||
|
||||
- `TopBar` - Navigation header
|
||||
- `Footer` - Site footer
|
||||
- `JackpotCountdown` - Live countdown timer
|
||||
- `JackpotPotDisplay` - Pot amount display
|
||||
- `LightningInvoiceCard` - Invoice QR code and copy
|
||||
- `TicketList` - Display ticket serial numbers
|
||||
- `PayoutStatus` - Payout status indicator
|
||||
- `NostrLoginButton` - Nostr authentication
|
||||
|
||||
## Deployment
|
||||
|
||||
### Vercel
|
||||
|
||||
```bash
|
||||
vercel
|
||||
```
|
||||
|
||||
### Netlify
|
||||
|
||||
```bash
|
||||
netlify deploy --prod
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker build -t lightning-lotto-frontend .
|
||||
docker run -p 3001:3000 lightning-lotto-frontend
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
40
front_end/env.example
Normal file
40
front_end/env.example
Normal file
@@ -0,0 +1,40 @@
|
||||
# Lightning Lottery Frontend - Environment Configuration
|
||||
# Copy this file to .env.local and fill in your values
|
||||
|
||||
# ======================
|
||||
# Server Configuration
|
||||
# ======================
|
||||
# Port for Next.js development server (default: 3001)
|
||||
PORT=3001
|
||||
|
||||
# ======================
|
||||
# API Configuration
|
||||
# ======================
|
||||
# Backend API base URL
|
||||
# Development: http://localhost:3000
|
||||
# Production: https://api.yourdomain.com
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:3000
|
||||
|
||||
# Frontend application base URL
|
||||
# Development: http://localhost:3001
|
||||
# Production: https://yourdomain.com
|
||||
NEXT_PUBLIC_APP_BASE_URL=http://localhost:3001
|
||||
|
||||
# Backend API fallback port used when the frontend is accessed via a non-localhost host
|
||||
# Keep at 3000 if you run the API on the default dev port
|
||||
NEXT_PUBLIC_BACKEND_PORT=3000
|
||||
|
||||
# ======================
|
||||
# Optional: Admin Configuration
|
||||
# ======================
|
||||
# Only needed if you want to use admin features from the frontend
|
||||
# NEXT_PUBLIC_ADMIN_KEY=your_admin_api_key_here
|
||||
|
||||
# ======================
|
||||
# Notes
|
||||
# ======================
|
||||
# - Copy this file to .env.local for local development
|
||||
# - All variables that need to be accessible in the browser must start with NEXT_PUBLIC_
|
||||
# - Never expose sensitive keys in production frontend
|
||||
# - PORT is read by the npm scripts to set the dev server port
|
||||
# - Restart the dev server after changing .env.local
|
||||
12
front_end/next.config.js
Normal file
12
front_end/next.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
env: {
|
||||
NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL || '',
|
||||
NEXT_PUBLIC_APP_BASE_URL: process.env.NEXT_PUBLIC_APP_BASE_URL || '',
|
||||
NEXT_PUBLIC_BACKEND_PORT: process.env.NEXT_PUBLIC_BACKEND_PORT || '',
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
|
||||
6833
front_end/package-lock.json
generated
Normal file
6833
front_end/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
front_end/package.json
Normal file
32
front_end/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "lightning-lotto-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p ${PORT:-3001}",
|
||||
"build": "next build",
|
||||
"start": "next start -p ${PORT:-3001}",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"axios": "^1.6.2",
|
||||
"next": "14.0.4",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-redux": "^9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.10.5",
|
||||
"@types/react": "18.2.45",
|
||||
"@types/react-dom": "18.2.18",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "8.56.0",
|
||||
"eslint-config-next": "14.0.4",
|
||||
"postcss": "^8.4.32",
|
||||
"puppeteer": "^24.31.0",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
||||
7
front_end/postcss.config.js
Normal file
7
front_end/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
190
front_end/src/app/about/page.tsx
Normal file
190
front_end/src/app/about/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import STRINGS from '@/constants/strings';
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-8 text-center text-white">
|
||||
About {STRINGS.app.title}
|
||||
</h1>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Introduction */}
|
||||
<section className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-bitcoin-orange">
|
||||
What is Lightning Lottery?
|
||||
</h2>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
Lightning Lottery is a provably fair lottery system built on the Bitcoin Lightning Network.
|
||||
Players can purchase tickets using Lightning payments and winners receive instant payouts
|
||||
directly to their Lightning address. No accounts required, no waiting for withdrawals.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
|
||||
<h2 className="text-2xl font-semibold mb-6 text-bitcoin-orange">
|
||||
How It Works
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-bitcoin-orange rounded-full flex items-center justify-center text-white font-bold">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">Buy Tickets</h3>
|
||||
<p className="text-gray-400">
|
||||
Enter your Lightning address and pay the invoice. Each ticket gives you a unique
|
||||
randomly-generated number for the draw.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-bitcoin-orange rounded-full flex items-center justify-center text-white font-bold">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">Wait for the Draw</h3>
|
||||
<p className="text-gray-400">
|
||||
Draws happen on a regular schedule. Watch the countdown and see the pot grow
|
||||
as more players join.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-bitcoin-orange rounded-full flex items-center justify-center text-white font-bold">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">Win Instantly</h3>
|
||||
<p className="text-gray-400">
|
||||
When the draw happens, a winning ticket is selected using cryptographically secure
|
||||
random number generation. The winner receives the pot automatically via Lightning!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Fairness */}
|
||||
<section className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-bitcoin-orange">
|
||||
Provably Fair
|
||||
</h2>
|
||||
<div className="space-y-4 text-gray-300">
|
||||
<p>
|
||||
Our lottery uses cryptographically secure random number generation (CSPRNG) from
|
||||
Node.js's <code className="bg-gray-800 px-2 py-0.5 rounded text-bitcoin-orange">crypto.randomBytes()</code> module
|
||||
to ensure completely unpredictable results.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-400">
|
||||
<li>Ticket numbers are randomly generated (not sequential)</li>
|
||||
<li>Winner selection uses 8 bytes of cryptographic randomness</li>
|
||||
<li>No one can predict or influence the outcome</li>
|
||||
<li>All draws are logged and verifiable</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Technical Details */}
|
||||
<section className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-bitcoin-orange">
|
||||
Technical Details
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Payments</h3>
|
||||
<ul className="text-gray-400 space-y-1 text-sm">
|
||||
<li>• Lightning Network (LNbits)</li>
|
||||
<li>• Instant confirmations</li>
|
||||
<li>• No minimum withdrawal</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Security</h3>
|
||||
<ul className="text-gray-400 space-y-1 text-sm">
|
||||
<li>• CSPRNG for all randomness</li>
|
||||
<li>• Nostr authentication (optional)</li>
|
||||
<li>• Open source codebase</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Draws</h3>
|
||||
<ul className="text-gray-400 space-y-1 text-sm">
|
||||
<li>• Automated on schedule</li>
|
||||
<li>• Instant winner notification</li>
|
||||
<li>• Automatic payout retry</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Transparency</h3>
|
||||
<ul className="text-gray-400 space-y-1 text-sm">
|
||||
<li>• Public winner history</li>
|
||||
<li>• Verifiable ticket numbers</li>
|
||||
<li>• Clear fee structure</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<section className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
|
||||
<h2 className="text-2xl font-semibold mb-6 text-bitcoin-orange">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
Do I need an account?
|
||||
</h3>
|
||||
<p className="text-gray-400">
|
||||
No! You can buy tickets with just a Lightning address. Optionally, you can log in
|
||||
with Nostr to track your tickets and auto-fill your details.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
How do I receive my winnings?
|
||||
</h3>
|
||||
<p className="text-gray-400">
|
||||
Winnings are sent automatically to the Lightning address you provided when buying
|
||||
tickets. Make sure your address can receive payments!
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
What happens if payout fails?
|
||||
</h3>
|
||||
<p className="text-gray-400">
|
||||
We automatically retry failed payouts. If it continues to fail after multiple attempts,
|
||||
a new winner is drawn to ensure the pot is always paid out.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
What is the house fee?
|
||||
</h3>
|
||||
<p className="text-gray-400">
|
||||
A small percentage of the pot goes to operating costs. The exact fee is shown before
|
||||
each draw and the winner receives the pot after fees.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="text-center py-8">
|
||||
<Link
|
||||
href="/buy"
|
||||
className="inline-block bg-bitcoin-orange hover:bg-orange-600 text-white px-12 py-4 rounded-lg text-xl font-bold transition-colors shadow-lg"
|
||||
>
|
||||
Buy Tickets Now
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
367
front_end/src/app/buy/page.tsx
Normal file
367
front_end/src/app/buy/page.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { LightningInvoiceCard } from '@/components/LightningInvoiceCard';
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||
import STRINGS from '@/constants/strings';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import { setUser } from '@/store/userSlice';
|
||||
import { getAuthToken, hexToNpub, shortNpub } from '@/lib/nostr';
|
||||
|
||||
export default function BuyPage() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useAppSelector((state) => state.user);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [jackpot, setJackpot] = useState<any>(null);
|
||||
|
||||
// Form state
|
||||
const [lightningAddress, setLightningAddress] = useState('');
|
||||
const [lightningAddressTouched, setLightningAddressTouched] = useState(false);
|
||||
const [buyerName, setBuyerName] = useState('Anon');
|
||||
const [buyerNameTouched, setBuyerNameTouched] = useState(false);
|
||||
const [useNostrName, setUseNostrName] = useState(false);
|
||||
const [tickets, setTickets] = useState(1);
|
||||
|
||||
// Invoice state
|
||||
const [invoice, setInvoice] = useState<any>(null);
|
||||
const [paymentStatus, setPaymentStatus] = useState<'idle' | 'waiting' | 'paid' | 'expired'>('idle');
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const expiryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const animationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const redirectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const profilePrefetchAttempted = useRef(false);
|
||||
const [showPaidAnimation, setShowPaidAnimation] = useState(false);
|
||||
|
||||
const clearPolling = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
if (expiryTimeoutRef.current) {
|
||||
clearTimeout(expiryTimeoutRef.current);
|
||||
expiryTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearVisualTimers = useCallback(() => {
|
||||
if (animationTimeoutRef.current) {
|
||||
clearTimeout(animationTimeoutRef.current);
|
||||
animationTimeoutRef.current = null;
|
||||
}
|
||||
if (redirectTimeoutRef.current) {
|
||||
clearTimeout(redirectTimeoutRef.current);
|
||||
redirectTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadJackpot();
|
||||
return () => {
|
||||
clearPolling();
|
||||
clearVisualTimers();
|
||||
};
|
||||
}, [clearPolling, clearVisualTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user.authenticated || profilePrefetchAttempted.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
profilePrefetchAttempted.current = true;
|
||||
|
||||
api
|
||||
.getProfile()
|
||||
.then((response) => {
|
||||
if (response.data?.user) {
|
||||
const computedDisplayName =
|
||||
response.data.user.display_name ||
|
||||
(response.data.user.nostr_pubkey
|
||||
? shortNpub(hexToNpub(response.data.user.nostr_pubkey))
|
||||
: null);
|
||||
|
||||
dispatch(
|
||||
setUser({
|
||||
pubkey: response.data.user.nostr_pubkey,
|
||||
lightning_address: response.data.user.lightning_address,
|
||||
displayName: computedDisplayName,
|
||||
token,
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
profilePrefetchAttempted.current = false;
|
||||
});
|
||||
}, [dispatch, user.authenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user.lightning_address && !lightningAddressTouched) {
|
||||
setLightningAddress(user.lightning_address);
|
||||
}
|
||||
}, [user.lightning_address, lightningAddressTouched]);
|
||||
|
||||
useEffect(() => {
|
||||
if (useNostrName) {
|
||||
if (user.displayName) {
|
||||
setBuyerName(user.displayName);
|
||||
} else if (user.pubkey) {
|
||||
setBuyerName(shortNpub(hexToNpub(user.pubkey)));
|
||||
} else {
|
||||
setBuyerName('Anon');
|
||||
}
|
||||
} else if (!buyerNameTouched) {
|
||||
setBuyerName('Anon');
|
||||
}
|
||||
}, [useNostrName, user.displayName, user.pubkey, buyerNameTouched]);
|
||||
|
||||
const loadJackpot = async () => {
|
||||
try {
|
||||
const response = await api.getNextJackpot();
|
||||
if (response.data) {
|
||||
setJackpot(response.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load jackpot');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLightningAddressChange = (value: string) => {
|
||||
if (!lightningAddressTouched) {
|
||||
setLightningAddressTouched(true);
|
||||
}
|
||||
setLightningAddress(value);
|
||||
};
|
||||
|
||||
const handleBuyerNameChange = (value: string) => {
|
||||
if (!buyerNameTouched) {
|
||||
setBuyerNameTouched(true);
|
||||
}
|
||||
setUseNostrName(false);
|
||||
setBuyerName(value);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const finalName = buyerName.trim() || 'Anon';
|
||||
const response = await api.buyTickets(lightningAddress, tickets, finalName);
|
||||
|
||||
if (response.data) {
|
||||
setInvoice(response.data);
|
||||
setPaymentStatus('waiting');
|
||||
setShowPaidAnimation(false);
|
||||
clearVisualTimers();
|
||||
startPolling(response.data.ticket_purchase_id);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create purchase');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = (purchaseId?: string) => {
|
||||
if (!purchaseId) {
|
||||
console.error('Missing purchase ID for polling');
|
||||
setError('Missing purchase identifier. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
clearPolling();
|
||||
clearVisualTimers();
|
||||
setShowPaidAnimation(false);
|
||||
|
||||
pollIntervalRef.current = setInterval(async () => {
|
||||
try {
|
||||
const response = await api.getTicketStatus(purchaseId);
|
||||
|
||||
if (response.data.purchase.invoice_status === 'paid') {
|
||||
setPaymentStatus('paid');
|
||||
clearPolling();
|
||||
setShowPaidAnimation(true);
|
||||
|
||||
// Redirect after showing the paid animation
|
||||
redirectTimeoutRef.current = setTimeout(() => {
|
||||
router.push(`/tickets/${purchaseId}`);
|
||||
}, 2500);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Polling error:', err);
|
||||
}
|
||||
}, 5000); // Poll every 5 seconds
|
||||
|
||||
// Stop polling after 20 minutes if still unpaid
|
||||
expiryTimeoutRef.current = setTimeout(() => {
|
||||
setPaymentStatus((prev) => {
|
||||
if (prev === 'waiting') {
|
||||
clearPolling();
|
||||
return 'expired';
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, 20 * 60 * 1000);
|
||||
};
|
||||
|
||||
if (!jackpot) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
const ticketPriceSats = jackpot.lottery.ticket_price_sats;
|
||||
const totalCost = ticketPriceSats * tickets;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-center text-white">
|
||||
{STRINGS.buy.title}
|
||||
</h1>
|
||||
|
||||
{!invoice ? (
|
||||
/* Purchase Form */
|
||||
<div className="bg-gray-900 rounded-xl p-8 border border-gray-800">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Lightning Address */}
|
||||
<div>
|
||||
<label className="block text-gray-300 mb-2 font-medium">
|
||||
{STRINGS.buy.lightningAddress}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lightningAddress}
|
||||
onChange={(e) => handleLightningAddressChange(e.target.value)}
|
||||
placeholder={STRINGS.buy.lightningAddressPlaceholder}
|
||||
required
|
||||
className="w-full bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange"
|
||||
/>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Where to send your winnings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buyer Name */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-gray-300 font-medium">
|
||||
{STRINGS.buy.buyerName}
|
||||
</label>
|
||||
{user.authenticated && (
|
||||
<label className="flex items-center text-sm text-gray-400 space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useNostrName}
|
||||
onChange={(e) => setUseNostrName(e.target.checked)}
|
||||
className="accent-bitcoin-orange"
|
||||
/>
|
||||
<span>{STRINGS.buy.useNostrName}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={buyerName}
|
||||
onChange={(e) => handleBuyerNameChange(e.target.value)}
|
||||
placeholder={STRINGS.buy.buyerNamePlaceholder}
|
||||
disabled={useNostrName}
|
||||
maxLength={64}
|
||||
className={`w-full bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange ${
|
||||
useNostrName ? 'opacity-70 cursor-not-allowed' : ''
|
||||
}`}
|
||||
/>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{STRINGS.buy.buyerNameHelp}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Number of Tickets */}
|
||||
<div>
|
||||
<label className="block text-gray-300 mb-2 font-medium">
|
||||
{STRINGS.buy.numberOfTickets}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={tickets}
|
||||
onChange={(e) => setTickets(Math.max(1, Math.min(100, parseInt(e.target.value) || 1)))}
|
||||
min="1"
|
||||
max="100"
|
||||
required
|
||||
className="w-full bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pricing Info */}
|
||||
<div className="bg-gray-800 p-4 rounded-lg space-y-2">
|
||||
<div className="flex justify-between text-gray-300">
|
||||
<span>{STRINGS.buy.ticketPrice}</span>
|
||||
<span className="font-mono">{ticketPriceSats.toLocaleString()} sats</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-white font-bold text-lg">
|
||||
<span>{STRINGS.buy.totalCost}</span>
|
||||
<span className="font-mono">{totalCost.toLocaleString()} sats</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/50 text-red-200 px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-bitcoin-orange hover:bg-orange-600 disabled:bg-gray-600 text-white py-4 rounded-lg text-lg font-bold transition-colors"
|
||||
>
|
||||
{loading ? 'Creating Invoice...' : STRINGS.buy.createInvoice}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
/* Invoice Display */
|
||||
<div className="space-y-6">
|
||||
{paymentStatus === 'paid' ? (
|
||||
<div className="bg-green-900/50 text-green-200 px-6 py-4 rounded-lg text-center text-lg">
|
||||
✓ {STRINGS.buy.paymentReceived}
|
||||
</div>
|
||||
) : paymentStatus === 'expired' ? (
|
||||
<div className="bg-red-900/50 text-red-200 px-6 py-4 rounded-lg text-center text-lg">
|
||||
⚠️ {STRINGS.buy.invoiceExpired}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-blue-900/50 text-blue-200 px-6 py-4 rounded-lg text-center">
|
||||
{STRINGS.buy.waitingForPayment}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LightningInvoiceCard
|
||||
paymentRequest={invoice.invoice.payment_request}
|
||||
amountSats={invoice.invoice.amount_sats}
|
||||
showPaidAnimation={showPaidAnimation}
|
||||
/>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-gray-400 mb-2">{STRINGS.buy.paymentInstructions}</p>
|
||||
<a
|
||||
href={invoice.public_url}
|
||||
className="text-bitcoin-orange hover:underline"
|
||||
>
|
||||
View ticket status page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
159
front_end/src/app/dashboard/page.tsx
Normal file
159
front_end/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||
import api from '@/lib/api';
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||
import { hexToNpub, shortNpub, removeAuthToken } from '@/lib/nostr';
|
||||
import STRINGS from '@/constants/strings';
|
||||
import { logout } from '@/store/userSlice';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useAppSelector((state) => state.user);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profile, setProfile] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user.authenticated) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
loadProfile();
|
||||
}, [user.authenticated]);
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.getProfile();
|
||||
if (response.data) {
|
||||
setProfile(response.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load profile');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 text-xl mb-4">⚠️ {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-4 md:mb-0">
|
||||
{STRINGS.dashboard.title}
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
removeAuthToken();
|
||||
dispatch(logout());
|
||||
router.push('/');
|
||||
}}
|
||||
className="self-start md:self-auto bg-gray-800 hover:bg-gray-700 text-white px-4 py-2 rounded-lg border border-gray-700 transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Profile Card */}
|
||||
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-300">
|
||||
{STRINGS.dashboard.profile}
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-gray-400">Nostr Public Key:</span>
|
||||
<div className="text-white font-mono">
|
||||
{profile?.user?.nostr_pubkey ? shortNpub(hexToNpub(profile.user.nostr_pubkey)) : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
{profile?.user?.lightning_address && (
|
||||
<div>
|
||||
<span className="text-gray-400">Lightning Address:</span>
|
||||
<div className="text-white">{profile.user.lightning_address}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
||||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||
<div className="text-gray-400 text-sm mb-2">
|
||||
{STRINGS.dashboard.currentRoundTickets}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-bitcoin-orange">
|
||||
{(profile?.stats?.current_round_tickets || 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||
<div className="text-gray-400 text-sm mb-2">
|
||||
{STRINGS.dashboard.pastTickets}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-purple-400">
|
||||
{(profile?.stats?.past_tickets || 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||
<div className="text-gray-400 text-sm mb-2">
|
||||
{STRINGS.dashboard.totalWins}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-green-500">
|
||||
{(profile?.stats?.total_wins || 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||
<div className="text-gray-400 text-sm mb-2">
|
||||
{STRINGS.dashboard.totalWinnings}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-green-500">
|
||||
{(profile?.stats?.total_winnings_sats || 0).toLocaleString()}
|
||||
<span className="text-lg text-gray-400 ml-1">sats</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Link
|
||||
href="/dashboard/tickets"
|
||||
className="bg-gray-900 hover:bg-gray-800 rounded-xl p-6 border border-gray-800 transition-colors"
|
||||
>
|
||||
<div className="text-2xl mb-2">🎫</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">
|
||||
{STRINGS.dashboard.tickets}
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm">View your ticket purchase history</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/dashboard/wins"
|
||||
className="bg-gray-900 hover:bg-gray-800 rounded-xl p-6 border border-gray-800 transition-colors"
|
||||
>
|
||||
<div className="text-2xl mb-2">🏆</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">
|
||||
{STRINGS.dashboard.wins}
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm">Check your wins and payouts</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
119
front_end/src/app/dashboard/tickets/page.tsx
Normal file
119
front_end/src/app/dashboard/tickets/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import api from '@/lib/api';
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||
import { relativeTime } from '@/lib/format';
|
||||
import STRINGS from '@/constants/strings';
|
||||
|
||||
export default function DashboardTicketsPage() {
|
||||
const router = useRouter();
|
||||
const user = useAppSelector((state) => state.user);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tickets, setTickets] = useState<any[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user.authenticated) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
loadTickets();
|
||||
}, [user.authenticated]);
|
||||
|
||||
const loadTickets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.getUserTickets();
|
||||
if (response.data) {
|
||||
setTickets(response.data.purchases || []);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load tickets');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 text-xl mb-4">⚠️ {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex items-center text-sm text-gray-400 hover:text-white mb-4 transition-colors"
|
||||
>
|
||||
<span className="mr-2">←</span>
|
||||
{STRINGS.dashboard.backToDashboard}
|
||||
</Link>
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-white">
|
||||
{STRINGS.dashboard.tickets}
|
||||
</h1>
|
||||
|
||||
{tickets.length === 0 ? (
|
||||
<div className="bg-gray-900 rounded-xl p-12 border border-gray-800 text-center">
|
||||
<div className="text-4xl mb-4">🎫</div>
|
||||
<div className="text-xl text-gray-400 mb-4">{STRINGS.empty.noTickets}</div>
|
||||
<Link
|
||||
href="/buy"
|
||||
className="inline-block bg-bitcoin-orange hover:bg-orange-600 text-white px-6 py-3 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{STRINGS.empty.buyNow}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{tickets.map((ticket) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
href={`/tickets/${ticket.id}`}
|
||||
className="block bg-gray-900 hover:bg-gray-800 rounded-xl p-6 border border-gray-800 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-gray-400 text-sm mb-1">
|
||||
{relativeTime(ticket.created_at)}
|
||||
</div>
|
||||
<div className="text-white font-mono text-sm">
|
||||
{ticket.id.substring(0, 16)}...
|
||||
</div>
|
||||
</div>
|
||||
<div className={`
|
||||
px-3 py-1 rounded-full text-sm font-medium
|
||||
${ticket.invoice_status === 'paid' ? 'bg-green-900/30 text-green-300' : 'bg-yellow-900/30 text-yellow-300'}
|
||||
`}>
|
||||
{ticket.invoice_status}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-400">Tickets:</span>
|
||||
<span className="text-white ml-2">{ticket.number_of_tickets}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Amount:</span>
|
||||
<span className="text-white ml-2">{ticket.amount_sats.toLocaleString()} sats</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
111
front_end/src/app/dashboard/wins/page.tsx
Normal file
111
front_end/src/app/dashboard/wins/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import api from '@/lib/api';
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import STRINGS from '@/constants/strings';
|
||||
|
||||
export default function DashboardWinsPage() {
|
||||
const router = useRouter();
|
||||
const user = useAppSelector((state) => state.user);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [wins, setWins] = useState<any[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user.authenticated) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
loadWins();
|
||||
}, [user.authenticated]);
|
||||
|
||||
const loadWins = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.getUserWins();
|
||||
if (response.data) {
|
||||
setWins(response.data.wins || []);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load wins');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 text-xl mb-4">⚠️ {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex items-center text-sm text-gray-400 hover:text-white mb-4 transition-colors"
|
||||
>
|
||||
<span className="mr-2">←</span>
|
||||
{STRINGS.dashboard.backToDashboard}
|
||||
</Link>
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-white">
|
||||
{STRINGS.dashboard.wins}
|
||||
</h1>
|
||||
|
||||
{wins.length === 0 ? (
|
||||
<div className="bg-gray-900 rounded-xl p-12 border border-gray-800 text-center">
|
||||
<div className="text-4xl mb-4">🏆</div>
|
||||
<div className="text-xl text-gray-400">{STRINGS.empty.noWins}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{wins.map((win) => (
|
||||
<div
|
||||
key={win.id}
|
||||
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-500 mb-1">
|
||||
🎉 {win.amount_sats.toLocaleString()} sats
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm">
|
||||
{formatDateTime(win.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`
|
||||
px-3 py-1 rounded-full text-sm font-medium
|
||||
${
|
||||
win.status === 'paid'
|
||||
? 'bg-green-900/30 text-green-300'
|
||||
: win.status === 'pending'
|
||||
? 'bg-yellow-900/30 text-yellow-300'
|
||||
: 'bg-red-900/30 text-red-300'
|
||||
}
|
||||
`}>
|
||||
{win.status}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
Cycle ID: {win.cycle_id.substring(0, 16)}...
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
53
front_end/src/app/globals.css
Normal file
53
front_end/src/app/globals.css
Normal file
@@ -0,0 +1,53 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 218, 218, 218;
|
||||
--background-rgb: 11, 11, 11;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: rgb(var(--background-rgb));
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.5s ease-out forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0b0b0b;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #333;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
36
front_end/src/app/layout.tsx
Normal file
36
front_end/src/app/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
import { TopBar } from '@/components/TopBar';
|
||||
import { Footer } from '@/components/Footer';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Lightning Lottery - Win Bitcoin',
|
||||
description: 'Bitcoin Lightning Network powered lottery with instant payouts',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<Providers>
|
||||
<div className="min-h-screen flex flex-col bg-black text-gray-200">
|
||||
<TopBar />
|
||||
<main className="flex-grow container mx-auto px-4 py-8">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
293
front_end/src/app/page.tsx
Normal file
293
front_end/src/app/page.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import { JackpotCountdown } from '@/components/JackpotCountdown';
|
||||
import { JackpotPotDisplay } from '@/components/JackpotPotDisplay';
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||
import { DrawAnimation } from '@/components/DrawAnimation';
|
||||
import STRINGS from '@/constants/strings';
|
||||
|
||||
interface RecentWinner {
|
||||
id: string;
|
||||
winner_name: string;
|
||||
winner_lightning_address: string;
|
||||
winning_ticket_serial: number;
|
||||
pot_after_fee_sats: number;
|
||||
scheduled_at: string;
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [jackpot, setJackpot] = useState<any>(null);
|
||||
const [ticketId, setTicketId] = useState('');
|
||||
const [recentWinner, setRecentWinner] = useState<RecentWinner | null>(null);
|
||||
const [showDrawAnimation, setShowDrawAnimation] = useState(false);
|
||||
const [drawJustCompleted, setDrawJustCompleted] = useState(false);
|
||||
const [winnerBannerDismissed, setWinnerBannerDismissed] = useState(false);
|
||||
const [isRecentWin, setIsRecentWin] = useState(false);
|
||||
|
||||
const loadJackpot = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.getNextJackpot();
|
||||
if (response.data) {
|
||||
setJackpot(response.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load jackpot');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRecentWinner = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.getPastWins(1, 0);
|
||||
if (response.data?.wins?.length > 0) {
|
||||
const latestWin = response.data.wins[0];
|
||||
const winTime = new Date(latestWin.scheduled_at).getTime();
|
||||
const now = Date.now();
|
||||
const sixtySeconds = 60 * 1000;
|
||||
|
||||
setRecentWinner(latestWin);
|
||||
|
||||
// Check if this is a recent win (within 60 seconds)
|
||||
const isRecent = now - winTime < sixtySeconds;
|
||||
setIsRecentWin(isRecent);
|
||||
|
||||
// If draw completed within last 60 seconds, show animation
|
||||
if (isRecent && !drawJustCompleted) {
|
||||
setShowDrawAnimation(true);
|
||||
setDrawJustCompleted(true);
|
||||
setWinnerBannerDismissed(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load recent winner:', err);
|
||||
}
|
||||
}, [drawJustCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
loadJackpot();
|
||||
loadRecentWinner();
|
||||
}, [loadJackpot, loadRecentWinner]);
|
||||
|
||||
// Poll for draw completion when countdown reaches zero
|
||||
useEffect(() => {
|
||||
if (!jackpot?.cycle?.scheduled_at) return;
|
||||
|
||||
const checkForDraw = () => {
|
||||
const scheduledTime = new Date(jackpot.cycle.scheduled_at).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
// If we're past the scheduled time, start polling for the winner
|
||||
if (now >= scheduledTime && !drawJustCompleted) {
|
||||
loadRecentWinner();
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(checkForDraw, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [jackpot?.cycle?.scheduled_at, drawJustCompleted, loadRecentWinner]);
|
||||
|
||||
const handleCheckTicket = () => {
|
||||
if (ticketId.trim()) {
|
||||
router.push(`/tickets/${ticketId.trim()}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnimationComplete = () => {
|
||||
setShowDrawAnimation(false);
|
||||
};
|
||||
|
||||
const handlePlayAgain = () => {
|
||||
setDrawJustCompleted(false);
|
||||
setWinnerBannerDismissed(true);
|
||||
setIsRecentWin(false);
|
||||
loadJackpot();
|
||||
loadRecentWinner();
|
||||
};
|
||||
|
||||
const handleDismissWinnerBanner = () => {
|
||||
setWinnerBannerDismissed(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 text-xl mb-4">⚠️ {error}</div>
|
||||
<button
|
||||
onClick={loadJackpot}
|
||||
className="bg-bitcoin-orange hover:bg-orange-600 text-white px-6 py-2 rounded-lg"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!jackpot) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
No active jackpot available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Only show winner banner if: recent win (within 60s), not dismissed, and animation not showing
|
||||
const showWinnerBanner = isRecentWin && recentWinner && !showDrawAnimation && !winnerBannerDismissed;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Draw Animation Overlay */}
|
||||
{showDrawAnimation && recentWinner && (
|
||||
<DrawAnimation
|
||||
winnerName={recentWinner.winner_name}
|
||||
winningTicket={recentWinner.winning_ticket_serial}
|
||||
potAmount={recentWinner.pot_after_fee_sats}
|
||||
onComplete={handleAnimationComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-6xl font-bold mb-4 text-white">
|
||||
{STRINGS.app.title}
|
||||
</h1>
|
||||
<p className="text-xl text-gray-400">{STRINGS.app.tagline}</p>
|
||||
</div>
|
||||
|
||||
{/* Recent Winner Banner - Only shown for 60 seconds after draw */}
|
||||
{showWinnerBanner && (
|
||||
<div className="bg-gradient-to-r from-yellow-900/40 via-yellow-800/30 to-yellow-900/40 border border-yellow-600/50 rounded-2xl p-6 mb-8 animate-fade-in relative">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleDismissWinnerBanner}
|
||||
className="absolute top-3 right-3 text-yellow-400/60 hover:text-yellow-400 transition-colors p-1"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<div className="text-yellow-400 text-sm uppercase tracking-wider mb-2">
|
||||
🏆 Latest Winner
|
||||
</div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-white mb-2">
|
||||
{recentWinner.winner_name || 'Anon'}
|
||||
</div>
|
||||
<div className="text-yellow-400 text-xl font-mono mb-3">
|
||||
Won {recentWinner.pot_after_fee_sats.toLocaleString()} sats
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm">
|
||||
Ticket #{recentWinner.winning_ticket_serial.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Jackpot Card */}
|
||||
<div className="bg-gray-900 rounded-2xl p-8 md:p-12 mb-8 border border-gray-800">
|
||||
<h2 className="text-2xl font-semibold text-center mb-6 text-gray-300">
|
||||
{STRINGS.home.currentJackpot}
|
||||
</h2>
|
||||
|
||||
{/* Pot Display */}
|
||||
<div className="mb-8">
|
||||
<JackpotPotDisplay potTotalSats={jackpot.cycle.pot_total_sats} />
|
||||
</div>
|
||||
|
||||
{/* Countdown */}
|
||||
<div className="mb-8">
|
||||
<div className="text-center text-gray-400 mb-4">
|
||||
{STRINGS.home.drawIn}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<JackpotCountdown scheduledAt={jackpot.cycle.scheduled_at} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ticket Price */}
|
||||
<div className="text-center text-gray-400 mb-8">
|
||||
Ticket Price: {jackpot.lottery.ticket_price_sats.toLocaleString()} sats
|
||||
</div>
|
||||
|
||||
{/* Buy Button - Show Refresh only after draw */}
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-4">
|
||||
<Link
|
||||
href="/buy"
|
||||
className="bg-bitcoin-orange hover:bg-orange-600 text-white px-12 py-4 rounded-lg text-xl font-bold transition-colors shadow-lg text-center"
|
||||
>
|
||||
{STRINGS.home.buyTickets}
|
||||
</Link>
|
||||
{drawJustCompleted && (
|
||||
<button
|
||||
onClick={handlePlayAgain}
|
||||
className="bg-gray-700 hover:bg-gray-600 text-white px-8 py-4 rounded-lg text-lg font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>🔄</span> Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Check Ticket Section */}
|
||||
<div className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
|
||||
<h3 className="text-xl font-semibold text-center mb-4 text-gray-300">
|
||||
{STRINGS.home.checkTicket}
|
||||
</h3>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={ticketId}
|
||||
onChange={(e) => setTicketId(e.target.value)}
|
||||
placeholder={STRINGS.home.ticketIdPlaceholder}
|
||||
className="flex-1 bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCheckTicket()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCheckTicket}
|
||||
className="bg-gray-700 hover:bg-gray-600 text-white px-8 py-3 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Check Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800">
|
||||
<div className="text-4xl mb-3">⚡</div>
|
||||
<h4 className="text-lg font-semibold mb-2 text-white">Instant</h4>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Lightning-fast ticket purchases and payouts
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800">
|
||||
<div className="text-4xl mb-3">🔒</div>
|
||||
<h4 className="text-lg font-semibold mb-2 text-white">Secure</h4>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Cryptographically secure random number generation
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800">
|
||||
<div className="text-4xl mb-3">🎯</div>
|
||||
<h4 className="text-lg font-semibold mb-2 text-white">Fair</h4>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Transparent draws with verifiable results
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
front_end/src/app/past-wins/page.tsx
Normal file
117
front_end/src/app/past-wins/page.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '@/lib/api';
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||
import STRINGS from '@/constants/strings';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
|
||||
interface PastWin {
|
||||
cycle_id: string;
|
||||
cycle_type: string;
|
||||
scheduled_at: string;
|
||||
pot_total_sats: number;
|
||||
pot_after_fee_sats: number | null;
|
||||
winner_name: string;
|
||||
winning_ticket_serial: number | null;
|
||||
}
|
||||
|
||||
export default function PastWinsPage() {
|
||||
const [wins, setWins] = useState<PastWin[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadWins = async () => {
|
||||
try {
|
||||
const response = await api.getPastWins();
|
||||
setWins(response.data?.wins || []);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load past wins');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadWins();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto text-center py-12">
|
||||
<div className="text-red-500 text-xl mb-2">⚠️ {error}</div>
|
||||
<p className="text-gray-400">Please try again in a moment.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">
|
||||
{STRINGS.pastWins.title}
|
||||
</h1>
|
||||
<p className="text-gray-400">{STRINGS.pastWins.description}</p>
|
||||
</div>
|
||||
|
||||
{wins.length === 0 ? (
|
||||
<div className="bg-gray-900 rounded-xl p-12 text-center border border-gray-800">
|
||||
<div className="text-4xl mb-4">⏳</div>
|
||||
<div className="text-gray-300 text-lg">{STRINGS.pastWins.noWins}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{wins.map((win) => (
|
||||
<div
|
||||
key={win.cycle_id}
|
||||
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-4">
|
||||
<div>
|
||||
<div className="text-sm uppercase tracking-wide text-gray-500">
|
||||
{win.cycle_type} • {formatDateTime(win.scheduled_at)}
|
||||
</div>
|
||||
<div className="text-white font-mono text-sm">
|
||||
{win.cycle_id.substring(0, 16)}...
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 md:mt-0 text-right">
|
||||
<div className="text-gray-400 text-sm">{STRINGS.pastWins.pot}</div>
|
||||
<div className="text-2xl font-bold text-bitcoin-orange">
|
||||
{win.pot_total_sats.toLocaleString()} sats
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">{STRINGS.pastWins.winner}</div>
|
||||
<div className="text-white font-semibold">
|
||||
{win.winner_name || 'Anon'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">{STRINGS.pastWins.ticket}</div>
|
||||
<div className="text-white">
|
||||
{win.winning_ticket_serial !== null
|
||||
? `#${win.winning_ticket_serial}`
|
||||
: 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 mb-1">{STRINGS.pastWins.drawTime}</div>
|
||||
<div className="text-white">{formatDateTime(win.scheduled_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
9
front_end/src/app/providers.tsx
Normal file
9
front_end/src/app/providers.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from '@/store';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <Provider store={store}>{children}</Provider>;
|
||||
}
|
||||
|
||||
192
front_end/src/app/tickets/[id]/page.tsx
Normal file
192
front_end/src/app/tickets/[id]/page.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { JackpotCountdown } from '@/components/JackpotCountdown';
|
||||
import { TicketList } from '@/components/TicketList';
|
||||
import { PayoutStatus } from '@/components/PayoutStatus';
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import STRINGS from '@/constants/strings';
|
||||
|
||||
export default function TicketStatusPage() {
|
||||
const params = useParams();
|
||||
const ticketId = params.id as string;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadTicketStatus();
|
||||
|
||||
// Auto-refresh if payment pending or draw not complete
|
||||
const interval = setInterval(() => {
|
||||
if (autoRefresh) {
|
||||
loadTicketStatus(true);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [ticketId, autoRefresh]);
|
||||
|
||||
const loadTicketStatus = async (silent = false) => {
|
||||
try {
|
||||
if (!silent) setLoading(true);
|
||||
|
||||
const response = await api.getTicketStatus(ticketId);
|
||||
|
||||
if (response.data) {
|
||||
setData(response.data);
|
||||
|
||||
// Stop auto-refresh if payment is complete and draw is done
|
||||
if (
|
||||
response.data.purchase.invoice_status === 'paid' &&
|
||||
response.data.cycle.status === 'completed'
|
||||
) {
|
||||
setAutoRefresh(false);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load ticket status');
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 text-xl mb-4">⚠️ {error}</div>
|
||||
<button
|
||||
onClick={() => loadTicketStatus()}
|
||||
className="bg-bitcoin-orange hover:bg-orange-600 text-white px-6 py-2 rounded-lg"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="text-center py-12 text-gray-400">Ticket not found</div>;
|
||||
}
|
||||
|
||||
const { purchase, tickets, cycle, result } = data;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-center text-white">
|
||||
{STRINGS.ticket.title}
|
||||
</h1>
|
||||
|
||||
{/* Purchase Info */}
|
||||
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-400">Purchase ID:</span>
|
||||
<div className="text-white font-mono break-all">{purchase.id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Status:</span>
|
||||
<div className="text-white capitalize">{purchase.invoice_status}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Tickets:</span>
|
||||
<div className="text-white">{purchase.number_of_tickets}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">Amount:</span>
|
||||
<div className="text-white">{purchase.amount_sats.toLocaleString()} sats</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Status */}
|
||||
{purchase.invoice_status === 'pending' && (
|
||||
<div className="bg-yellow-900/30 text-yellow-200 px-6 py-4 rounded-lg mb-6 text-center">
|
||||
{STRINGS.ticket.waiting}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tickets */}
|
||||
{purchase.ticket_issue_status === 'issued' && (
|
||||
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-300">
|
||||
{STRINGS.ticket.ticketNumbers}
|
||||
</h2>
|
||||
<TicketList tickets={tickets} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Draw Info */}
|
||||
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-300">
|
||||
Draw Information
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="text-gray-400">Draw Time:</span>
|
||||
<div className="text-white">{formatDateTime(cycle.scheduled_at)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-gray-400">Current Pot:</span>
|
||||
<div className="text-2xl font-bold text-bitcoin-orange">
|
||||
{cycle.pot_total_sats.toLocaleString()} sats
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cycle.status !== 'completed' && (
|
||||
<div>
|
||||
<span className="text-gray-400 block mb-2">Time Until Draw:</span>
|
||||
<JackpotCountdown scheduledAt={cycle.scheduled_at} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{result.has_drawn && (
|
||||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-300">
|
||||
Draw Results
|
||||
</h2>
|
||||
|
||||
{result.is_winner ? (
|
||||
<div>
|
||||
<div className="bg-green-900/30 text-green-200 px-6 py-4 rounded-lg mb-4 text-center text-2xl font-bold">
|
||||
🎉 {STRINGS.ticket.congratulations}
|
||||
</div>
|
||||
{result.payout && (
|
||||
<PayoutStatus
|
||||
status={result.payout.status}
|
||||
amountSats={result.payout.amount_sats}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="bg-gray-800 px-6 py-4 rounded-lg mb-4 text-center">
|
||||
<div className="text-gray-400 mb-2">{STRINGS.ticket.betterLuck}</div>
|
||||
{cycle.winning_ticket_id && (
|
||||
<div className="text-gray-300">
|
||||
{STRINGS.ticket.winningTicket}: <span className="font-bold text-bitcoin-orange">#{cycle.winning_ticket_id.substring(0, 8)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
214
front_end/src/components/DrawAnimation.tsx
Normal file
214
front_end/src/components/DrawAnimation.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface DrawAnimationProps {
|
||||
winnerName: string;
|
||||
winningTicket: number;
|
||||
potAmount: number;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function DrawAnimation({
|
||||
winnerName,
|
||||
winningTicket,
|
||||
potAmount,
|
||||
onComplete,
|
||||
}: DrawAnimationProps) {
|
||||
const [phase, setPhase] = useState<'spinning' | 'revealing' | 'winner' | 'done'>('spinning');
|
||||
const [displayTicket, setDisplayTicket] = useState(0);
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
|
||||
// Generate random ticket numbers during spin
|
||||
useEffect(() => {
|
||||
if (phase !== 'spinning') return;
|
||||
|
||||
const spinInterval = setInterval(() => {
|
||||
setDisplayTicket(Math.floor(Math.random() * 999999999) + 1);
|
||||
}, 50);
|
||||
|
||||
// After 2.5 seconds, start revealing
|
||||
const revealTimeout = setTimeout(() => {
|
||||
setPhase('revealing');
|
||||
}, 2500);
|
||||
|
||||
return () => {
|
||||
clearInterval(spinInterval);
|
||||
clearTimeout(revealTimeout);
|
||||
};
|
||||
}, [phase]);
|
||||
|
||||
// Slow down and reveal actual number
|
||||
useEffect(() => {
|
||||
if (phase !== 'revealing') return;
|
||||
|
||||
let speed = 50;
|
||||
let iterations = 0;
|
||||
const maxIterations = 15;
|
||||
|
||||
const slowDown = () => {
|
||||
if (iterations >= maxIterations) {
|
||||
setDisplayTicket(winningTicket);
|
||||
setPhase('winner');
|
||||
return;
|
||||
}
|
||||
|
||||
speed += 30;
|
||||
iterations++;
|
||||
setDisplayTicket(Math.floor(Math.random() * 999999999) + 1);
|
||||
|
||||
setTimeout(slowDown, speed);
|
||||
};
|
||||
|
||||
slowDown();
|
||||
}, [phase, winningTicket]);
|
||||
|
||||
// Show winner and confetti
|
||||
useEffect(() => {
|
||||
if (phase !== 'winner') return;
|
||||
|
||||
setShowConfetti(true);
|
||||
|
||||
// Auto-dismiss after 6 seconds
|
||||
const dismissTimeout = setTimeout(() => {
|
||||
setPhase('done');
|
||||
onComplete();
|
||||
}, 6000);
|
||||
|
||||
return () => clearTimeout(dismissTimeout);
|
||||
}, [phase, onComplete]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setPhase('done');
|
||||
onComplete();
|
||||
}, [onComplete]);
|
||||
|
||||
if (phase === 'done') return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
|
||||
onClick={phase === 'winner' ? handleDismiss : undefined}
|
||||
>
|
||||
{/* Confetti Effect */}
|
||||
{showConfetti && (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{[...Array(50)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="confetti-piece"
|
||||
style={{
|
||||
left: `${Math.random() * 100}%`,
|
||||
animationDelay: `${Math.random() * 2}s`,
|
||||
backgroundColor: ['#f7931a', '#ffd700', '#ff6b6b', '#4ecdc4', '#45b7d1'][
|
||||
Math.floor(Math.random() * 5)
|
||||
],
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center px-6 max-w-lg">
|
||||
{/* Spinning Phase */}
|
||||
{(phase === 'spinning' || phase === 'revealing') && (
|
||||
<>
|
||||
<div className="text-2xl text-yellow-400 mb-4 animate-pulse">
|
||||
🎰 Drawing Winner...
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-2xl p-8 border-2 border-yellow-500/50 shadow-2xl shadow-yellow-500/20">
|
||||
<div className="text-gray-400 text-sm mb-2">Ticket Number</div>
|
||||
<div
|
||||
className={`text-5xl md:text-6xl font-mono font-bold text-bitcoin-orange ${
|
||||
phase === 'spinning' ? 'animate-number-spin' : ''
|
||||
}`}
|
||||
>
|
||||
#{displayTicket.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Winner Phase */}
|
||||
{phase === 'winner' && (
|
||||
<div className="animate-winner-reveal">
|
||||
<div className="text-4xl mb-4">🎉🏆🎉</div>
|
||||
<div className="text-3xl md:text-4xl font-bold text-yellow-400 mb-6">
|
||||
We Have a Winner!
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-yellow-900/60 to-orange-900/60 rounded-2xl p-8 border-2 border-yellow-500 shadow-2xl shadow-yellow-500/30">
|
||||
<div className="text-gray-300 text-sm mb-1">Winner</div>
|
||||
<div className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
{winnerName || 'Anon'}
|
||||
</div>
|
||||
<div className="text-gray-300 text-sm mb-1">Winning Ticket</div>
|
||||
<div className="text-2xl font-mono text-bitcoin-orange mb-4">
|
||||
#{winningTicket.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-gray-300 text-sm mb-1">Prize</div>
|
||||
<div className="text-4xl md:text-5xl font-bold text-green-400">
|
||||
{potAmount.toLocaleString()} sats
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 text-gray-400 text-sm animate-pulse">
|
||||
Click anywhere to continue
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.confetti-piece {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
top: -10px;
|
||||
animation: confetti-fall 4s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes confetti-fall {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(100vh) rotate(720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-number-spin {
|
||||
animation: number-glow 0.1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes number-glow {
|
||||
from {
|
||||
text-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
|
||||
}
|
||||
to {
|
||||
text-shadow: 0 0 20px rgba(247, 147, 26, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-winner-reveal {
|
||||
animation: winner-pop 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes winner-pop {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
29
front_end/src/components/Footer.tsx
Normal file
29
front_end/src/components/Footer.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="bg-gray-900 border-t border-gray-800 py-8 mt-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<div className="text-gray-400 text-sm mb-4 md:mb-0">
|
||||
© 2025 Lightning Lottery. Powered by Bitcoin Lightning Network.
|
||||
</div>
|
||||
<div className="flex space-x-6">
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<Link
|
||||
href="/past-wins"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Past Winners
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
58
front_end/src/components/JackpotCountdown.tsx
Normal file
58
front_end/src/components/JackpotCountdown.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { formatCountdown } from '@/lib/format';
|
||||
|
||||
interface JackpotCountdownProps {
|
||||
scheduledAt: string;
|
||||
}
|
||||
|
||||
export function JackpotCountdown({ scheduledAt }: JackpotCountdownProps) {
|
||||
const [countdown, setCountdown] = useState(formatCountdown(scheduledAt));
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCountdown(formatCountdown(scheduledAt));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [scheduledAt]);
|
||||
|
||||
if (countdown.total <= 0) {
|
||||
return <div className="text-2xl font-bold text-yellow-500">Drawing Now!</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex space-x-4" role="timer" aria-live="polite">
|
||||
{countdown.days > 0 && (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||
{countdown.days}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">days</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||
{countdown.hours.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">hours</div>
|
||||
</div>
|
||||
<div className="text-4xl md:text-5xl font-bold text-gray-500">:</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||
{countdown.minutes.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">minutes</div>
|
||||
</div>
|
||||
<div className="text-4xl md:text-5xl font-bold text-gray-500">:</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||
{countdown.seconds.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">seconds</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
20
front_end/src/components/JackpotPotDisplay.tsx
Normal file
20
front_end/src/components/JackpotPotDisplay.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { formatSats, satsToBTC } from '@/lib/format';
|
||||
|
||||
interface JackpotPotDisplayProps {
|
||||
potTotalSats: number;
|
||||
}
|
||||
|
||||
export function JackpotPotDisplay({ potTotalSats }: JackpotPotDisplayProps) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="text-5xl md:text-7xl font-bold text-bitcoin-orange mb-2">
|
||||
{formatSats(potTotalSats)}
|
||||
<span className="text-3xl md:text-4xl ml-2 text-gray-400">sats</span>
|
||||
</div>
|
||||
<div className="text-xl md:text-2xl text-gray-400">
|
||||
≈ {satsToBTC(potTotalSats)} BTC
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
154
front_end/src/components/LightningInvoiceCard.tsx
Normal file
154
front_end/src/components/LightningInvoiceCard.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
|
||||
interface LightningInvoiceCardProps {
|
||||
paymentRequest: string;
|
||||
amountSats: number;
|
||||
showPaidAnimation?: boolean;
|
||||
}
|
||||
|
||||
export function LightningInvoiceCard({
|
||||
paymentRequest,
|
||||
amountSats,
|
||||
showPaidAnimation = false,
|
||||
}: LightningInvoiceCardProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(paymentRequest);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-lg relative overflow-hidden">
|
||||
{/* QR Code Container */}
|
||||
<div className="flex justify-center mb-4 relative">
|
||||
<div
|
||||
className={`transition-all duration-700 ease-out ${
|
||||
showPaidAnimation ? 'scale-95 opacity-80' : 'scale-100 opacity-100'
|
||||
}`}
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={paymentRequest.toUpperCase()}
|
||||
size={260}
|
||||
level="M"
|
||||
includeMargin={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Paid Overlay - Smooth Green Circle with Checkmark */}
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center transition-all duration-500 ease-out ${
|
||||
showPaidAnimation
|
||||
? 'opacity-100 scale-100'
|
||||
: 'opacity-0 scale-50 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<div className="paid-badge">
|
||||
<svg
|
||||
className="checkmark-svg"
|
||||
viewBox="0 0 52 52"
|
||||
width="64"
|
||||
height="64"
|
||||
>
|
||||
<circle
|
||||
className="checkmark-circle"
|
||||
cx="26"
|
||||
cy="26"
|
||||
r="24"
|
||||
fill="none"
|
||||
stroke="#22c55e"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
className="checkmark-check"
|
||||
fill="none"
|
||||
stroke="#22c55e"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14 27l8 8 16-16"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Paid Status Text */}
|
||||
<div
|
||||
className={`text-center mb-4 transition-all duration-500 ${
|
||||
showPaidAnimation ? 'opacity-100' : 'opacity-0 h-0 overflow-hidden'
|
||||
}`}
|
||||
>
|
||||
<div className="text-green-600 font-bold text-lg">Payment Received!</div>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{amountSats.toLocaleString()} sats
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice */}
|
||||
<div className="mb-4">
|
||||
<div className="bg-gray-100 p-3 rounded text-xs break-all text-gray-700 max-h-24 overflow-y-auto">
|
||||
{paymentRequest}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={showPaidAnimation}
|
||||
className={`w-full py-3 rounded-lg font-medium transition-all duration-300 ${
|
||||
showPaidAnimation
|
||||
? 'bg-green-500 text-white cursor-default'
|
||||
: 'bg-bitcoin-orange hover:bg-orange-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{showPaidAnimation ? '✓ Paid' : copied ? '✓ Copied!' : '📋 Copy Invoice'}
|
||||
</button>
|
||||
|
||||
<style jsx>{`
|
||||
.paid-badge {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 50%;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 10px 40px rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
.checkmark-circle {
|
||||
stroke-dasharray: 166;
|
||||
stroke-dashoffset: 166;
|
||||
animation: circle-draw 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.checkmark-check {
|
||||
stroke-dasharray: 48;
|
||||
stroke-dashoffset: 48;
|
||||
animation: check-draw 0.4s ease-out 0.4s forwards;
|
||||
}
|
||||
|
||||
@keyframes circle-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes check-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
front_end/src/components/LoadingSpinner.tsx
Normal file
11
front_end/src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import STRINGS from '@/constants/strings';
|
||||
|
||||
export function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-bitcoin-orange"></div>
|
||||
<div className="text-gray-400 mt-4">{STRINGS.loading}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
72
front_end/src/components/NostrLoginButton.tsx
Normal file
72
front_end/src/components/NostrLoginButton.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAppDispatch } from '@/store/hooks';
|
||||
import { setUser } from '@/store/userSlice';
|
||||
import { getNostrPublicKey, signNostrMessage, storeAuthToken, shortNpub, hexToNpub } from '@/lib/nostr';
|
||||
import api from '@/lib/api';
|
||||
|
||||
export function NostrLoginButton() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleLogin = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get Nostr public key
|
||||
const pubkey = await getNostrPublicKey();
|
||||
|
||||
// Generate nonce
|
||||
const nonce = Math.random().toString(36).substring(7);
|
||||
|
||||
// Sign message
|
||||
const signature = await signNostrMessage(nonce);
|
||||
|
||||
// Authenticate with backend
|
||||
const response = await api.nostrAuth(pubkey, signature, nonce);
|
||||
|
||||
if (response.data && response.data.token) {
|
||||
// Store token
|
||||
storeAuthToken(response.data.token);
|
||||
|
||||
const displayName =
|
||||
response.data.user.display_name ||
|
||||
shortNpub(hexToNpub(response.data.user.nostr_pubkey));
|
||||
|
||||
// Update Redux state
|
||||
dispatch(
|
||||
setUser({
|
||||
pubkey: response.data.user.nostr_pubkey,
|
||||
lightning_address: response.data.user.lightning_address,
|
||||
token: response.data.token,
|
||||
displayName,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Nostr login error:', err);
|
||||
setError(err.message || 'Failed to login with Nostr');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={loading}
|
||||
className="bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
{loading ? 'Connecting...' : '🔐 Login with Nostr'}
|
||||
</button>
|
||||
{error && (
|
||||
<div className="text-red-500 text-sm mt-2">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
55
front_end/src/components/PayoutStatus.tsx
Normal file
55
front_end/src/components/PayoutStatus.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import STRINGS from '@/constants/strings';
|
||||
|
||||
interface PayoutStatusProps {
|
||||
status: 'pending' | 'paid' | 'failed' | null;
|
||||
amountSats?: number;
|
||||
}
|
||||
|
||||
export function PayoutStatus({ status, amountSats }: PayoutStatusProps) {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getStatusDisplay = () => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return {
|
||||
text: STRINGS.payout.pending,
|
||||
color: 'text-yellow-500',
|
||||
icon: '⏳',
|
||||
};
|
||||
case 'paid':
|
||||
return {
|
||||
text: STRINGS.payout.paid,
|
||||
color: 'text-green-500',
|
||||
icon: '✓',
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
text: STRINGS.payout.failed,
|
||||
color: 'text-red-500',
|
||||
icon: '⚠️',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const display = getStatusDisplay();
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800 p-6 rounded-lg">
|
||||
<div className="text-lg font-semibold text-gray-300 mb-2">
|
||||
{STRINGS.ticket.payoutStatus}
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${display.color} flex items-center space-x-2`}>
|
||||
<span>{display.icon}</span>
|
||||
<span>{display.text}</span>
|
||||
</div>
|
||||
{amountSats && (
|
||||
<div className="text-gray-400 mt-2">
|
||||
Amount: {amountSats.toLocaleString()} sats
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
40
front_end/src/components/TicketList.tsx
Normal file
40
front_end/src/components/TicketList.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
interface Ticket {
|
||||
id: string;
|
||||
serial_number: number;
|
||||
is_winning_ticket: boolean;
|
||||
}
|
||||
|
||||
interface TicketListProps {
|
||||
tickets: Ticket[];
|
||||
}
|
||||
|
||||
export function TicketList({ tickets }: TicketListProps) {
|
||||
if (tickets.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
No tickets issued yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-3">
|
||||
{tickets.map((ticket) => (
|
||||
<div
|
||||
key={ticket.id}
|
||||
className={`
|
||||
p-4 rounded-lg text-center font-bold text-lg
|
||||
${
|
||||
ticket.is_winning_ticket
|
||||
? 'bg-green-600 text-white ring-4 ring-green-400'
|
||||
: 'bg-gray-800 text-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
#{ticket.serial_number}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
62
front_end/src/components/TopBar.tsx
Normal file
62
front_end/src/components/TopBar.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import { NostrLoginButton } from './NostrLoginButton';
|
||||
import { shortNpub, hexToNpub } from '@/lib/nostr';
|
||||
import STRINGS from '@/constants/strings';
|
||||
|
||||
export function TopBar() {
|
||||
const user = useAppSelector((state) => state.user);
|
||||
|
||||
return (
|
||||
<nav className="bg-gray-900 border-b border-gray-800">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<span className="text-2xl">⚡</span>
|
||||
<span className="text-xl font-bold text-bitcoin-orange">
|
||||
{STRINGS.app.title}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center space-x-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
href="/buy"
|
||||
className="text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
Buy Tickets
|
||||
</Link>
|
||||
<Link
|
||||
href="/past-wins"
|
||||
className="text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
Past Winners
|
||||
</Link>
|
||||
|
||||
{user.authenticated ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-gray-300 hover:text-white transition-colors"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<NostrLoginButton />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
7
front_end/src/config/index.ts
Normal file
7
front_end/src/config/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const config = {
|
||||
apiBaseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
|
||||
appBaseUrl: process.env.NEXT_PUBLIC_APP_BASE_URL || 'http://localhost:3001',
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
96
front_end/src/constants/strings.ts
Normal file
96
front_end/src/constants/strings.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// All user-facing text strings
|
||||
export const STRINGS = {
|
||||
app: {
|
||||
title: 'Lightning Lottery',
|
||||
tagline: 'Win Bitcoin on the Lightning Network',
|
||||
},
|
||||
home: {
|
||||
currentJackpot: 'Current Jackpot',
|
||||
drawIn: 'Draw In',
|
||||
buyTickets: 'Buy Tickets',
|
||||
checkTicket: 'Check My Ticket',
|
||||
ticketIdPlaceholder: 'Enter ticket ID',
|
||||
},
|
||||
buy: {
|
||||
title: 'Buy Lottery Tickets',
|
||||
lightningAddress: 'Lightning Address',
|
||||
lightningAddressPlaceholder: 'you@getalby.com',
|
||||
buyerName: 'Display Name (optional)',
|
||||
buyerNamePlaceholder: 'Anon',
|
||||
buyerNameHelp: 'Shown on the public winners board. Leave blank to appear as Anon.',
|
||||
useNostrName: 'Use my Nostr name',
|
||||
numberOfTickets: 'Number of Tickets',
|
||||
ticketPrice: 'Ticket Price',
|
||||
totalCost: 'Total Cost',
|
||||
createInvoice: 'Create Invoice',
|
||||
paymentInstructions: 'Scan QR code or copy invoice to pay',
|
||||
copyInvoice: 'Copy Invoice',
|
||||
waitingForPayment: 'Waiting for payment...',
|
||||
paymentReceived: 'Payment Received!',
|
||||
invoiceExpired: 'Invoice Expired',
|
||||
},
|
||||
ticket: {
|
||||
title: 'Ticket Status',
|
||||
purchaseId: 'Purchase ID',
|
||||
status: 'Status',
|
||||
ticketNumbers: 'Your Ticket Numbers',
|
||||
drawTime: 'Draw Time',
|
||||
currentPot: 'Current Pot',
|
||||
waiting: 'Waiting for payment...',
|
||||
issued: 'Tickets Issued',
|
||||
drawPending: 'Draw pending...',
|
||||
congratulations: 'Congratulations! You Won!',
|
||||
winningTicket: 'Winning Ticket',
|
||||
betterLuck: 'Better luck next time!',
|
||||
payoutStatus: 'Payout Status',
|
||||
},
|
||||
payout: {
|
||||
pending: 'Payout pending...',
|
||||
paid: 'Paid! 🎉',
|
||||
failed: 'Payout failed - contact support',
|
||||
},
|
||||
dashboard: {
|
||||
title: 'My Dashboard',
|
||||
profile: 'Profile',
|
||||
tickets: 'My Tickets',
|
||||
wins: 'My Wins',
|
||||
stats: 'Statistics',
|
||||
totalTickets: 'Total Tickets',
|
||||
currentRoundTickets: 'Current Round Tickets',
|
||||
pastTickets: 'Past Tickets (Completed Rounds)',
|
||||
totalWins: 'Total Wins',
|
||||
totalWinnings: 'Total Winnings',
|
||||
backToDashboard: 'Back to Dashboard',
|
||||
},
|
||||
pastWins: {
|
||||
title: 'Past Winners',
|
||||
description: 'Recent jackpots and their champions.',
|
||||
noWins: 'No completed jackpots yet. Check back soon!',
|
||||
winner: 'Winner',
|
||||
ticket: 'Ticket #',
|
||||
pot: 'Pot',
|
||||
drawTime: 'Draw Time',
|
||||
},
|
||||
admin: {
|
||||
title: 'Admin Dashboard',
|
||||
cycles: 'Cycles',
|
||||
payouts: 'Payouts',
|
||||
runDraw: 'Run Draw',
|
||||
retryPayout: 'Retry Payout',
|
||||
},
|
||||
errors: {
|
||||
generic: 'Something went wrong. Please try again.',
|
||||
nostrNotFound: 'Nostr extension not found',
|
||||
invalidAddress: 'Invalid Lightning Address',
|
||||
networkError: 'Network error. Please check your connection.',
|
||||
},
|
||||
loading: 'Loading...',
|
||||
empty: {
|
||||
noTickets: 'You have no tickets yet',
|
||||
noWins: 'No wins yet',
|
||||
buyNow: 'Buy Tickets Now',
|
||||
},
|
||||
};
|
||||
|
||||
export default STRINGS;
|
||||
|
||||
224
front_end/src/lib/api.ts
Normal file
224
front_end/src/lib/api.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import config from '@/config';
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '[::1]']);
|
||||
const EXPLICIT_BACKEND_PORT = process.env.NEXT_PUBLIC_BACKEND_PORT;
|
||||
|
||||
const buildBaseUrl = (protocol: string, hostname: string, port?: string) => {
|
||||
if (port && port.length > 0) {
|
||||
return `${protocol}//${hostname}:${port}`;
|
||||
}
|
||||
return `${protocol}//${hostname}`;
|
||||
};
|
||||
|
||||
const inferPort = (configuredPort?: string, runtimePort?: string, runtimeProtocol?: string): string | undefined => {
|
||||
if (EXPLICIT_BACKEND_PORT && EXPLICIT_BACKEND_PORT.length > 0) {
|
||||
return EXPLICIT_BACKEND_PORT;
|
||||
}
|
||||
|
||||
if (configuredPort && configuredPort.length > 0) {
|
||||
return configuredPort;
|
||||
}
|
||||
|
||||
if (runtimePort && runtimePort.length > 0) {
|
||||
return runtimePort === '3001' ? '3000' : runtimePort;
|
||||
}
|
||||
|
||||
if (runtimeProtocol === 'https:') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const resolveBrowserBaseUrl = (staticBaseUrl: string) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return staticBaseUrl;
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location;
|
||||
|
||||
if (!staticBaseUrl || staticBaseUrl.length === 0) {
|
||||
const inferredPort = inferPort(undefined, port, protocol);
|
||||
return buildBaseUrl(protocol, hostname, inferredPort);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(staticBaseUrl);
|
||||
const shouldSwapHost = LOOPBACK_HOSTS.has(parsed.hostname) && !LOOPBACK_HOSTS.has(hostname);
|
||||
|
||||
if (shouldSwapHost) {
|
||||
const inferredPort = inferPort(parsed.port, port, parsed.protocol);
|
||||
return buildBaseUrl(parsed.protocol, hostname, inferredPort);
|
||||
}
|
||||
|
||||
return staticBaseUrl;
|
||||
} catch {
|
||||
return staticBaseUrl;
|
||||
}
|
||||
};
|
||||
|
||||
interface ApiOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
auth?: boolean;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
private cachedBrowserBaseUrl: string | null = null;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private getBaseUrl(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return this.baseUrl || 'http://localhost:3000';
|
||||
}
|
||||
|
||||
if (this.cachedBrowserBaseUrl) {
|
||||
return this.cachedBrowserBaseUrl;
|
||||
}
|
||||
|
||||
const resolved = resolveBrowserBaseUrl(this.baseUrl || 'http://localhost:3000');
|
||||
this.cachedBrowserBaseUrl = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private getAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('auth_token');
|
||||
}
|
||||
|
||||
async request<T = any>(path: string, options: ApiOptions = {}): Promise<T> {
|
||||
const { method = 'GET', headers = {}, body, auth = false } = options;
|
||||
|
||||
const url = `${this.getBaseUrl()}${path}`;
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
};
|
||||
|
||||
if (auth) {
|
||||
const token = this.getAuthToken();
|
||||
if (token) {
|
||||
requestHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const requestOptions: RequestInit = {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
};
|
||||
|
||||
if (body && method !== 'GET') {
|
||||
requestOptions.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, requestOptions);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'API request failed');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error('API request error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Public endpoints
|
||||
async getNextJackpot() {
|
||||
return this.request('/jackpot/next');
|
||||
}
|
||||
|
||||
async buyTickets(lightningAddress: string, tickets: number, buyerName?: string) {
|
||||
return this.request('/jackpot/buy', {
|
||||
method: 'POST',
|
||||
body: { lightning_address: lightningAddress, tickets, name: buyerName },
|
||||
auth: true,
|
||||
});
|
||||
}
|
||||
|
||||
async getTicketStatus(ticketPurchaseId: string) {
|
||||
return this.request(`/tickets/${ticketPurchaseId}`);
|
||||
}
|
||||
|
||||
async getPastWins(limit = 25, offset = 0) {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
return this.request(`/jackpot/past-wins?${params.toString()}`);
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
async nostrAuth(nostrPubkey: string, signedMessage: string, nonce: string) {
|
||||
return this.request('/auth/nostr', {
|
||||
method: 'POST',
|
||||
body: { nostr_pubkey: nostrPubkey, signed_message: signedMessage, nonce },
|
||||
});
|
||||
}
|
||||
|
||||
// User endpoints
|
||||
async getProfile() {
|
||||
return this.request('/me', { auth: true });
|
||||
}
|
||||
|
||||
async updateLightningAddress(lightningAddress: string) {
|
||||
return this.request('/me/lightning-address', {
|
||||
method: 'PATCH',
|
||||
body: { lightning_address: lightningAddress },
|
||||
auth: true,
|
||||
});
|
||||
}
|
||||
|
||||
async getUserTickets(limit = 50, offset = 0) {
|
||||
return this.request(`/me/tickets?limit=${limit}&offset=${offset}`, { auth: true });
|
||||
}
|
||||
|
||||
async getUserWins() {
|
||||
return this.request('/me/wins', { auth: true });
|
||||
}
|
||||
|
||||
// Admin endpoints
|
||||
async listCycles(status?: string, cycleType?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.append('status', status);
|
||||
if (cycleType) params.append('cycle_type', cycleType);
|
||||
|
||||
return this.request(`/admin/cycles?${params.toString()}`, {
|
||||
headers: { 'X-Admin-Key': process.env.NEXT_PUBLIC_ADMIN_KEY || '' },
|
||||
});
|
||||
}
|
||||
|
||||
async runDrawManually(cycleId: string) {
|
||||
return this.request(`/admin/cycles/${cycleId}/run-draw`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Admin-Key': process.env.NEXT_PUBLIC_ADMIN_KEY || '' },
|
||||
});
|
||||
}
|
||||
|
||||
async listPayouts(status?: string) {
|
||||
const params = status ? `?status=${status}` : '';
|
||||
return this.request(`/admin/payouts${params}`, {
|
||||
headers: { 'X-Admin-Key': process.env.NEXT_PUBLIC_ADMIN_KEY || '' },
|
||||
});
|
||||
}
|
||||
|
||||
async retryPayout(payoutId: string) {
|
||||
return this.request(`/admin/payouts/${payoutId}/retry`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Admin-Key': process.env.NEXT_PUBLIC_ADMIN_KEY || '' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient(config.apiBaseUrl);
|
||||
export default api;
|
||||
|
||||
79
front_end/src/lib/format.ts
Normal file
79
front_end/src/lib/format.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Convert sats to BTC
|
||||
*/
|
||||
export function satsToBTC(sats: number): string {
|
||||
return (sats / 100000000).toFixed(8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format sats with thousands separator
|
||||
*/
|
||||
export function formatSats(sats: number): string {
|
||||
return sats.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time
|
||||
*/
|
||||
export function relativeTime(date: Date | string): string {
|
||||
const now = new Date();
|
||||
const then = new Date(date);
|
||||
const diffMs = now.getTime() - then.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSeconds < 60) {
|
||||
return 'just now';
|
||||
} else if (diffMinutes < 60) {
|
||||
return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''} ago`;
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return then.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format countdown time
|
||||
*/
|
||||
export function formatCountdown(targetDate: Date | string): {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
total: number;
|
||||
} {
|
||||
const now = new Date();
|
||||
const target = new Date(targetDate);
|
||||
const diffMs = target.getTime() - now.getTime();
|
||||
|
||||
if (diffMs <= 0) {
|
||||
return { days: 0, hours: 0, minutes: 0, seconds: 0, total: 0 };
|
||||
}
|
||||
|
||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
|
||||
|
||||
return { days, hours, minutes, seconds, total: diffMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date and time
|
||||
*/
|
||||
export function formatDateTime(date: Date | string): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
100
front_end/src/lib/nostr.ts
Normal file
100
front_end/src/lib/nostr.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// Nostr utilities for NIP-07 authentication
|
||||
|
||||
interface NostrWindow extends Window {
|
||||
nostr?: {
|
||||
getPublicKey: () => Promise<string>;
|
||||
signEvent: (event: any) => Promise<any>;
|
||||
};
|
||||
}
|
||||
|
||||
declare let window: NostrWindow;
|
||||
|
||||
/**
|
||||
* Check if Nostr extension is available (NIP-07)
|
||||
*/
|
||||
export function isNostrAvailable(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return !!window.nostr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's Nostr public key
|
||||
*/
|
||||
export async function getNostrPublicKey(): Promise<string> {
|
||||
if (!isNostrAvailable()) {
|
||||
throw new Error('Nostr extension not found. Please install a Nostr browser extension.');
|
||||
}
|
||||
|
||||
try {
|
||||
const pubkey = await window.nostr!.getPublicKey();
|
||||
return pubkey;
|
||||
} catch (error: any) {
|
||||
throw new Error('Failed to get Nostr public key: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a message with Nostr
|
||||
*/
|
||||
export async function signNostrMessage(message: string): Promise<string> {
|
||||
if (!isNostrAvailable()) {
|
||||
throw new Error('Nostr extension not found');
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a simple event to sign
|
||||
const event = {
|
||||
kind: 22242, // Custom kind for auth
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: message,
|
||||
};
|
||||
|
||||
const signedEvent = await window.nostr!.signEvent(event);
|
||||
return signedEvent.sig;
|
||||
} catch (error: any) {
|
||||
throw new Error('Failed to sign message: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store auth token
|
||||
*/
|
||||
export function storeAuthToken(token: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth token
|
||||
*/
|
||||
export function getAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('auth_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove auth token
|
||||
*/
|
||||
export function removeAuthToken(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex pubkey to npub format (simplified)
|
||||
*/
|
||||
export function hexToNpub(hex: string): string {
|
||||
// This is a simplified version
|
||||
// In production, use a proper bech32 encoding library
|
||||
return `npub1${hex.substring(0, 58)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten npub for display
|
||||
*/
|
||||
export function shortNpub(npub: string): string {
|
||||
if (npub.length < 16) return npub;
|
||||
return `${npub.substring(0, 8)}...${npub.substring(npub.length - 4)}`;
|
||||
}
|
||||
|
||||
6
front_end/src/store/hooks.ts
Normal file
6
front_end/src/store/hooks.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from './index';
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
|
||||
16
front_end/src/store/index.ts
Normal file
16
front_end/src/store/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import userReducer from './userSlice';
|
||||
import jackpotReducer from './jackpotSlice';
|
||||
import purchaseReducer from './purchaseSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
user: userReducer,
|
||||
jackpot: jackpotReducer,
|
||||
purchase: purchaseReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
45
front_end/src/store/jackpotSlice.ts
Normal file
45
front_end/src/store/jackpotSlice.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
interface Cycle {
|
||||
id: string;
|
||||
cycle_type: string;
|
||||
scheduled_at: string;
|
||||
pot_total_sats: number;
|
||||
ticket_price_sats: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface JackpotState {
|
||||
cycle: Cycle | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: JackpotState = {
|
||||
cycle: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const jackpotSlice = createSlice({
|
||||
name: 'jackpot',
|
||||
initialState,
|
||||
reducers: {
|
||||
setCycle: (state, action: PayloadAction<Cycle>) => {
|
||||
state.cycle = action.payload;
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
},
|
||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.loading = action.payload;
|
||||
},
|
||||
setError: (state, action: PayloadAction<string>) => {
|
||||
state.error = action.payload;
|
||||
state.loading = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setCycle, setLoading, setError } = jackpotSlice.actions;
|
||||
export default jackpotSlice.reducer;
|
||||
|
||||
45
front_end/src/store/purchaseSlice.ts
Normal file
45
front_end/src/store/purchaseSlice.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
interface Ticket {
|
||||
id: string;
|
||||
serial_number: number;
|
||||
is_winning_ticket: boolean;
|
||||
}
|
||||
|
||||
interface PurchaseState {
|
||||
ticket_purchase_id: string | null;
|
||||
invoice_status: string | null;
|
||||
tickets: Ticket[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: PurchaseState = {
|
||||
ticket_purchase_id: null,
|
||||
invoice_status: null,
|
||||
tickets: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const purchaseSlice = createSlice({
|
||||
name: 'purchase',
|
||||
initialState,
|
||||
reducers: {
|
||||
setPurchase: (state, action: PayloadAction<Partial<PurchaseState>>) => {
|
||||
return { ...state, ...action.payload, loading: false, error: null };
|
||||
},
|
||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.loading = action.payload;
|
||||
},
|
||||
setError: (state, action: PayloadAction<string>) => {
|
||||
state.error = action.payload;
|
||||
state.loading = false;
|
||||
},
|
||||
clearPurchase: () => initialState,
|
||||
},
|
||||
});
|
||||
|
||||
export const { setPurchase, setLoading, setError, clearPurchase } = purchaseSlice.actions;
|
||||
export default purchaseSlice.reducer;
|
||||
|
||||
32
front_end/src/store/userSlice.ts
Normal file
32
front_end/src/store/userSlice.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
interface UserState {
|
||||
authenticated: boolean;
|
||||
pubkey: string | null;
|
||||
lightning_address: string | null;
|
||||
token: string | null;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
const initialState: UserState = {
|
||||
authenticated: false,
|
||||
pubkey: null,
|
||||
lightning_address: null,
|
||||
token: null,
|
||||
displayName: null,
|
||||
};
|
||||
|
||||
const userSlice = createSlice({
|
||||
name: 'user',
|
||||
initialState,
|
||||
reducers: {
|
||||
setUser: (state, action: PayloadAction<Partial<UserState>>) => {
|
||||
return { ...state, ...action.payload, authenticated: true };
|
||||
},
|
||||
logout: () => initialState,
|
||||
},
|
||||
});
|
||||
|
||||
export const { setUser, logout } = userSlice.actions;
|
||||
export default userSlice.reducer;
|
||||
|
||||
22
front_end/tailwind.config.js
Normal file
22
front_end/tailwind.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bitcoin: {
|
||||
orange: '#f7931a',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
28
front_end/tsconfig.json
Normal file
28
front_end/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user