Initial release
Build and Push Docker Images / build (push) Successful in 17s

This commit is contained in:
Stardream
2026-06-02 10:58:24 +10:00
commit eace48732d
88 changed files with 11140 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --frozen-lockfile 2>/dev/null || npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+4
View File
@@ -0,0 +1,4 @@
FROM node:20-alpine
WORKDIR /app
# npm install runs at startup; then vite build --watch rebuilds on source changes
CMD ["sh", "-c", "npm install && npm run build -- --watch"]
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#ffffff" />
<title>TG Lottery Bot</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<script>
try {
if (window.Telegram && window.Telegram.WebApp) {
window.Telegram.WebApp.ready()
window.Telegram.WebApp.expand()
}
} catch (error) {}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
try_files /index.html =404;
}
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
try_files $uri $uri/ /index.html;
}
}
+4092
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "tg-lottery-bot",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@tanstack/react-query": "^5.56.2",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"canvas-confetti": "^1.9.3",
"framer-motion": "^11.11.1",
"lucide-react": "^0.446.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@types/canvas-confetti": "^1.6.4",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.5.3",
"vite": "^5.4.8"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+41
View File
@@ -0,0 +1,41 @@
import { Component, type ReactNode } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import Home from './pages/Home'
import CreateLottery from './pages/CreateLottery'
import LotteryDetail from './pages/LotteryDetail'
import EditLottery from './pages/EditLottery'
class ErrorBoundary extends Component<{ children: ReactNode }, { error: string | null }> {
state = { error: null }
static getDerivedStateFromError(e: Error) { return { error: e.message } }
render() {
if (this.state.error) {
return (
<div style={{ padding: 20, fontFamily: 'monospace', color: '#c00' }}>
<b>Render Error</b>
<pre style={{ whiteSpace: 'pre-wrap', fontSize: 12 }}>{this.state.error}</pre>
<p style={{ fontSize: 11, color: '#666' }}>
initData: {window.Telegram?.WebApp?.initData?.slice(0, 60) || '(empty)'}
</p>
</div>
)
}
return this.props.children
}
}
export default function App() {
return (
<ErrorBoundary>
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/create" element={<CreateLottery />} />
<Route path="/lottery/:id" element={<LotteryDetail />} />
<Route path="/lottery/:id/edit" element={<EditLottery />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</ErrorBoundary>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { useNavigate } from 'react-router-dom'
import { Users, Gift, ChevronRight, Shuffle, Clock } from 'lucide-react'
import { motion } from 'framer-motion'
import { Badge } from './ui/Badge'
import { Card } from './ui/Card'
import type { Lottery } from '@/types'
import { STATUS_LABELS, formatDateShort } from '@/lib/utils'
function drawMethodLabel(lottery: Lottery): { icon: React.ReactNode; label: string } | null {
if (lottery.status === 'cancelled') return null
if (lottery.status === 'finished') {
const t = lottery.draw_trigger
if (t === 'scheduled') return { icon: <Clock className="h-3.5 w-3.5" />, label: 'Scheduled' }
if (t === 'auto_count') return { icon: <Users className="h-3.5 w-3.5" />, label: 'Auto draw' }
return { icon: <Shuffle className="h-3.5 w-3.5" />, label: 'Manual' }
}
if (lottery.draw_at) return { icon: <Clock className="h-3.5 w-3.5" />, label: 'Scheduled' }
if (lottery.auto_draw_count) return { icon: <Users className="h-3.5 w-3.5" />, label: 'Auto draw' }
return { icon: <Shuffle className="h-3.5 w-3.5" />, label: 'Manual' }
}
interface LotteryCardProps {
lottery: Lottery
currentUserId: number
showOwnerBadge?: boolean
fromTab?: string
}
export function LotteryCard({ lottery, currentUserId, showOwnerBadge = false, fromTab }: LotteryCardProps) {
const navigate = useNavigate()
const isOwner = lottery.creator_id === currentUserId
const drawMethod = drawMethodLabel(lottery)
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<Card
className="cursor-pointer active:scale-[0.98] transition-transform"
onClick={() => navigate(`/lottery/${lottery.id}`, { state: { fromTab } })}
>
<div className="p-4 flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 mb-1">
<Badge variant={lottery.status as any}>
{STATUS_LABELS[lottery.status] ?? lottery.status}
</Badge>
{lottery.status === 'finished' && lottery.is_winner === true && (
<Badge variant="default" className="bg-emerald-500/10 text-emerald-600">
🏆 Won
</Badge>
)}
{lottery.status === 'finished' && lottery.is_winner === false && (
<Badge variant="default" className="bg-tg-secondary-bg text-tg-hint">
😢 Missed
</Badge>
)}
{showOwnerBadge && isOwner && (
<Badge variant="default" className="bg-tg-btn/10 text-tg-btn">
Mine
</Badge>
)}
{lottery.require_membership && (
<Badge variant="default" className="bg-tg-btn/10 text-tg-btn">
🔒 Members only
</Badge>
)}
</div>
<h3 className="font-semibold text-tg-text truncate">{lottery.title}</h3>
{lottery.description && (
<p className="text-sm text-tg-hint truncate mt-0.5">{lottery.description}</p>
)}
<div className="flex items-center gap-2 mt-2 text-xs text-tg-hint">
<span className="flex items-center gap-1">
<Gift className="h-3.5 w-3.5" />
{lottery.total_prizes}
</span>
<span className="flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{lottery.participant_count}
</span>
{drawMethod && (
<span className="flex items-center gap-1">
{drawMethod.icon}
{drawMethod.label}
</span>
)}
<span className="ml-auto shrink-0">{formatDateShort(lottery.created_at)}</span>
</div>
</div>
<ChevronRight className="h-5 w-5 text-tg-hint shrink-0 mt-1" />
</div>
</Card>
</motion.div>
)
}
+137
View File
@@ -0,0 +1,137 @@
import { useState } from 'react'
import { Plus, Trash2, Pencil } from 'lucide-react'
import { Button } from './ui/Button'
import { Input } from './ui/Input'
import { Dialog, DialogContent, DialogTrigger } from './ui/Dialog'
import type { PrizeCreate } from '@/types'
interface LocalPrize extends PrizeCreate {
_id: string
}
interface PrizeEditorProps {
prizes: LocalPrize[]
onChange: (prizes: LocalPrize[]) => void
disabled?: boolean
}
let _counter = 0
export function PrizeEditor({ prizes, onChange, disabled }: PrizeEditorProps) {
const [open, setOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [name, setName] = useState('')
const [qty, setQty] = useState('1')
const [nameErr, setNameErr] = useState('')
function openAdd() {
setEditingId(null)
setName('')
setQty('1')
setNameErr('')
setOpen(true)
}
function openEdit(prize: LocalPrize) {
setEditingId(prize._id)
setName(prize.name)
setQty(String(prize.quantity))
setNameErr('')
setOpen(true)
}
function handleSave() {
if (!name.trim()) {
setNameErr('Enter a prize name')
return
}
const quantity = Math.max(1, parseInt(qty, 10) || 1)
if (editingId) {
onChange(prizes.map((p) => p._id === editingId ? { ...p, name: name.trim(), quantity } : p))
} else {
onChange([...prizes, { _id: String(++_counter), name: name.trim(), quantity }])
}
setOpen(false)
}
function handleRemove(id: string) {
onChange(prizes.filter((p) => p._id !== id))
}
return (
<div className="flex flex-col gap-2">
{prizes.length === 0 ? (
<p className="text-sm text-tg-hint text-center py-3">
No prizes yet. Add one below.
</p>
) : (
<div className="rounded-xl overflow-hidden bg-tg-bg divide-y divide-tg-secondary-bg">
{prizes.map((prize) => (
<div key={prize._id} className="flex items-center px-4 py-3 gap-3">
<div className="flex-1 min-w-0">
<p className="font-medium text-tg-text truncate">{prize.name}</p>
<p className="text-xs text-tg-hint">x{prize.quantity}</p>
</div>
{!disabled && (
<div className="flex items-center gap-1">
<button
onClick={() => openEdit(prize)}
className="text-tg-hint p-1.5 rounded-lg hover:bg-tg-secondary-bg transition-colors"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleRemove(prize._id)}
className="text-red-400 p-1.5 rounded-lg hover:bg-red-50 transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)}
</div>
))}
</div>
)}
{!disabled && (
<>
<Button variant="secondary" size="md" className="w-full" onClick={openAdd}>
<Plus className="h-4 w-4" />
Add prize
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent title={editingId ? 'Edit prize' : 'Add prize'}>
<div className="flex flex-col gap-4">
<Input
label="Prize name *"
placeholder="Example: iPhone 16 Pro"
value={name}
onChange={(e) => {
setName(e.target.value)
setNameErr('')
}}
error={nameErr}
autoFocus
/>
<Input
label="Quantity"
type="number"
min={1}
max={1000}
value={qty}
onChange={(e) => setQty(e.target.value)}
/>
<Button onClick={handleSave} size="lg">
{editingId ? 'Save' : 'Add'}
</Button>
</div>
</DialogContent>
</Dialog>
</>
)}
</div>
)
}
export type { LocalPrize }
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react'
import { Trophy } from 'lucide-react'
import { motion } from 'framer-motion'
import type { Winner } from '@/types'
import { displayName } from '@/lib/utils'
function UserAvatar({ userId, name }: { userId: number; name: string }) {
const [failed, setFailed] = useState(false)
if (!failed) {
return (
<img
src={`/api/users/${userId}/avatar`}
alt={name}
className="h-9 w-9 rounded-full object-cover bg-tg-btn/10 shrink-0"
onError={() => setFailed(true)}
/>
)
}
return (
<div className="h-9 w-9 rounded-full bg-tg-btn/10 flex items-center justify-center text-sm font-bold text-tg-btn shrink-0">
{name[0]}
</div>
)
}
interface WinnerListProps {
winners: Winner[]
onUserClick?: (user: { user_id: number; username?: string | null }) => void
}
export function WinnerList({ winners, onUserClick }: WinnerListProps) {
if (winners.length === 0) {
return (
<div className="text-center py-8 text-tg-hint">
<Trophy className="h-10 w-10 mx-auto mb-2 opacity-30" />
<p>No winners yet</p>
</div>
)
}
return (
<div className="rounded-xl overflow-hidden bg-tg-bg divide-y divide-tg-secondary-bg">
{winners.map((winner, i) => (
<motion.div
key={`${winner.user_id}-${winner.prize_name}`}
initial={{ opacity: 0, x: -16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.06 }}
className={`flex items-center px-4 py-3 gap-3 ${onUserClick ? 'cursor-pointer active:bg-tg-secondary-bg transition-colors' : ''}`}
onClick={() => onUserClick?.(winner)}
>
<UserAvatar userId={winner.user_id} name={winner.first_name} />
<div className="flex-1 min-w-0">
<p className="font-semibold text-tg-text truncate">
{displayName(winner)}
</p>
<p className="text-sm text-tg-hint truncate">{winner.prize_name}</p>
</div>
</motion.div>
))}
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { type HTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: 'default' | 'draft' | 'active' | 'drawing' | 'finished' | 'cancelled'
}
const variantClasses: Record<string, string> = {
default: 'bg-gray-100 text-gray-600',
draft: 'bg-gray-100 text-gray-500',
active: 'bg-emerald-100 text-emerald-700',
drawing: 'bg-blue-100 text-blue-700',
finished: 'bg-purple-100 text-purple-700',
cancelled: 'bg-red-100 text-red-600',
}
export function Badge({ variant = 'default', className, ...props }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
variantClasses[variant],
className
)}
{...props}
/>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { type ButtonHTMLAttributes, forwardRef } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-xl font-semibold transition-all active:scale-95 disabled:opacity-50 disabled:pointer-events-none select-none',
{
variants: {
variant: {
primary: 'bg-tg-btn text-tg-btn-text',
secondary: 'bg-tg-secondary-bg text-tg-text',
destructive: 'bg-red-500 text-white',
ghost: 'text-tg-link hover:bg-tg-secondary-bg',
outline: 'border border-tg-hint text-tg-text bg-transparent',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-11 px-5 text-base',
lg: 'h-12 px-6 text-base w-full',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
)
interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loading, children, disabled, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || loading}
{...props}
>
{loading ? (
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
) : null}
{children}
</button>
)
)
Button.displayName = 'Button'
export { Button, buttonVariants }
+32
View File
@@ -0,0 +1,32 @@
import { type HTMLAttributes, forwardRef } from 'react'
import { cn } from '@/lib/utils'
const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('bg-tg-bg rounded-2xl shadow-sm overflow-hidden', className)} {...props} />
)
)
Card.displayName = 'Card'
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('px-4 pt-4 pb-2', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('px-4 pb-4', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('px-4 py-3 border-t border-tg-secondary-bg flex gap-2', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardContent, CardFooter }
+45
View File
@@ -0,0 +1,45 @@
import type { ReactNode } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
export const Dialog = DialogPrimitive.Root
export const DialogTrigger = DialogPrimitive.Trigger
export const DialogClose = DialogPrimitive.Close
export function DialogContent({
children,
className,
title,
}: {
children: ReactNode
className?: string
title?: string
}) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
className={cn(
'fixed bottom-0 left-0 right-0 z-50 bg-tg-bg rounded-t-3xl p-6',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
'max-h-[90dvh] overflow-y-auto',
className
)}
>
<div className="flex items-center justify-between mb-4">
{title && (
<DialogPrimitive.Title className="text-lg font-bold text-tg-text">
{title}
</DialogPrimitive.Title>
)}
<DialogPrimitive.Close className="ml-auto p-1 rounded-full hover:bg-tg-secondary-bg transition-colors">
<X className="h-5 w-5 text-tg-hint" />
</DialogPrimitive.Close>
</div>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
}
+59
View File
@@ -0,0 +1,59 @@
import { type InputHTMLAttributes, forwardRef } from 'react'
import { cn } from '@/lib/utils'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
hint?: string
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, hint, ...props }, ref) => (
<div className="flex flex-col gap-1 min-w-0">
{label && (
<label className="text-sm font-medium text-tg-hint">{label}</label>
)}
<input
ref={ref}
className={cn(
'h-11 w-full rounded-xl bg-tg-secondary-bg px-3 text-tg-text placeholder:text-tg-hint',
'outline-none focus:ring-2 focus:ring-tg-btn/40 transition-shadow',
error && 'ring-2 ring-red-400',
className
)}
{...props}
/>
{error && <p className="text-xs text-red-500">{error}</p>}
{hint && !error && <p className="text-xs text-tg-hint">{hint}</p>}
</div>
)
)
Input.displayName = 'Input'
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
}
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, label, error, ...props }, ref) => (
<div className="flex flex-col gap-1">
{label && <label className="text-sm font-medium text-tg-hint">{label}</label>}
<textarea
ref={ref}
rows={3}
className={cn(
'w-full rounded-xl bg-tg-secondary-bg px-3 py-2.5 text-tg-text placeholder:text-tg-hint',
'outline-none focus:ring-2 focus:ring-tg-btn/40 transition-shadow resize-none',
error && 'ring-2 ring-red-400',
className
)}
{...props}
/>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
)
)
Textarea.displayName = 'Textarea'
export { Input, Textarea }
+70
View File
@@ -0,0 +1,70 @@
import * as SelectPrimitive from '@radix-ui/react-select'
import { ChevronDown, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
interface SelectOption {
value: string
label: string
}
interface SelectProps {
value?: string
onValueChange: (value: string) => void
options: SelectOption[]
placeholder?: string
label?: string
disabled?: boolean
}
export function Select({ value, onValueChange, options, placeholder = '请选择', label, disabled }: SelectProps) {
return (
<div className="flex flex-col gap-1">
{label && <label className="text-sm font-medium text-tg-hint">{label}</label>}
<SelectPrimitive.Root value={value} onValueChange={onValueChange} disabled={disabled}>
<SelectPrimitive.Trigger
className={cn(
'flex h-11 w-full items-center justify-between rounded-xl',
'bg-tg-secondary-bg px-3 text-tg-text',
'outline-none focus:ring-2 focus:ring-tg-btn/40 transition-shadow',
'data-[placeholder]:text-tg-hint disabled:opacity-50'
)}
>
<SelectPrimitive.Value placeholder={placeholder} />
<ChevronDown className="h-4 w-4 text-tg-hint shrink-0" />
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-xl bg-tg-bg shadow-xl border border-tg-secondary-bg',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95'
)}
position="popper"
sideOffset={4}
>
<SelectPrimitive.Viewport className="p-1">
{options.map((opt) => (
<SelectPrimitive.Item
key={opt.value}
value={opt.value}
className={cn(
'relative flex cursor-default select-none items-center rounded-lg px-3 py-2.5',
'text-sm text-tg-text outline-none',
'focus:bg-tg-secondary-bg data-[state=checked]:font-semibold'
)}
>
<SelectPrimitive.ItemText>{opt.label}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator className="ml-auto">
<Check className="h-4 w-4 text-tg-btn" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
</div>
)
}
+40
View File
@@ -0,0 +1,40 @@
import * as SwitchPrimitive from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
interface SwitchProps {
checked: boolean
onCheckedChange: (checked: boolean) => void
label?: string
description?: string
disabled?: boolean
}
export function Switch({ checked, onCheckedChange, label, description, disabled }: SwitchProps) {
return (
<div className="flex items-center justify-between gap-3">
{(label || description) && (
<div className="flex-1 min-w-0">
{label && <p className="text-sm font-medium text-tg-text">{label}</p>}
{description && <p className="text-xs text-tg-hint mt-0.5">{description}</p>}
</div>
)}
<SwitchPrimitive.Root
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
className={cn(
'relative h-6 w-11 rounded-full transition-colors focus-visible:outline-none',
'data-[state=checked]:bg-tg-btn data-[state=unchecked]:bg-gray-300',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<SwitchPrimitive.Thumb
className={cn(
'block h-5 w-5 rounded-full bg-white shadow-md transition-transform',
'data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0.5'
)}
/>
</SwitchPrimitive.Root>
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { AnimatePresence, motion } from 'framer-motion'
interface ToastProps {
message: string | null
}
export function Toast({ message }: ToastProps) {
return (
<AnimatePresence>
{message && (
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 40 }}
className="fixed bottom-24 left-4 right-4 z-[60] bg-gray-900 text-white text-sm px-4 py-3 rounded-2xl text-center shadow-lg"
>
{message}
</motion.div>
)}
</AnimatePresence>
)
}
+12
View File
@@ -0,0 +1,12 @@
import { useState } from 'react'
export function useToast(duration = 3000) {
const [toastMsg, setToastMsg] = useState<string | null>(null)
function showToast(msg: string) {
setToastMsg(msg)
window.setTimeout(() => setToastMsg(null), duration)
}
return { toastMsg, showToast }
}
+87
View File
@@ -0,0 +1,87 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
}
:root {
--tg-bg-color: #ffffff;
--tg-text-color: #000000;
--tg-hint-color: #999999;
--tg-link-color: #2481cc;
--tg-button-color: #2481cc;
--tg-button-rgb: 36 129 204; /* RGB of #2481cc - needed for opacity variants */
--tg-button-text-color: #ffffff;
--tg-secondary-bg-color: #efeff3;
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
margin: 0;
padding: 0;
background-color: var(--tg-secondary-bg-color);
color: var(--tg-text-color);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
min-height: 100dvh;
overscroll-behavior: none;
}
#root {
min-height: 100dvh;
}
/* Scrollbar - thin, blends into background, no layout impact on mobile */
::-webkit-scrollbar {
width: 3px;
background: transparent;
}
::-webkit-scrollbar-track {
background: var(--tg-secondary-bg-color);
}
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--tg-hint-color) 45%, transparent);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--tg-button-color) 70%, transparent);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--tg-hint-color) 45%, transparent) transparent;
}
/* Smooth scroll */
* {
scroll-behavior: smooth;
}
@layer components {
.card {
@apply bg-tg-bg rounded-2xl p-4 shadow-sm;
}
.section-header {
@apply text-xs font-semibold uppercase tracking-wide text-tg-hint px-4 pt-2 pb-1;
}
.list-item {
@apply bg-tg-bg px-4 py-3 flex items-center gap-3;
}
.list-item:not(:last-child) {
@apply border-b border-tg-secondary-bg;
}
}
+40
View File
@@ -0,0 +1,40 @@
import axios from 'axios'
import { getTelegramInitData } from './telegram'
const api = axios.create({
baseURL: '/api',
timeout: 15000,
})
async function waitForTelegramInitData(): Promise<string> {
const existing = getTelegramInitData()
if (existing) return existing
const hasTelegramWebApp = Boolean(window.Telegram?.WebApp)
if (!hasTelegramWebApp) return ''
const deadline = Date.now() + 2000
while (Date.now() < deadline) {
await new Promise((resolve) => window.setTimeout(resolve, 50))
const initData = getTelegramInitData()
if (initData) return initData
}
return ''
}
api.interceptors.request.use(async (config) => {
// Always send the header so the backend can return 401 instead of 422
config.headers['X-Telegram-Init-Data'] = await waitForTelegramInitData()
return config
})
api.interceptors.response.use(
(res) => res,
(err) => {
const msg = err.response?.data?.detail ?? err.message ?? 'Unknown error'
return Promise.reject(new Error(typeof msg === 'string' ? msg : JSON.stringify(msg)))
}
)
export default api
+121
View File
@@ -0,0 +1,121 @@
interface TelegramWebApp {
initData: string
initDataUnsafe: {
user?: {
id: number
username?: string
first_name: string
last_name?: string
}
start_param?: string
}
themeParams: {
bg_color?: string
text_color?: string
hint_color?: string
link_color?: string
button_color?: string
button_text_color?: string
secondary_bg_color?: string
}
colorScheme: 'light' | 'dark'
MainButton: {
text: string
color: string
textColor: string
isVisible: boolean
isActive: boolean
show(): void
hide(): void
enable(): void
disable(): void
showProgress(leaveActive?: boolean): void
hideProgress(): void
onClick(fn: () => void): void
offClick(fn: () => void): void
setParams(params: { text?: string; color?: string; text_color?: string; is_active?: boolean }): void
}
BackButton: {
isVisible: boolean
show(): void
hide(): void
onClick(fn: () => void): void
offClick(fn: () => void): void
}
HapticFeedback: {
impactOccurred(style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft'): void
notificationOccurred(type: 'error' | 'success' | 'warning'): void
selectionChanged(): void
}
openLink(url: string, options?: { try_instant_view?: boolean }): void
openTelegramLink(url: string): void
expand(): void
close(): void
ready(): void
showAlert(message: string, callback?: () => void): void
showConfirm(message: string, callback: (ok: boolean) => void): void
isExpanded: boolean
shareMessage(preparedMessageId: string, callback?: (sent: boolean) => void): void
}
declare global {
interface Window {
Telegram?: { WebApp: TelegramWebApp }
}
}
// Keep tg as a stable reference for non-auth uses (theme, haptic, BackButton, etc.)
// Do NOT use tg for initData reads - it may be captured before Telegram initializes.
export const tg: TelegramWebApp | undefined = window.Telegram?.WebApp
export function getTelegramInitData(): string {
// Read dynamically so we always get the value after Telegram has initialized
const initData = window.Telegram?.WebApp?.initData
if (initData) return initData
// Dev fallback: set VITE_DEV_INIT_DATA in .env.local for local testing
return import.meta.env.VITE_DEV_INIT_DATA ?? ''
}
export function getTelegramUser() {
return (
window.Telegram?.WebApp?.initDataUnsafe?.user ?? {
id: 0,
first_name: 'Dev',
username: 'devuser',
}
)
}
function hexToRgbTriplet(hex: string): string {
const h = hex.replace('#', '')
const r = parseInt(h.substring(0, 2), 16)
const g = parseInt(h.substring(2, 4), 16)
const b = parseInt(h.substring(4, 6), 16)
return `${r} ${g} ${b}`
}
export function applyTelegramTheme(): void {
if (!tg) return
const p = tg.themeParams
const root = document.documentElement
if (p.bg_color) root.style.setProperty('--tg-bg-color', p.bg_color)
if (p.text_color) root.style.setProperty('--tg-text-color', p.text_color)
if (p.hint_color) root.style.setProperty('--tg-hint-color', p.hint_color)
if (p.link_color) root.style.setProperty('--tg-link-color', p.link_color)
if (p.button_color) {
root.style.setProperty('--tg-button-color', p.button_color)
// Space-separated RGB for Tailwind opacity modifier support (bg-tg-btn/10)
root.style.setProperty('--tg-button-rgb', hexToRgbTriplet(p.button_color))
}
if (p.button_text_color) root.style.setProperty('--tg-button-text-color', p.button_text_color)
if (p.secondary_bg_color) root.style.setProperty('--tg-secondary-bg-color', p.secondary_bg_color)
}
export function haptic(type: 'light' | 'medium' | 'success' | 'error' = 'light'): void {
if (!tg) return
if (type === 'success' || type === 'error') {
tg.HapticFeedback.notificationOccurred(type)
} else {
tg.HapticFeedback.impactOccurred(type)
}
}
+49
View File
@@ -0,0 +1,49 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(iso: string): string {
const utc = iso.endsWith('Z') || iso.includes('+') ? iso : iso + 'Z'
return new Date(utc).toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
export function formatDateShort(iso: string): string {
const utc = iso.endsWith('Z') || iso.includes('+') ? iso : iso + 'Z'
return new Date(utc).toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
export function displayName(user: { first_name: string; last_name?: string | null; username?: string | null }): string {
const full = [user.first_name, user.last_name].filter(Boolean).join(' ')
return full || user.username || ''
}
export const STATUS_LABELS: Record<string, string> = {
draft: 'Draft',
active: 'Active',
drawing: 'Drawing',
finished: 'Finished',
cancelled: 'Cancelled',
}
export const STATUS_COLORS: Record<string, string> = {
draft: 'bg-gray-100 text-gray-600',
active: 'bg-emerald-100 text-emerald-700',
drawing: 'bg-blue-100 text-blue-700',
finished: 'bg-purple-100 text-purple-700',
cancelled: 'bg-red-100 text-red-600',
}
+27
View File
@@ -0,0 +1,27 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import { applyTelegramTheme } from './lib/telegram'
import './index.css'
applyTelegramTheme()
window.Telegram?.WebApp?.ready()
window.Telegram?.WebApp?.expand()
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
)
+357
View File
@@ -0,0 +1,357 @@
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Link2, X } from 'lucide-react'
import { motion } from 'framer-motion'
import { Input, Textarea } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select'
import { Switch } from '@/components/ui/Switch'
import { Toast } from '@/components/ui/Toast'
import { PrizeEditor, type LocalPrize } from '@/components/PrizeEditor'
import { useToast } from '@/hooks/useToast'
import api from '@/lib/api'
import { haptic, tg } from '@/lib/telegram'
import type { ChatInfo, Lottery } from '@/types'
function utcOffset(timezone: string): string {
const parts = new Intl.DateTimeFormat('en', {
timeZone: timezone,
timeZoneName: 'shortOffset',
}).formatToParts(new Date())
const offset = parts.find((p) => p.type === 'timeZoneName')?.value ?? ''
return offset.replace('GMT', 'UTC')
}
export default function CreateLottery() {
const navigate = useNavigate()
const location = useLocation()
const qc = useQueryClient()
const template = (location.state as any)?.template as import('@/types').Lottery | undefined
const [title, setTitle] = useState(() => template?.title ?? '')
const [description, setDescription] = useState(() => template?.description ?? '')
const [contactInfo, setContactInfo] = useState(() => template?.contact_info ?? '')
const [prizes, setPrizes] = useState<LocalPrize[]>(() =>
template?.prizes.map((p) => ({ _id: crypto.randomUUID(), name: p.name, description: p.description ?? '', quantity: p.quantity })) ?? []
)
const [requiredChatIds, setRequiredChatIds] = useState<string[]>(() =>
template?.required_chat_ids?.map(String) ?? []
)
const [chatPassphrases, setChatPassphrases] = useState<Record<string, string>>(() => {
if (!template?.required_chat_ids?.length) return {}
return Object.fromEntries(
template.required_chat_ids.map((id, i) => [String(id), (template.required_chat_passphrases ?? [])[i] ?? ''])
)
})
const [addChatKey, setAddChatKey] = useState(0)
const [maxParticipants, setMaxParticipants] = useState(() => template?.max_participants?.toString() ?? '')
const [drawDate, setDrawDate] = useState('')
const [drawTime, setDrawTime] = useState(() => {
const now = new Date()
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
})
const creatorTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const [autoDrawCount, setAutoDrawCount] = useState(() => template?.auto_draw_count?.toString() ?? '')
const [allowMultipleWins, setAllowMultipleWins] = useState(() => template?.allow_multiple_wins ?? false)
const [inviteLinkChatIds, setInviteLinkChatIds] = useState<Set<string>>(() =>
new Set(template?.invite_link_chat_ids?.map(String) ?? [])
)
const [errors, setErrors] = useState<Record<string, string>>({})
const { toastMsg, showToast } = useToast()
useEffect(() => {
tg?.BackButton.show()
const back = () => navigate('/')
tg?.BackButton.onClick(back)
return () => {
tg?.BackButton.hide()
tg?.BackButton.offClick(back)
}
}, [navigate])
const { data: chats = [] } = useQuery<ChatInfo[]>({
queryKey: ['chats'],
queryFn: () => api.get('/chats').then((r) => r.data),
})
const createMutation = useMutation({
mutationFn: async () => {
const requiredChats = chats.filter((c) => requiredChatIds.includes(String(c.id)))
if (inviteLinkChatIds.size > 0) {
const failures: string[] = []
for (const cid of inviteLinkChatIds) {
const title = chats.find((c) => String(c.id) === cid)?.title ?? cid
try {
const { data } = await api.get(`/chats/${cid}/invite`)
if (!data.url) failures.push(`${title}${data.error ? ` (${data.error})` : ''}`)
} catch {
failures.push(title)
}
}
if (failures.length > 0)
throw new Error(`Cannot generate invite link for: ${failures.join('; ')}`)
}
const lottery: Lottery = await api
.post('/lotteries', {
title: title.trim(),
description: description.trim() || undefined,
contact_info: contactInfo.trim() || undefined,
require_membership: requiredChatIds.length > 0,
required_chat_ids: requiredChats.map((c) => c.id),
required_chat_titles: requiredChats.map((c) => c.title),
required_chat_types: requiredChats.map((c) => c.chat_type),
required_chat_usernames: requiredChats.map((c) => c.username ?? ''),
max_participants: maxParticipants ? parseInt(maxParticipants, 10) : undefined,
draw_at: drawDate ? new Date(`${drawDate}T${drawTime || '00:00'}`).toISOString() : undefined,
auto_draw_count: autoDrawCount ? parseInt(autoDrawCount, 10) : undefined,
creator_timezone: drawDate ? creatorTimezone : undefined,
allow_multiple_wins: allowMultipleWins,
invite_link_chat_ids: requiredChats.filter((c) => inviteLinkChatIds.has(String(c.id))).map((c) => c.id),
required_chat_passphrases: requiredChats.map((c) => chatPassphrases[String(c.id)] ?? ''),
})
.then((r) => r.data)
for (const prize of prizes) {
await api.post(`/lotteries/${lottery.id}/prizes`, {
name: prize.name,
description: prize.description,
quantity: prize.quantity,
})
}
return lottery
},
onSuccess: (lottery) => {
haptic('success')
qc.invalidateQueries({ queryKey: ['lotteries'] })
navigate(`/lottery/${lottery.id}`)
},
onError: (err: Error) => {
haptic('error')
showToast(err.message)
},
})
function validate(): boolean {
const errs: Record<string, string> = {}
if (!title.trim()) errs.title = 'Enter a title'
if (prizes.length === 0) errs.prizes = 'Add at least one prize'
if (drawDate) {
const drawDatetime = new Date(`${drawDate}T${drawTime || '00:00'}`)
if (drawDatetime <= new Date()) errs.drawAt = 'Draw time must be in the future'
}
setErrors(errs)
return Object.keys(errs).length === 0
}
function handleSubmit() {
if (!validate()) {
haptic('error')
return
}
createMutation.mutate()
}
return (
<div className="min-h-dvh bg-tg-secondary-bg pb-28">
<div className="bg-tg-bg px-4 pt-4 pb-4 sticky top-0 z-10 shadow-sm flex items-center gap-3">
<button
onClick={() => navigate('/')}
className="p-1.5 -ml-1.5 rounded-xl hover:bg-tg-secondary-bg transition-colors"
>
<ArrowLeft className="h-5 w-5 text-tg-text" />
</button>
<h1 className="text-lg font-bold text-tg-text">{template ? 'Copy lottery' : 'Create lottery'}</h1>
</div>
<div className="px-4 pt-4 flex flex-col gap-5">
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }}>
<p className="section-header px-0">Basic info</p>
<div className="bg-tg-bg rounded-2xl p-4 flex flex-col gap-4">
<Input
label="Title *"
placeholder="Summer giveaway"
value={title}
onChange={(e) => {
setTitle(e.target.value)
setErrors((p) => ({ ...p, title: '' }))
}}
error={errors.title}
maxLength={255}
/>
<Textarea
label="Description"
placeholder="Add rules or notes."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Input
label="Contact info (private)"
placeholder="e.g. @username or email"
value={contactInfo}
onChange={(e) => setContactInfo(e.target.value)}
hint="Only shown in winner notifications, not in public announcements."
/>
</div>
</motion.div>
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
<p className="section-header px-0">
Prizes
{prizes.length > 0 && (
<span className="ml-1 text-tg-btn">({prizes.reduce((s, p) => s + p.quantity, 0)} total)</span>
)}
</p>
{errors.prizes && <p className="text-xs text-red-500 mb-2">{errors.prizes}</p>}
<PrizeEditor prizes={prizes} onChange={setPrizes} />
</motion.div>
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }}>
<p className="section-header px-0">Group settings</p>
<div className="bg-tg-bg rounded-2xl p-4 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-tg-hint">Require membership</label>
{requiredChatIds.length > 0 && (
<div className="flex flex-col gap-2">
{requiredChatIds.map((cid) => {
const chat = chats.find((c) => String(c.id) === cid)
const isChannel = chat?.chat_type === 'channel'
const usingLink = inviteLinkChatIds.has(cid)
return (
<div key={cid} className="bg-tg-secondary-bg rounded-xl p-3 flex flex-col gap-2">
<div className="flex items-center gap-1.5">
<span className={`shrink-0 text-xs font-medium px-2 py-0.5 rounded-full ${isChannel ? 'bg-amber-500/10 text-amber-600' : 'bg-tg-btn/10 text-tg-btn'}`}>
{isChannel ? 'Channel' : 'Group'}
</span>
<span className="flex-1 text-sm text-tg-text truncate">{chat?.title ?? cid}</span>
<button
title={usingLink ? 'Disable invite link' : 'Use invite link'}
onClick={() => setInviteLinkChatIds((s) => { const n = new Set(s); usingLink ? n.delete(cid) : n.add(cid); return n })}
className={`p-1.5 rounded-lg transition-colors ${usingLink ? 'text-tg-btn' : 'text-tg-hint opacity-30'}`}
>
<Link2 className="h-4 w-4" />
</button>
<button
onClick={() => {
setRequiredChatIds((ids) => ids.filter((i) => i !== cid))
setInviteLinkChatIds((s) => { const n = new Set(s); n.delete(cid); return n })
setChatPassphrases((p) => { const n = { ...p }; delete n[cid]; return n })
}}
className="p-1.5 rounded-lg text-tg-hint opacity-30 hover:opacity-100 hover:text-red-500 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{!isChannel && (
<Input
placeholder="Passphrase (optional)"
value={chatPassphrases[cid] ?? ''}
onChange={(e) => setChatPassphrases((p) => ({ ...p, [cid]: e.target.value }))}
/>
)}
</div>
)
})}
</div>
)}
{requiredChatIds.length > 0 && (
<p className="text-xs text-tg-hint">Tap <Link2 className="inline h-3 w-3 -mt-0.5" /> to use invite link. Bot must be admin with invite link permission.</p>
)}
{(() => {
const available = chats.filter((c) => !requiredChatIds.includes(String(c.id)))
if (available.length === 0 && chats.length === 0) {
return <p className="text-xs text-tg-hint">Add the bot to a group first.</p>
}
if (available.length === 0) return null
return (
<Select
key={addChatKey}
placeholder="Add group / channel…"
value=""
onValueChange={(v) => {
setRequiredChatIds((ids) => [...ids, v])
setAddChatKey((k) => k + 1)
}}
options={available.map((c) => ({
value: String(c.id),
label: `${c.chat_type === 'channel' ? 'Channel' : 'Group'}: ${c.title}`,
}))}
/>
)
})()}
</div>
<Input
label="Max participants"
placeholder="Unlimited"
type="number"
min={1}
value={maxParticipants}
onChange={(e) => setMaxParticipants(e.target.value)}
hint="Leave empty for no limit."
/>
</div>
</motion.div>
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
<p className="section-header px-0">Draw settings</p>
<div className="bg-tg-bg rounded-2xl p-4 flex flex-col gap-4 overflow-hidden">
<Switch
label="Allow multiple wins"
description="The same participant may win more than one prize."
checked={allowMultipleWins}
onCheckedChange={setAllowMultipleWins}
/>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-tg-hint">Draw at ({utcOffset(creatorTimezone)})</label>
<div className="flex gap-2">
<input
type="date"
value={drawDate}
onChange={(e) => setDrawDate(e.target.value)}
className="flex-1 min-w-0 h-11 rounded-xl bg-tg-secondary-bg px-3 text-tg-text outline-none focus:ring-2 focus:ring-tg-btn/40 transition-shadow"
/>
<input
type="time"
value={drawTime}
onChange={(e) => setDrawTime(e.target.value)}
disabled={!drawDate}
className="w-28 min-w-0 h-11 rounded-xl bg-tg-secondary-bg px-3 text-tg-text outline-none focus:ring-2 focus:ring-tg-btn/40 transition-shadow disabled:opacity-40"
/>
</div>
{errors.drawAt
? <p className="text-xs text-red-500">{errors.drawAt}</p>
: <p className="text-xs text-tg-hint">Leave empty to draw manually.</p>
}
</div>
<Input
label="Draw when participants reach"
placeholder="e.g. 100"
type="number"
min={1}
value={autoDrawCount}
onChange={(e) => setAutoDrawCount(e.target.value)}
hint="Independent from max participants."
/>
</div>
</motion.div>
</div>
<div className="fixed bottom-0 left-0 right-0 p-4 bg-tg-bg border-t border-tg-secondary-bg">
<Button
size="lg"
onClick={handleSubmit}
loading={createMutation.isPending}
disabled={createMutation.isPending}
>
Create lottery
</Button>
</div>
<Toast message={toastMsg} />
</div>
)
}
+444
View File
@@ -0,0 +1,444 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Link2, Loader2, AlertCircle, X } from 'lucide-react'
import { motion } from 'framer-motion'
import { Input, Textarea } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select'
import { Switch } from '@/components/ui/Switch'
import { Toast } from '@/components/ui/Toast'
import { PrizeEditor, type LocalPrize } from '@/components/PrizeEditor'
import { useToast } from '@/hooks/useToast'
import api from '@/lib/api'
import { haptic, tg } from '@/lib/telegram'
import type { ChatInfo, Lottery } from '@/types'
function utcOffset(timezone: string): string {
const parts = new Intl.DateTimeFormat('en', {
timeZone: timezone,
timeZoneName: 'shortOffset',
}).formatToParts(new Date())
const offset = parts.find((p) => p.type === 'timeZoneName')?.value ?? ''
return offset.replace('GMT', 'UTC')
}
type EditablePrize = LocalPrize & { existingId?: string }
function toDatetimeLocal(isoString: string): string {
const utc = isoString.endsWith('Z') || isoString.includes('+') ? isoString : isoString + 'Z'
const d = new Date(utc)
const offset = d.getTimezoneOffset() * 60000
return new Date(d.getTime() - offset).toISOString().slice(0, 16)
}
export default function EditLottery() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [contactInfo, setContactInfo] = useState('')
const [prizes, setPrizes] = useState<EditablePrize[]>([])
const [originalPrizeIds, setOriginalPrizeIds] = useState<Set<string>>(new Set())
const [requiredChatIds, setRequiredChatIds] = useState<string[]>([])
const [chatPassphrases, setChatPassphrases] = useState<Record<string, string>>({})
const [addChatKey, setAddChatKey] = useState(0)
const [maxParticipants, setMaxParticipants] = useState('')
const [drawDate, setDrawDate] = useState('')
const [drawTime, setDrawTime] = useState(() => {
const now = new Date()
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
})
const creatorTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const [autoDrawCount, setAutoDrawCount] = useState('')
const [allowMultipleWins, setAllowMultipleWins] = useState(false)
const [inviteLinkChatIds, setInviteLinkChatIds] = useState<Set<string>>(new Set())
const [errors, setErrors] = useState<Record<string, string>>({})
const { toastMsg, showToast } = useToast()
const [initialized, setInitialized] = useState(false)
useEffect(() => {
tg?.BackButton.show()
const back = () => navigate(`/lottery/${id}`)
tg?.BackButton.onClick(back)
return () => {
tg?.BackButton.hide()
tg?.BackButton.offClick(back)
}
}, [navigate, id])
const { data: lottery, isLoading, error } = useQuery<Lottery>({
queryKey: ['lottery', id],
queryFn: () => api.get(`/lotteries/${id}`).then((r) => r.data),
enabled: !!id,
})
const canEditPrizes = lottery?.status === 'draft' || lottery?.status === 'active'
const { data: chats = [] } = useQuery<ChatInfo[]>({
queryKey: ['chats'],
queryFn: () => api.get('/chats').then((r) => r.data),
})
useEffect(() => {
if (!lottery || initialized) return
if (lottery.status === 'finished' || lottery.status === 'cancelled') {
navigate(`/lottery/${id}`, { replace: true })
return
}
setTitle(lottery.title)
setDescription(lottery.description ?? '')
setContactInfo(lottery.contact_info ?? '')
setMaxParticipants(lottery.max_participants ? String(lottery.max_participants) : '')
if (lottery.draw_at) {
const local = toDatetimeLocal(lottery.draw_at)
setDrawDate(local.slice(0, 10))
setDrawTime(local.slice(11, 16))
}
setAutoDrawCount(lottery.auto_draw_count ? String(lottery.auto_draw_count) : '')
setAllowMultipleWins(lottery.allow_multiple_wins ?? false)
setInviteLinkChatIds(new Set((lottery.invite_link_chat_ids ?? []).map(String)))
setRequiredChatIds((lottery.required_chat_ids ?? []).map(String))
setChatPassphrases(
Object.fromEntries(
(lottery.required_chat_ids ?? []).map((id, i) => [
String(id),
(lottery.required_chat_passphrases ?? [])[i] ?? '',
])
)
)
const existingPrizes: EditablePrize[] = lottery.prizes.map((p) => ({
_id: `existing_${p.id}`,
existingId: p.id,
name: p.name,
quantity: p.quantity,
}))
setPrizes(existingPrizes)
setOriginalPrizeIds(new Set(lottery.prizes.map((p) => p.id)))
setInitialized(true)
}, [lottery, initialized, navigate, id])
const saveMutation = useMutation({
mutationFn: async () => {
const requiredChats = chats.filter((c) => requiredChatIds.includes(String(c.id)))
if (inviteLinkChatIds.size > 0) {
const failures: string[] = []
for (const cid of inviteLinkChatIds) {
const title = chats.find((c) => String(c.id) === cid)?.title ?? cid
try {
const { data } = await api.get(`/chats/${cid}/invite`)
if (!data.url) failures.push(`${title}${data.error ? ` (${data.error})` : ''}`)
} catch {
failures.push(title)
}
}
if (failures.length > 0)
throw new Error(`Cannot generate invite link for: ${failures.join('; ')}`)
}
await api.put(`/lotteries/${id}`, {
title: title.trim(),
description: description.trim() || null,
contact_info: contactInfo.trim() || null,
require_membership: requiredChatIds.length > 0,
required_chat_ids: requiredChats.map((c) => c.id),
required_chat_titles: requiredChats.map((c) => c.title),
required_chat_types: requiredChats.map((c) => c.chat_type),
required_chat_usernames: requiredChats.map((c) => c.username ?? ''),
max_participants: maxParticipants ? parseInt(maxParticipants, 10) : null,
draw_at: drawDate ? new Date(`${drawDate}T${drawTime || '00:00'}`).toISOString() : null,
auto_draw_count: autoDrawCount ? parseInt(autoDrawCount, 10) : null,
creator_timezone: drawDate ? creatorTimezone : null,
allow_multiple_wins: allowMultipleWins,
invite_link_chat_ids: requiredChats.filter((c) => inviteLinkChatIds.has(String(c.id))).map((c) => c.id),
required_chat_passphrases: requiredChats.map((c) => chatPassphrases[String(c.id)] ?? ''),
})
if (lottery?.status === 'draft' || lottery?.status === 'active') {
const currentExistingIds = new Set(
prizes.filter((p) => p.existingId).map((p) => p.existingId as string)
)
const toDelete = [...originalPrizeIds].filter((pid) => !currentExistingIds.has(pid))
for (const pid of toDelete) {
await api.delete(`/prizes/${pid}`)
}
const originalByExistingId = Object.fromEntries(
lottery.prizes.map((p) => [p.id, p])
)
const existingPrizes = prizes.filter((p) => p.existingId)
for (const prize of existingPrizes) {
const orig = originalByExistingId[prize.existingId!]
if (orig && (orig.name !== prize.name || orig.quantity !== prize.quantity)) {
await api.put(`/prizes/${prize.existingId}`, {
name: prize.name,
quantity: prize.quantity,
})
}
}
const newPrizes = prizes.filter((p) => !p.existingId)
for (const prize of newPrizes) {
await api.post(`/lotteries/${id}/prizes`, {
name: prize.name,
description: prize.description,
quantity: prize.quantity,
})
}
}
},
onSuccess: () => {
haptic('success')
qc.invalidateQueries({ queryKey: ['lottery', id] })
qc.invalidateQueries({ queryKey: ['lotteries'] })
navigate(`/lottery/${id}`)
},
onError: (err: Error) => {
haptic('error')
showToast(err.message)
},
})
function validate(): boolean {
const errs: Record<string, string> = {}
if (!title.trim()) errs.title = 'Enter a title'
if (prizes.length === 0) errs.prizes = 'Add at least one prize'
if (drawDate) {
const drawDatetime = new Date(`${drawDate}T${drawTime || '00:00'}`)
if (drawDatetime <= new Date()) errs.drawAt = 'Draw time must be in the future'
}
setErrors(errs)
return Object.keys(errs).length === 0
}
function handleSubmit() {
if (!validate()) {
haptic('error')
return
}
saveMutation.mutate()
}
if (isLoading || !initialized) {
return (
<div className="min-h-dvh flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-tg-btn" />
</div>
)
}
if (error || !lottery) {
return (
<div className="min-h-dvh flex flex-col items-center justify-center p-6 text-center gap-3">
<AlertCircle className="h-10 w-10 text-red-500" />
<p className="font-semibold text-tg-text">Failed to load lottery</p>
<Button variant="secondary" onClick={() => navigate('/')}>Back</Button>
</div>
)
}
return (
<div className="min-h-dvh bg-tg-secondary-bg pb-28">
<div className="bg-tg-bg px-4 pt-4 pb-4 sticky top-0 z-10 shadow-sm flex items-center gap-3">
<button
onClick={() => navigate(`/lottery/${id}`)}
className="p-1.5 -ml-1.5 rounded-xl hover:bg-tg-secondary-bg transition-colors"
>
<ArrowLeft className="h-5 w-5 text-tg-text" />
</button>
<h1 className="text-lg font-bold text-tg-text">Edit lottery</h1>
</div>
<div className="px-4 pt-4 flex flex-col gap-5">
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }}>
<p className="section-header px-0">Basic info</p>
<div className="bg-tg-bg rounded-2xl p-4 flex flex-col gap-4">
<Input
label="Title *"
placeholder="Summer giveaway"
value={title}
onChange={(e) => {
setTitle(e.target.value)
setErrors((p) => ({ ...p, title: '' }))
}}
error={errors.title}
maxLength={255}
/>
<Textarea
label="Description"
placeholder="Add rules or notes."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Input
label="Contact info (private)"
placeholder="e.g. @username or email"
value={contactInfo}
onChange={(e) => setContactInfo(e.target.value)}
hint="Only shown in winner notifications, not in public announcements."
/>
</div>
</motion.div>
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
<p className="section-header px-0">
Prizes
{prizes.length > 0 && (
<span className="ml-1 text-tg-btn">({prizes.reduce((s, p) => s + p.quantity, 0)} total)</span>
)}
</p>
{errors.prizes && <p className="text-xs text-red-500 mb-2">{errors.prizes}</p>}
<PrizeEditor prizes={prizes} onChange={(p) => setPrizes(p as EditablePrize[])} disabled={!canEditPrizes} />
</motion.div>
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.15 }}>
<p className="section-header px-0">Group settings</p>
<div className="bg-tg-bg rounded-2xl p-4 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-tg-hint">Require membership</label>
{requiredChatIds.length > 0 && (
<div className="flex flex-col gap-2">
{requiredChatIds.map((cid) => {
const chat = chats.find((c) => String(c.id) === cid)
const isChannel = chat?.chat_type === 'channel'
const usingLink = inviteLinkChatIds.has(cid)
return (
<div key={cid} className="bg-tg-secondary-bg rounded-xl p-3 flex flex-col gap-2">
<div className="flex items-center gap-1.5">
<span className={`shrink-0 text-xs font-medium px-2 py-0.5 rounded-full ${isChannel ? 'bg-amber-500/10 text-amber-600' : 'bg-tg-btn/10 text-tg-btn'}`}>
{isChannel ? 'Channel' : 'Group'}
</span>
<span className="flex-1 text-sm text-tg-text truncate">{chat?.title ?? cid}</span>
<button
title={usingLink ? 'Disable invite link' : 'Use invite link'}
onClick={() => setInviteLinkChatIds((s) => { const n = new Set(s); usingLink ? n.delete(cid) : n.add(cid); return n })}
className={`p-1.5 rounded-lg transition-colors ${usingLink ? 'text-tg-btn' : 'text-tg-hint opacity-30'}`}
>
<Link2 className="h-4 w-4" />
</button>
<button
onClick={() => {
setRequiredChatIds((ids) => ids.filter((i) => i !== cid))
setInviteLinkChatIds((s) => { const n = new Set(s); n.delete(cid); return n })
setChatPassphrases((p) => { const n = { ...p }; delete n[cid]; return n })
}}
className="p-1.5 rounded-lg text-tg-hint opacity-30 hover:opacity-100 hover:text-red-500 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{!isChannel && (
<Input
placeholder="Passphrase (optional)"
value={chatPassphrases[cid] ?? ''}
onChange={(e) => setChatPassphrases((p) => ({ ...p, [cid]: e.target.value }))}
/>
)}
</div>
)
})}
</div>
)}
{requiredChatIds.length > 0 && (
<p className="text-xs text-tg-hint">Tap <Link2 className="inline h-3 w-3 -mt-0.5" /> to use invite link. Bot must be admin with invite link permission.</p>
)}
{(() => {
const available = chats.filter((c) => !requiredChatIds.includes(String(c.id)))
if (available.length === 0 && chats.length === 0) {
return <p className="text-xs text-tg-hint">Add the bot to a group first.</p>
}
if (available.length === 0) return null
return (
<Select
key={addChatKey}
placeholder="Add group / channel…"
value=""
onValueChange={(v) => {
setRequiredChatIds((ids) => [...ids, v])
setAddChatKey((k) => k + 1)
}}
options={available.map((c) => ({
value: String(c.id),
label: `${c.chat_type === 'channel' ? 'Channel' : 'Group'}: ${c.title}`,
}))}
/>
)
})()}
</div>
<Input
label="Max participants"
placeholder="Unlimited"
type="number"
min={1}
value={maxParticipants}
onChange={(e) => setMaxParticipants(e.target.value)}
hint="Leave empty for no limit."
/>
</div>
</motion.div>
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
<p className="section-header px-0">Draw settings</p>
<div className="bg-tg-bg rounded-2xl p-4 flex flex-col gap-4 overflow-hidden">
<Switch
label="Allow multiple wins"
description="The same participant may win more than one prize."
checked={allowMultipleWins}
onCheckedChange={setAllowMultipleWins}
/>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-tg-hint">Draw at ({utcOffset(creatorTimezone)})</label>
<div className="flex gap-2">
<input
type="date"
value={drawDate}
onChange={(e) => setDrawDate(e.target.value)}
className="flex-1 min-w-0 h-11 rounded-xl bg-tg-secondary-bg px-3 text-tg-text outline-none focus:ring-2 focus:ring-tg-btn/40 transition-shadow"
/>
<input
type="time"
value={drawTime}
onChange={(e) => setDrawTime(e.target.value)}
disabled={!drawDate}
className="w-28 min-w-0 h-11 rounded-xl bg-tg-secondary-bg px-3 text-tg-text outline-none focus:ring-2 focus:ring-tg-btn/40 transition-shadow disabled:opacity-40"
/>
</div>
{errors.drawAt
? <p className="text-xs text-red-500">{errors.drawAt}</p>
: <p className="text-xs text-tg-hint">Leave empty to draw manually.</p>
}
</div>
<Input
label="Draw when participants reach"
placeholder="e.g. 100"
type="number"
min={1}
value={autoDrawCount}
onChange={(e) => setAutoDrawCount(e.target.value)}
hint="Independent from max participants."
/>
</div>
</motion.div>
</div>
<div className="fixed bottom-0 left-0 right-0 p-4 bg-tg-bg border-t border-tg-secondary-bg">
<Button
size="lg"
onClick={handleSubmit}
loading={saveMutation.isPending}
disabled={saveMutation.isPending}
>
Save changes
</Button>
</div>
<Toast message={toastMsg} />
</div>
)
}
+325
View File
@@ -0,0 +1,325 @@
import { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Plus, Trophy, Loader2, AlertCircle, ChevronDown, Check } from 'lucide-react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { motion, AnimatePresence } from 'framer-motion'
import { LotteryCard } from '@/components/LotteryCard'
import { Button } from '@/components/ui/Button'
import api from '@/lib/api'
import { getTelegramInitData, getTelegramUser } from '@/lib/telegram'
import type { Lottery } from '@/types'
import { cn, STATUS_LABELS } from '@/lib/utils'
export default function Home() {
const navigate = useNavigate()
const user = getTelegramUser()
const [tab, setTab] = useState<'mine' | 'joined'>(
() => (sessionStorage.getItem('homeTab') as 'mine' | 'joined') ?? 'mine'
)
const [direction, setDirection] = useState(0)
type FilterValue = 'all' | Lottery['status']
const [tabFilters, setTabFilters] = useState<Record<string, FilterValue>>(() => ({
mine: (sessionStorage.getItem('homeFilter_mine') as FilterValue) ?? 'all',
joined: (sessionStorage.getItem('homeFilter_joined') as FilterValue) ?? 'all',
}))
const filterStatus = tabFilters[tab] ?? 'all'
const [tabFinishedSub, setTabFinishedSub] = useState<Record<string, 'all' | 'won' | 'no_win'>>({ mine: 'all', joined: 'all' })
const finishedSub = tabFinishedSub[tab] ?? 'all'
const [finishedDropdownOpen, setFinishedDropdownOpen] = useState(false)
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 })
const finishedBtnRef = useRef<HTMLButtonElement>(null)
const tabOrder = { mine: 0, joined: 1 }
function handleTabChange(v: 'mine' | 'joined') {
setDirection(tabOrder[v] > tabOrder[tab] ? 1 : -1)
setTab(v)
sessionStorage.setItem('homeTab', v)
setFinishedDropdownOpen(false)
}
function handleFilterChange(status: FilterValue) {
setTabFilters((prev) => ({ ...prev, [tab]: status }))
sessionStorage.setItem(`homeFilter_${tab}`, status)
}
function openFinishedDropdown() {
handleFilterChange('finished')
const rect = finishedBtnRef.current?.getBoundingClientRect()
if (rect) setDropdownPos({ top: rect.bottom + 6, left: rect.left })
setFinishedDropdownOpen((v) => !v)
}
const {
data: myLotteries = [],
isLoading: myLoading,
error: myError,
} = useQuery<Lottery[]>({
queryKey: ['lotteries', 'mine'],
queryFn: () => api.get('/lotteries').then((r) => r.data),
})
const {
data: joinedLotteries = [],
isLoading: joinedLoading,
error: joinedError,
} = useQuery<Lottery[]>({
queryKey: ['lotteries', 'joined'],
queryFn: () => api.get('/lotteries/joined').then((r) => r.data),
enabled: tab === 'joined',
})
const isLoading = tab === 'mine' ? myLoading : joinedLoading
const error = tab === 'mine' ? myError : joinedError
const lotteries = tab === 'mine' ? myLotteries : joinedLotteries
const filteredLotteries =
filterStatus === 'all' ? lotteries
: filterStatus === 'finished' && finishedSub === 'won'
? lotteries.filter((l) => l.status === 'finished' && l.is_winner === true)
: filterStatus === 'finished' && finishedSub === 'no_win'
? lotteries.filter((l) => l.status === 'finished' && l.is_winner === false)
: lotteries.filter((l) => l.status === filterStatus)
const STATUS_ORDER: Lottery['status'][] = ['draft', 'active', 'drawing', 'finished', 'cancelled']
const uniqueStatuses = STATUS_ORDER.filter((s) => lotteries.some((l) => l.status === s))
const showWonSub = lotteries.some((l) => l.status === 'finished' && l.is_winner === true)
const showNoWinSub = lotteries.some((l) => l.status === 'finished' && l.is_winner === false)
return (
<div className="min-h-dvh bg-tg-secondary-bg pb-24">
<div className="sticky top-0 z-10">
<div className="bg-tg-bg px-4 pt-4 pb-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-tg-text">Lottery Bot</h1>
<p className="text-sm text-tg-hint">
Hello, {user.first_name}
{user.username ? ` (@${user.username})` : ''}
</p>
</div>
<Trophy className="h-8 w-8 text-tg-btn opacity-80" />
</div>
<TabsPrimitive.Root value={tab} onValueChange={(v) => handleTabChange(v as 'mine' | 'joined')}>
<TabsPrimitive.List className="relative flex mt-3 gap-1 bg-tg-secondary-bg rounded-xl p-1">
{([
['mine', 'My lotteries'],
['joined', 'Joined'],
] as const).map(([value, label]) => (
<TabsPrimitive.Trigger
key={value}
value={value}
className={cn(
'relative flex-1 py-1.5 rounded-lg text-sm font-medium transition-colors z-10',
'data-[state=active]:text-tg-text',
'data-[state=inactive]:text-tg-hint'
)}
>
{tab === value && (
<motion.div
layoutId="tab-pill"
className="absolute inset-0 bg-tg-bg rounded-lg shadow-sm"
transition={{ type: 'spring', stiffness: 400, damping: 35 }}
/>
)}
<span className="relative z-10">
{label}
{value === 'joined' && joinedLotteries.length > 0 && (
<span className="ml-1.5 text-xs bg-tg-btn text-tg-btn-text px-1.5 py-0.5 rounded-full">
{joinedLotteries.length}
</span>
)}
</span>
</TabsPrimitive.Trigger>
))}
</TabsPrimitive.List>
</TabsPrimitive.Root>
</div>
<AnimatePresence>
{lotteries.length > 0 && uniqueStatuses.length > 1 && (
<motion.div
key={tab}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.18 }}
style={{ overflow: 'hidden' }}
>
<div className="flex gap-2 overflow-x-auto bg-tg-secondary-bg px-4 pt-3 pb-3 no-scrollbar">
<button
onClick={() => { handleFilterChange('all'); setFinishedDropdownOpen(false) }}
className={cn(
'shrink-0 flex items-center text-xs px-3 py-1 rounded-full font-medium transition-colors',
filterStatus === 'all' ? 'bg-tg-btn text-tg-btn-text' : 'bg-tg-secondary-bg text-tg-hint'
)}
>
All
</button>
{uniqueStatuses.map((s) => {
const isFinishedWithSub = s === 'finished' && (showWonSub || showNoWinSub)
const active = filterStatus === s
return (
<button
key={s}
ref={s === 'finished' ? finishedBtnRef : undefined}
onClick={() => isFinishedWithSub ? openFinishedDropdown() : handleFilterChange(s)}
className={cn(
'shrink-0 flex items-center gap-0.5 text-xs px-3 py-1 rounded-full font-medium transition-colors',
active ? 'bg-tg-btn text-tg-btn-text' : 'bg-tg-secondary-bg text-tg-hint'
)}
>
{STATUS_LABELS[s]}
{isFinishedWithSub && (
<ChevronDown className={cn('h-3 w-3 transition-transform', finishedDropdownOpen && active ? 'rotate-180' : '')} />
)}
</button>
)
})}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Finished sub-filter dropdown */}
<AnimatePresence>
{finishedDropdownOpen && (
<>
<div className="fixed inset-0 z-[90]" onClick={() => setFinishedDropdownOpen(false)} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -4 }}
transition={{ duration: 0.12 }}
style={{ position: 'fixed', top: dropdownPos.top, left: dropdownPos.left, zIndex: 91 }}
className="bg-tg-bg rounded-xl shadow-lg overflow-hidden min-w-[130px]"
>
{([
['all', 'All finished'],
...(showWonSub ? [['won', '🏆 Won']] : []),
...(showNoWinSub ? [['no_win', '😢 Missed']] : []),
] as [string, string][]).map(([sub, label]) => (
<button
key={sub}
onClick={() => { setTabFinishedSub((p) => ({ ...p, [tab]: sub as 'all' | 'won' | 'no_win' })); setFinishedDropdownOpen(false) }}
className="flex items-center gap-2 w-full px-4 py-2.5 text-sm text-tg-text hover:bg-tg-secondary-bg transition-colors"
>
<span className="flex-1 text-left">{label}</span>
{finishedSub === sub && <Check className="h-3.5 w-3.5 text-tg-btn shrink-0" />}
</button>
))}
</motion.div>
</>
)}
</AnimatePresence>
</div>
<div className="px-4 pt-4 overflow-hidden">
<AnimatePresence mode="wait" custom={direction}>
<motion.div
key={tab}
custom={direction}
variants={{
enter: (d: number) => ({ x: d * 32, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (d: number) => ({ x: d * -32, opacity: 0 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="flex flex-col gap-3"
>
{error ? (
<ErrorState error={error} />
) : isLoading ? (
<div className="flex justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-tg-btn" />
</div>
) : lotteries.length === 0 ? (
<EmptyState tab={tab} onCreateClick={() => navigate('/create')} />
) : filteredLotteries.length === 0 ? (
<p className="text-center text-sm text-tg-hint py-12">No lotteries match this filter.</p>
) : (
filteredLotteries.map((l) => (
<LotteryCard key={l.id} lottery={l} currentUserId={user.id} showOwnerBadge={tab === 'joined'} fromTab={tab} />
))
)}
</motion.div>
</AnimatePresence>
</div>
<motion.div
className="fixed bottom-6 right-4 z-20"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<Button
size="md"
className="rounded-2xl shadow-lg gap-2 px-5"
onClick={() => navigate('/create')}
>
<Plus className="h-5 w-5" />
Create
</Button>
</motion.div>
</div>
)
}
function EmptyState({ tab, onCreateClick }: { tab: string; onCreateClick: () => void }) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="text-5xl mb-4">-</div>
<p className="text-lg font-semibold text-tg-text mb-1">
{tab === 'mine' ? 'No lotteries yet' : 'No joined lotteries'}
</p>
<p className="text-sm text-tg-hint mb-6">
{tab === 'mine' ? 'Create your first lottery.' : 'Join a lottery to see it here.'}
</p>
{tab === 'mine' && (
<Button onClick={onCreateClick}>
<Plus className="h-4 w-4" />
Create
</Button>
)}
</div>
)
}
function ErrorState({ error }: { error: unknown }) {
const initData = getTelegramInitData()
const webApp = window.Telegram?.WebApp
const unsafeUser = webApp?.initDataUnsafe?.user
return (
<div
style={{
margin: 16,
padding: 16,
borderRadius: 12,
background: '#ffffff',
color: '#111111',
border: '1px solid #e5e7eb',
fontFamily: 'system-ui, -apple-system, Segoe UI, sans-serif',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<AlertCircle className="h-5 w-5 text-red-500" />
<strong>Failed to load Mini App</strong>
</div>
<p style={{ margin: '8px 0', color: '#374151', wordBreak: 'break-word' }}>
{String(error)}
</p>
<div style={{ margin: 0, fontSize: 12, lineHeight: 1.7, color: '#4b5563' }}>
<div>Telegram SDK: {webApp ? 'present' : 'missing'}</div>
<div>initData length: {initData.length}</div>
<div>initData has hash: {initData.includes('hash=') ? 'yes' : 'no'}</div>
<div>User: {unsafeUser?.id ? `${unsafeUser.id} ${unsafeUser.username ?? ''}` : 'missing'}</div>
<div>Platform: {(webApp as any)?.platform ?? 'unknown'}</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
export interface Prize {
id: string
name: string
description?: string
quantity: number
sort_order: number
}
export interface Lottery {
id: string
creator_id: number
title: string
description?: string
contact_info?: string
status: 'draft' | 'active' | 'drawing' | 'finished' | 'cancelled'
target_chat_id?: number
target_chat_title?: string
require_membership: boolean
required_chat_ids: number[]
required_chat_titles: string[]
required_chat_types: string[]
required_chat_usernames: string[]
announcement_targets: { chat_id: number; chat_title: string; message_id?: number }[]
allow_multiple_wins: boolean
invite_link_chat_ids: number[]
required_chat_passphrases: string[]
max_participants?: number
draw_at?: string
auto_draw_count?: number
creator_timezone?: string
participant_count: number
prize_count: number
total_prizes: number
created_at: string
drawn_at?: string
draw_trigger?: 'manual' | 'scheduled' | 'auto_count'
prizes: Prize[]
is_winner?: boolean | null
}
export interface Participant {
user_id: number
username?: string
first_name: string
last_name?: string
joined_at: string
}
export interface Winner {
user_id: number
username?: string
first_name: string
last_name?: string
prize_name: string
drawn_at: string
}
export interface ChatInfo {
id: number
title: string
username?: string
chat_type: string
}
export interface PrizeCreate {
name: string
description?: string
quantity: number
}
export interface LotteryCreate {
title: string
description?: string
target_chat_id?: number
target_chat_title?: string
require_membership: boolean
max_participants?: number
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
import type { Config } from 'tailwindcss'
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
tg: {
bg: 'var(--tg-bg-color)',
text: 'var(--tg-text-color)',
hint: 'var(--tg-hint-color)',
link: 'var(--tg-link-color)',
// Use RGB tuple format so opacity modifiers (bg-tg-btn/10) work
btn: 'rgb(var(--tg-button-rgb) / <alpha-value>)',
'btn-text': 'var(--tg-button-text-color)',
'secondary-bg': 'var(--tg-secondary-bg-color)',
},
},
fontFamily: {
sans: ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],
},
},
},
plugins: [],
} satisfies Config
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
build: {
target: 'es2018',
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
allowedHosts: true,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})