This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user