diff --git a/app/api/admin/access-logs/route.ts b/app/api/admin/access-logs/route.ts new file mode 100644 index 00000000..85ac01c3 --- /dev/null +++ b/app/api/admin/access-logs/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server" +import { getAdmin } from "@/lib/supabase/admin" + +const ADMIN_USERNAME = "cipollas" + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url) + const adminUsername = searchParams.get("adminUsername") + const date = searchParams.get("date") // Format: YYYY-MM-DD + + if (adminUsername !== ADMIN_USERNAME) { + return NextResponse.json({ error: "Non autorizzato" }, { status: 403 }) + } + + const supabase = getAdmin() + + // Get start and end of the requested day (or today) + const targetDate = date ? new Date(date) : new Date() + const startOfDay = new Date(targetDate) + startOfDay.setHours(0, 0, 0, 0) + const endOfDay = new Date(targetDate) + endOfDay.setHours(23, 59, 59, 999) + + const { data, error } = await supabase + .from("access_logs") + .select("id, user_id, username, logged_at") + .gte("logged_at", startOfDay.toISOString()) + .lte("logged_at", endOfDay.toISOString()) + .order("logged_at", { ascending: false }) + + if (error) { + return NextResponse.json({ error: "Errore database: " + error.message }, { status: 500 }) + } + + // Count unique users + const uniqueUsers = new Set(data?.map(log => log.user_id) || []) + + return NextResponse.json({ + logs: data || [], + totalAccesses: data?.length || 0, + uniqueUsers: uniqueUsers.size, + date: targetDate.toISOString().split("T")[0], + }) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} diff --git a/app/api/admin/ban/route.ts b/app/api/admin/ban/route.ts new file mode 100644 index 00000000..30fa3415 --- /dev/null +++ b/app/api/admin/ban/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server" +import { getAdmin } from "@/lib/supabase/admin" + +export async function POST(req: Request) { + try { + const { userId, adminUsername, reason } = await req.json() + if (adminUsername !== "cipollas") return NextResponse.json({ error: "Non autorizzato" }, { status: 403 }) + + const supabase = getAdmin() + const { data: profile } = await supabase.from("profiles").select("display_name").eq("id", userId).single() + if (!profile) return NextResponse.json({ error: "Utente non trovato" }, { status: 404 }) + + const { data: piUser } = await supabase.from("pi_users").select("pi_uid").eq("username", profile.display_name).maybeSingle() + if (!piUser) return NextResponse.json({ error: "Pi user non trovato" }, { status: 404 }) + + await supabase.from("banned_users").upsert({ + pi_uid: piUser.pi_uid, + username: profile.display_name, + reason: reason || "Violazione regole chat", + }, { onConflict: "pi_uid" }) + + // Delete all messages from banned user + await supabase.from("messages").delete().eq("user_id", userId) + + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts new file mode 100644 index 00000000..62bdc0ae --- /dev/null +++ b/app/api/messages/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from "next/server" +import { getAdmin } from "@/lib/supabase/admin" + +export async function GET() { + try { + const supabase = getAdmin() + const { data: messages, error } = await supabase + .from("messages") + .select("id, content, created_at, user_id, reply_to, image_url, audio_url") + .order("created_at", { ascending: true }) + .limit(200) + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (!messages || messages.length === 0) return NextResponse.json([]) + + const userIds = [...new Set(messages.map((m) => m.user_id))] + const { data: profiles } = await supabase.from("profiles").select("id, display_name").in("id", userIds) + const profileMap = new Map(profiles?.map((p) => [p.id, p.display_name]) || []) + + // Get replied-to messages + const replyIds = messages.map((m) => m.reply_to).filter(Boolean) + let replyMap = new Map() + if (replyIds.length > 0) { + const { data: replies } = await supabase.from("messages").select("id, content, user_id").in("id", replyIds) + if (replies) { + for (const r of replies) { + replyMap.set(r.id, { + content: r.content, + display_name: profileMap.get(r.user_id) || "Pioniere", + }) + } + } + } + + const enriched = messages.map((m) => ({ + ...m, + display_name: profileMap.get(m.user_id) || "Pioniere", + reply_message: m.reply_to ? replyMap.get(m.reply_to) || null : null, + })) + + return NextResponse.json(enriched) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} + +export async function POST(req: Request) { + try { + const { content, userId, replyTo, imageUrl, audioUrl } = await req.json() + if ((!content?.trim() && !imageUrl && !audioUrl) || !userId) { + return NextResponse.json({ error: "Dati mancanti" }, { status: 400 }) + } + const supabase = getAdmin() + + // Check if banned + const { data: piUser } = await supabase.from("pi_users").select("pi_uid").eq("username", + (await supabase.from("profiles").select("display_name").eq("id", userId).single()).data?.display_name + ).maybeSingle() + + if (piUser) { + const { data: banned } = await supabase.from("banned_users").select("id").eq("pi_uid", piUser.pi_uid).maybeSingle() + if (banned) return NextResponse.json({ error: "Utente bannato" }, { status: 403 }) + } + + const insertData: Record = { content: content?.trim() || "", user_id: userId } + if (replyTo) insertData.reply_to = replyTo + if (imageUrl) insertData.image_url = imageUrl + if (audioUrl) insertData.audio_url = audioUrl + + const { error } = await supabase.from("messages").insert(insertData) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} + +export async function DELETE(req: Request) { + try { + const { messageId, adminUsername } = await req.json() + if (adminUsername !== "cipollas") return NextResponse.json({ error: "Non autorizzato" }, { status: 403 }) + + const supabase = getAdmin() + await supabase.from("messages").delete().eq("id", messageId) + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} diff --git a/app/api/pi/approve/route.ts b/app/api/pi/approve/route.ts new file mode 100644 index 00000000..f4e5101a --- /dev/null +++ b/app/api/pi/approve/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server" +import { getAdmin } from "@/lib/supabase/admin" + +export async function POST(req: Request) { + try { + const { paymentId, piUid, username, amount, memo } = await req.json() + + if (!process.env.PI_API_KEY) { + console.error("[v0] PI_API_KEY non configurata") + return NextResponse.json({ error: "API key non configurata" }, { status: 500 }) + } + + console.log("[v0] Approving payment:", paymentId) + const res = await fetch(`https://api.minepi.com/v2/payments/${paymentId}/approve`, { + method: "POST", + headers: { + Authorization: `Key ${process.env.PI_API_KEY}`, + "Content-Type": "application/json", + }, + }) + + const data = await res.text() + console.log("[v0] Approve response:", res.status, data) + + if (!res.ok) { + return NextResponse.json({ error: `Errore approvazione: ${data}` }, { status: res.status }) + } + + // Save donation to database with 'approved' status + const supabase = getAdmin() + try { + await supabase.from("donations").insert({ + pi_uid: piUid || "unknown", + username: username || "Anonimo", + pi_payment_id: paymentId, + amount: amount || 0, + memo: memo || "Donazione", + status: "approved", + }) + } catch { + // Table might not exist yet, continue + } + + return NextResponse.json({ success: true }) + } catch (err) { + console.error("[v0] Approve error:", err) + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} diff --git a/app/api/pi/auth/route.ts b/app/api/pi/auth/route.ts new file mode 100644 index 00000000..b629664d --- /dev/null +++ b/app/api/pi/auth/route.ts @@ -0,0 +1,126 @@ +import { NextResponse } from "next/server" +import { getAdmin } from "@/lib/supabase/admin" + +const PI_API_KEY = process.env.PI_API_KEY! +const ADMIN_USERNAME = "cipollas" + +export async function POST(req: Request) { + try { + const body = await req.json() + const { accessToken, user: piUser } = body + + if (!accessToken || !piUser?.uid) { + return NextResponse.json({ error: "Dati mancanti" }, { status: 400 }) + } + + // Verify with Pi Network + const piRes = await fetch("https://api.minepi.com/v2/me", { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!piRes.ok) { + return NextResponse.json({ error: "Token Pi non valido" }, { status: 401 }) + } + const piData = await piRes.json() + + const supabase = getAdmin() + const username = piData.username || piUser.uid + const isAdmin = username === ADMIN_USERNAME + + // Log ALL access attempts (before any checks) so admin can see everyone who tries to enter + await supabase.from("access_logs").insert({ + user_id: piUser.uid, + username, + }) + + // Admin bypasses all checks + if (!isAdmin) { + // Check KYC status from multiple possible locations + const credentials = piData.credentials || piUser.credentials || {} + + // KYC can be in different formats depending on Pi SDK version + const kycStatus = credentials.kyc_verification_status || + credentials.kyc_status || + piUser.kyc_verification_status + + const kycVerified = kycStatus === "approved" || + kycStatus === "provisional" || + kycStatus === "APPROVED" || + kycStatus === "PROVISIONAL" || + piUser.kyc_verified === true || + credentials.kyc_verified === true + + // Check migration status + const hasMigrated = credentials.has_migrated === true || + piUser.has_migrated === true || + credentials.migration_status === "completed" + + // Only block if we have explicit negative data + if (kycStatus && !kycVerified) { + return NextResponse.json({ + error: "KYC non verificato. Devi avere il KYC approvato o provvisorio per accedere." + }, { status: 403 }) + } + + // Only check migration if KYC status was provided + if (kycStatus && credentials.has_migrated === false) { + return NextResponse.json({ + error: "Migrazione non completata. Devi completare la prima migrazione per accedere." + }, { status: 403 }) + } + } + + // Check if banned + const { data: banned } = await supabase + .from("banned_users") + .select("id") + .eq("pi_uid", piUser.uid) + .maybeSingle() + + if (banned) { + return NextResponse.json({ error: "Utente bannato dalla chat" }, { status: 403 }) + } + + // Upsert pi_users + await supabase.from("pi_users").upsert({ + pi_uid: piUser.uid, + username, + access_token: accessToken, + is_admin: isAdmin, + }, { onConflict: "pi_uid" }) + + // Upsert auth user + profile + const email = `${piUser.uid}@pi.user` + const { data: authData } = await supabase.auth.admin.listUsers() + let userId: string + + const existing = authData?.users?.find((u) => u.email === email) + if (existing) { + userId = existing.id + } else { + const { data: newUser, error } = await supabase.auth.admin.createUser({ + email, + password: piUser.uid + "_pi_secret_2024", + email_confirm: true, + }) + if (error || !newUser.user) { + return NextResponse.json({ error: "Errore creazione utente" }, { status: 500 }) + } + userId = newUser.user.id + } + + // Upsert profile + await supabase.from("profiles").upsert({ + id: userId, + display_name: username, + }, { onConflict: "id" }) + + return NextResponse.json({ + userId, + username, + piUid: piUser.uid, + isAdmin, + }) + } catch { + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} diff --git a/app/api/pi/complete/route.ts b/app/api/pi/complete/route.ts new file mode 100644 index 00000000..595171ac --- /dev/null +++ b/app/api/pi/complete/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server" +import { getAdmin } from "@/lib/supabase/admin" + +export async function POST(req: Request) { + try { + const { paymentId, txid } = await req.json() + + // Complete with Pi Network + if (!process.env.PI_API_KEY) { + console.error("[v0] PI_API_KEY non configurata") + return NextResponse.json({ error: "API key non configurata" }, { status: 500 }) + } + console.log("[v0] Completing payment:", paymentId, "txid:", txid) + const res = await fetch(`https://api.minepi.com/v2/payments/${paymentId}/complete`, { + method: "POST", + headers: { + Authorization: `Key ${process.env.PI_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ txid }), + }) + if (!res.ok) return NextResponse.json({ error: "Errore completamento" }, { status: 500 }) + + // Update donation status to completed + const supabase = getAdmin() + try { + await supabase + .from("donations") + .update({ + status: "completed", + tx_id: txid, + completed_at: new Date().toISOString() + }) + .eq("pi_payment_id", paymentId) + } catch { + // Table might not exist yet, continue + } + + const data = await res.text() + console.log("[v0] Complete response:", res.status, data) + if (!res.ok) return NextResponse.json({ error: `Errore completamento: ${data}` }, { status: res.status }) + return NextResponse.json({ success: true }) + } catch (err) { + console.error("[v0] Complete error:", err) + return NextResponse.json({ error: "Errore del server" }, { status: 500 }) + } +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 00000000..d0ddcaca --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,37 @@ +import { put } from "@vercel/blob" +import { type NextRequest, NextResponse } from "next/server" + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData() + const file = formData.get("file") as File + + if (!file) { + return NextResponse.json({ error: "Nessun file fornito" }, { status: 400 }) + } + + // Check file type + const isImage = file.type.startsWith("image/") + const isAudio = file.type.startsWith("audio/") + + if (!isImage && !isAudio) { + return NextResponse.json({ error: "Solo immagini e audio sono permessi" }, { status: 400 }) + } + + // Validate file size + const maxSize = isAudio ? 10 * 1024 * 1024 : 5 * 1024 * 1024 + if (file.size > maxSize) { + return NextResponse.json({ error: `File troppo grande (max ${isAudio ? "10MB" : "5MB"})` }, { status: 400 }) + } + + const folder = isAudio ? "chat-audio" : "chat-images" + const blob = await put(`${folder}/${Date.now()}-${file.name}`, file, { + access: "public", + }) + + return NextResponse.json({ url: blob.url }) + } catch (error) { + console.error("Upload error:", error) + return NextResponse.json({ error: "Errore upload" }, { status: 500 }) + } +} diff --git a/app/auth/actions.ts b/app/auth/actions.ts new file mode 100644 index 00000000..7ab614d5 --- /dev/null +++ b/app/auth/actions.ts @@ -0,0 +1,7 @@ +"use server" + +import { redirect } from "next/navigation" + +export async function signOutAction() { + redirect("/auth/login") +} diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx new file mode 100644 index 00000000..89f862c0 --- /dev/null +++ b/app/auth/login/page.tsx @@ -0,0 +1,133 @@ +"use client" + +import { useEffect, useState, useRef } from "react" + +type PiSDK = { + init: (config: { version: string; sandbox: boolean }) => void + authenticate: (scopes: string[], onIncomplete: () => void) => Promise<{ accessToken: string; user: { uid: string } }> +} + +function LoginForm() { + const [loading, setLoading] = useState(false) + const [status, setStatus] = useState("") + const [error, setError] = useState("") + const [sdkReady, setSdkReady] = useState(false) + const piRef = useRef(null) + + useEffect(() => { + const session = localStorage.getItem("pi_session") + if (session) window.location.href = "/chat" + + // Initialize Pi SDK on mount + function initPiSDK() { + const Pi = (window as unknown as Record).Pi as PiSDK | undefined + if (Pi) { + try { + Pi.init({ version: "2.0", sandbox: false }) + piRef.current = Pi + setSdkReady(true) + } catch { + // SDK might already be initialized, that's ok + piRef.current = Pi + setSdkReady(true) + } + } else { + // SDK not loaded yet, retry in 500ms + setTimeout(initPiSDK, 500) + } + } + initPiSDK() + }, []) + + async function handlePiLogin() { + const Pi = piRef.current + + if (!Pi) { + setError("Pi SDK non disponibile. Apri nel Pi Browser.") + return + } + + setLoading(true) + setError("") + setStatus("Connessione a Pi Network...") + + try { + const auth = await Pi.authenticate(["username", "payments", "wallet_address"], () => {}) + setStatus("Verifica credenziali...") + + const res = await fetch("/api/pi/auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accessToken: auth.accessToken, user: auth.user }), + }) + + const data = await res.json() + if (!res.ok) { + setError(data.error || "Errore di autenticazione") + setLoading(false) + return + } + + setStatus("Accesso riuscito! Reindirizzamento...") + localStorage.setItem("pi_session", JSON.stringify({ + userId: data.userId, + username: data.username, + piUid: data.piUid, + isAdmin: data.isAdmin, + })) + + await new Promise((r) => setTimeout(r, 300)) + window.location.href = "/chat" + } catch { + setError("Errore durante il login. Riprova.") + setLoading(false) + } + } + + return ( +
+
+ Pi +
+ +

Chat Pionieri

+

+ Chat esclusiva per Pionieri verificati con KYC approvato o provvisorio e prima migrazione completata +

+ + + + {error &&

{error}

} + +
+

Requisiti di accesso:

+
    +
  • + KYC approvato o provvisorio su Pi Network +
  • +
  • + Prima migrazione al Mainnet completata +
  • +
  • + Accesso tramite Pi Browser +
  • +
+
+ +
+ Privacy Policy + Terms of Service +
+
+ ) +} + +export default function LoginPage() { + return +} diff --git a/app/chat/accessi/page.tsx b/app/chat/accessi/page.tsx new file mode 100644 index 00000000..fc6e721f --- /dev/null +++ b/app/chat/accessi/page.tsx @@ -0,0 +1,146 @@ +"use client" + +import { useEffect, useState } from "react" + +interface AccessLog { + id: string + user_id: string + username: string + logged_at: string +} + +interface AccessData { + logs: AccessLog[] + totalAccesses: number + uniqueUsers: number +} + +export default function AccessiPage() { + const [data, setData] = useState(null) + const [selectedDate, setSelectedDate] = useState(() => { + return new Date().toISOString().split("T")[0] + }) + const [isAdmin, setIsAdmin] = useState(false) + const [adminUsername, setAdminUsername] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + // Check if user is admin + const session = localStorage.getItem("pi_session") + if (session) { + try { + const parsed = JSON.parse(session) + setIsAdmin(parsed.isAdmin === true) + if (parsed.isAdmin === true) { + setAdminUsername(parsed.username) + } + } catch { + setIsAdmin(false) + } + } + }, []) + + useEffect(() => { + async function loadLogs() { + if (!adminUsername) return + setLoading(true) + try { + const res = await fetch(`/api/admin/access-logs?date=${selectedDate}&adminUsername=${encodeURIComponent(adminUsername)}`) + if (res.ok) { + const result = await res.json() + setData(result) + } + } catch { + // Handle error + } finally { + setLoading(false) + } + } + loadLogs() + }, [selectedDate, adminUsername]) + + function formatTime(dateStr: string) { + const date = new Date(dateStr) + return date.toLocaleTimeString("it-IT", { hour: "2-digit", minute: "2-digit" }) + } + + function formatDate(dateStr: string) { + const date = new Date(dateStr) + return date.toLocaleDateString("it-IT", { day: "numeric", month: "short", year: "numeric" }) + } + + if (!isAdmin) { + return ( +
+

Accesso riservato agli amministratori

+ Torna alla chat +
+ ) + } + + return ( +
+
+
+

Registro Accessi

+

Solo admin

+
+ + Indietro + +
+ +
+ {/* Date selector */} +
+ + setSelectedDate(e.target.value)} + className="rounded-lg border border-border bg-card px-3 py-2 text-foreground" + /> +
+ + {/* Stats */} + {data && ( +
+
+

{data.totalAccesses}

+

Accessi totali

+
+
+

{data.uniqueUsers}

+

Utenti unici

+
+
+ )} + + {/* Logs list */} +
+
+

Accessi del {formatDate(selectedDate)}

+
+ + {loading ? ( +
Caricamento...
+ ) : data?.logs.length === 0 ? ( +
Nessun accesso registrato
+ ) : ( +
+ {data?.logs.map((log) => ( +
+
+

{log.username}

+

ID: {log.user_id.substring(0, 8)}...

+
+

{formatTime(log.logged_at)}

+
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/app/chat/admin/page.tsx b/app/chat/admin/page.tsx new file mode 100644 index 00000000..902c79a2 --- /dev/null +++ b/app/chat/admin/page.tsx @@ -0,0 +1,141 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" + +interface AccessLog { + id: string + user_id: string + username: string + logged_at: string +} + +interface LogsData { + logs: AccessLog[] + totalAccesses: number + uniqueUsers: number + date: string +} + +export default function AdminPage() { + const router = useRouter() + const [logsData, setLogsData] = useState(null) + const [loading, setLoading] = useState(true) + const [selectedDate, setSelectedDate] = useState(() => new Date().toISOString().split("T")[0]) + const [username, setUsername] = useState("") + const [isAdmin, setIsAdmin] = useState(false) + + useEffect(() => { + const session = localStorage.getItem("pi_session") + if (!session) { + router.push("/auth/login") + return + } + const parsed = JSON.parse(session) + if (parsed.username !== "cipollas") { + router.push("/chat") + return + } + setUsername(parsed.username) + setIsAdmin(true) + }, [router]) + + useEffect(() => { + if (!isAdmin || !username) return + + async function loadLogs() { + setLoading(true) + const res = await fetch(`/api/admin/access-logs?adminUsername=${username}&date=${selectedDate}`) + if (res.ok) { + const data = await res.json() + setLogsData(data) + } + setLoading(false) + } + loadLogs() + }, [isAdmin, username, selectedDate]) + + if (!isAdmin) { + return ( +
+

Verifica accesso...

+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Registro Accessi

+

Solo admin

+
+ +
+ +
+ {/* Date picker */} +
+ + setSelectedDate(e.target.value)} + className="w-full max-w-xs rounded-lg border border-border bg-background px-3 py-2 text-base text-foreground" + /> +
+ + {/* Stats */} + {logsData && ( +
+
+

{logsData.totalAccesses}

+

Accessi totali

+
+
+

{logsData.uniqueUsers}

+

Utenti unici

+
+
+ )} + + {/* Logs list */} +
+
+

Accessi del {selectedDate}

+
+
+ {loading ? ( +
Caricamento...
+ ) : logsData?.logs.length === 0 ? ( +
Nessun accesso in questa data
+ ) : ( +
    + {logsData?.logs.map((log) => ( +
  • +
    +

    {log.username}

    +

    ID: {log.user_id.slice(0, 8)}...

    +
    +

    + {new Date(log.logged_at).toLocaleTimeString("it-IT", { + hour: "2-digit", + minute: "2-digit", + })} +

    +
  • + ))} +
+ )} +
+
+
+
+ ) +} diff --git a/app/chat/page.tsx b/app/chat/page.tsx new file mode 100644 index 00000000..b0d0f8bd --- /dev/null +++ b/app/chat/page.tsx @@ -0,0 +1,41 @@ +"use client" + +import { useEffect, useState } from "react" +import { ChatRoom } from "@/components/chat-room" + +interface Session { + userId: string + username: string + piUid: string + isAdmin: boolean +} + +export default function ChatPage() { + const [session, setSession] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const raw = localStorage.getItem("pi_session") + if (!raw) { + window.location.href = "/auth/login" + return + } + try { + setSession(JSON.parse(raw)) + } catch { + localStorage.removeItem("pi_session") + window.location.href = "/auth/login" + } + setLoading(false) + }, []) + + if (loading || !session) { + return ( +
+
+
+ ) + } + + return +} diff --git a/app/chat/payment/page.tsx b/app/chat/payment/page.tsx new file mode 100644 index 00000000..8efc736f --- /dev/null +++ b/app/chat/payment/page.tsx @@ -0,0 +1,195 @@ +"use client" + +import { useState, useEffect, useRef } from "react" + +type PiSDK = { + init: (config: { version: string; sandbox: boolean }) => void + authenticate: (scopes: string[], onIncomplete: () => void) => Promise<{ user?: { uid?: string; username?: string } }> + createPayment: (data: Record, callbacks: Record) => void +} + +const QUICK_AMOUNTS = [0.1, 0.5, 1, 5, 10] + +export default function PaymentPage() { + const [status, setStatus] = useState<"idle" | "processing" | "success" | "error">("idle") + const [errorMsg, setErrorMsg] = useState("") + const [amount, setAmount] = useState("") + const [sdkReady, setSdkReady] = useState(false) + const piRef = useRef(null) + + useEffect(() => { + // Initialize Pi SDK on mount + function initPiSDK() { + const Pi = (window as unknown as Record).Pi as PiSDK | undefined + if (Pi) { + try { + Pi.init({ version: "2.0", sandbox: false }) + piRef.current = Pi + setSdkReady(true) + } catch { + // SDK might already be initialized, that's ok + piRef.current = Pi + setSdkReady(true) + } + } else { + // SDK not loaded yet, retry in 500ms + setTimeout(initPiSDK, 500) + } + } + initPiSDK() + }, []) + + function selectQuickAmount(value: number) { + setAmount(value.toString()) + } + + async function handlePayment() { + const parsedAmount = parseFloat(amount) + if (!amount || isNaN(parsedAmount) || parsedAmount <= 0) { + setErrorMsg("Inserisci un importo valido maggiore di 0") + setStatus("error") + return + } + + const Pi = piRef.current + + if (!Pi) { + setErrorMsg("Pi SDK non disponibile. Apri nel Pi Browser.") + setStatus("error") + return + } + + setStatus("processing") + setErrorMsg("") + + try { + const authResult = await Pi.authenticate(["payments", "username"], () => {}) + const piUid = authResult?.user?.uid || "unknown" + const username = authResult?.user?.username || "Anonimo" + const memo = `Donazione ${parsedAmount} Pi - Chat Pionieri` + + Pi.createPayment( + { amount: parsedAmount, memo, metadata: { purpose: "donation" } }, + { + onReadyForServerApproval: async (paymentId: string) => { + const res = await fetch("/api/pi/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ paymentId, piUid, username, amount: parsedAmount, memo }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + console.error("Approve error:", data) + setErrorMsg(data.error || "Errore approvazione pagamento") + setStatus("error") + } + }, + onReadyForServerCompletion: async (paymentId: string, txid: string) => { + const res = await fetch("/api/pi/complete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ paymentId, txid }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + console.error("Complete error:", data) + setErrorMsg(data.error || "Errore completamento pagamento") + setStatus("error") + return + } + setStatus("success") + }, + onCancel: () => setStatus("idle"), + onError: (err: Error) => { + setErrorMsg(err?.message || "Errore durante il pagamento") + setStatus("error") + }, + } + ) + } catch (err: unknown) { + setErrorMsg(err instanceof Error ? err.message : "Errore sconosciuto") + setStatus("error") + } + } + + return ( +
+
+ Indietro +

Donazione Pi

+
+ +
+
+ Pi +
+

Aiuta o supporta l'app con una donazione

+

+ Scegli un contributo per il supporto e aggiornamento dell'app, non รจ obbligatorio il supporto o aiuto. +

+ + {/* Quick amount buttons */} +
+ {QUICK_AMOUNTS.map((val) => ( + + ))} +
+ + {/* Custom amount input */} +
+ +
+ setAmount(e.target.value)} + className="w-full bg-transparent text-lg font-bold text-foreground outline-none" + style={{ fontSize: "18px" }} + /> + Pi +
+
+ + + + {errorMsg &&

{errorMsg}

} + {status === "success" && ( +
+

Donazione completata con successo!

+ +
+ )} +
+
+ ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 00000000..2db1d749 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,31 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 40 33% 95%; + --foreground: 0 0% 5%; + --card: 40 30% 98%; + --card-foreground: 0 0% 5%; + --primary: 36 100% 50%; + --primary-foreground: 0 0% 100%; + --secondary: 40 20% 92%; + --secondary-foreground: 0 0% 5%; + --muted: 40 15% 90%; + --muted-foreground: 0 0% 40%; + --accent: 36 100% 50%; + --accent-foreground: 0 0% 100%; + --destructive: 0 84% 60%; + --destructive-foreground: 0 0% 98%; + --border: 40 15% 85%; + --input: 40 15% 85%; + --ring: 36 100% 50%; + --radius: 0.75rem; + } +} + +@layer base { + * { @apply border-border; } + body { @apply bg-background text-foreground; } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 00000000..4692c878 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,31 @@ +import type { Metadata, Viewport } from "next" +import { Inter } from "next/font/google" +import { SpeedInsights } from "@vercel/speed-insights/next" +import "./globals.css" + +const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }) + +export const metadata: Metadata = { + title: "Chat Pionieri - Pi Network", + description: "Chat esclusiva per Pionieri verificati Pi Network", +} + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +