diff --git a/src/app/api/tokens/claim/route.ts b/src/app/api/tokens/claim/route.ts new file mode 100644 index 0000000..27018a3 --- /dev/null +++ b/src/app/api/tokens/claim/route.ts @@ -0,0 +1,109 @@ +// src/app/api/tokens/claim/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { getSessionFromCookie, getUserFromHeaders } from '../../../../lib/auth' +import { getUserPoints } from '../../../../services/userPointsService' +import { awardTokensForSighting } from '../../../../services/blockchain/tokenRewardService' +import { isWalletConnected, getWalletAddress } from '../../../../services/blockchain/seiService' + +export async function POST(req: NextRequest): Promise { + try { + // Log request info + console.log('Token Claim API Request:', { + path: req.url, + method: req.method, + headers: Object.fromEntries(req.headers.entries()), + hasCookies: req.headers.has('cookie') + }); + + // Get user from session + const session = await getSessionFromCookie(req); + const headerUser = getUserFromHeaders(req); + + // Check authentication - require session OR header authentication + const isAuthenticated = !!session || !!headerUser; + + if (!isAuthenticated) { + console.log('Unauthorized access attempt to token claim API'); + return NextResponse.json( + { success: false, error: 'Unauthorized. Please sign in to claim tokens.' }, + { status: 401 } + ); + } + + // Get user info + const userId = session?.user?.id || headerUser?.id || ''; + const userEmail = session?.user?.email || headerUser?.email || ''; + + // Log user identification + console.log('User identification for token claim:', { userId, userEmail }); + + // Check if wallet is connected (client should check this too, but double-check) + if (!req.headers.get('x-wallet-address')) { + return NextResponse.json( + { success: false, error: 'No wallet address provided. Please connect your wallet first.' }, + { status: 400 } + ); + } + + const walletAddress = req.headers.get('x-wallet-address'); + + // Get user's points + const userPoints = await getUserPoints(userId || userEmail); + console.log('User points:', userPoints); + + // Check if user has points to claim + if (!userPoints || userPoints < 1) { + return NextResponse.json( + { success: false, error: 'You need at least 1 point to claim tokens.' }, + { status: 400 } + ); + } + + // Parse the request body + const requestData = await req.json(); + console.log('Request data:', requestData); + + // Get species + const species = requestData.species || 'animal'; + + // Award tokens based on points (1 point = 1 token) + try { + // Award tokens through blockchain service + const rewardResult = await awardTokensForSighting(species, requestData.points); + + if (rewardResult.success) { + return NextResponse.json({ + success: true, + message: `Successfully claimed WILD tokens for your wildlife points!`, + tokenAmount: rewardResult.tokenAmount, + txHash: rewardResult.txHash + }); + } else { + throw new Error(rewardResult.error || 'Failed to award tokens'); + } + } catch (err) { + console.error('Error awarding tokens:', err); + return NextResponse.json( + { + success: false, + error: err instanceof Error ? err.message : 'Error processing token reward' + }, + { status: 500 } + ); + } + } catch (error) { + console.error('Token claim failed:', { + errorName: error instanceof Error ? error.name : 'Unknown Error', + errorMessage: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : 'No stack trace' + }); + + return NextResponse.json( + { success: false, error: 'Failed to claim tokens' }, + { status: 500 } + ); + } +} + +// Ensure dynamic routing +export const dynamic = 'force-dynamic' \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index b625681..1c904bf 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,19 +1,51 @@ // src/app/page.tsx 'use client' -import React from 'react' +import React, { useState } from 'react' import { useSession } from 'next-auth/react' import ImageAnalysisCard from '../components/ImageAnalysisCard' import Navigation from '../components/Navigation' +import { Coins, Leaf } from 'lucide-react' import { analyzeImage, VisionAnalysisResult, VISION_CONFIG } from '../services/googleVisionService' import { APP_CONFIG, getThemeColors } from '../config/appConfig' import { normalizeImageUpload } from '../services/imageService' +import { isWalletConnected, getWalletAddress } from '../services/blockchain/seiService' +import { getUserPoints } from '../services/userPointsService' const Page: React.FC = (): React.ReactElement => { // Get auth session const { data: session, status } = useSession() const isAuthenticated = status === 'authenticated' && !!session + // State for user points and token claim + const [userPoints, setUserPoints] = useState(null) + const [isClaimingTokens, setIsClaimingTokens] = useState(false) + const [claimResult, setClaimResult] = useState<{ + success: boolean; + message: string; + tokenAmount?: number; + txHash?: string; + } | null>(null) + + // Effect to fetch user points + React.useEffect(() => { + const fetchUserPoints = async () => { + if (isAuthenticated && session?.user) { + const userId = session.user.id || session.user.email || ''; + if (userId) { + try { + const points = await getUserPoints(userId); + setUserPoints(points); + } catch (error) { + console.error('Error fetching user points:', error); + } + } + } + }; + + fetchUserPoints(); + }, [isAuthenticated, session]); + // Log session details for debugging React.useEffect(() => { if (session) { @@ -54,6 +86,17 @@ const Page: React.FC = (): React.ReactElement => { // Analyze the normalized image with session data const result = await analyzeImage(buffer, normalizedImage.name, sessionData) + + // Refresh points after successful analysis + if (isAuthenticated && session?.user && result.isAnimal) { + try { + const updatedPoints = await getUserPoints(session.user.id || session.user.email || ''); + setUserPoints(updatedPoints); + } catch (error) { + console.error('Error updating user points:', error); + } + } + return result } catch (error) { console.error('Image analysis error:', error) @@ -61,6 +104,69 @@ const Page: React.FC = (): React.ReactElement => { } } } + + // Handle token claim + const handleClaimTokens = async () => { + if (!isAuthenticated || !session?.user) { + console.error('User not authenticated'); + return; + } + + if (!isWalletConnected()) { + console.error('Wallet not connected'); + return; + } + + if (!userPoints || userPoints < 1) { + console.error('No points to claim'); + return; + } + + setIsClaimingTokens(true); + setClaimResult(null); + + try { + // Call our new token claim API + const response = await fetch('/api/tokens/claim', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-wallet-address': getWalletAddress() || '' + }, + body: JSON.stringify({ + points: userPoints, + species: 'wildlife' // Generic species for point-based claims + }) + }); + + const result = await response.json(); + + if (result.success) { + setClaimResult({ + success: true, + message: 'Successfully claimed WILD tokens!', + tokenAmount: result.tokenAmount, + txHash: result.txHash + }); + + // Reset points to 0 after successful claim + setUserPoints(0); + } else { + setClaimResult({ + success: false, + message: result.error || 'Failed to claim tokens' + }); + } + } catch (error) { + console.error('Error claiming tokens:', error); + setClaimResult({ + success: false, + message: error instanceof Error ? error.message : 'Error claiming tokens' + }); + } finally { + setIsClaimingTokens(false); + } + }; // Auth-locked content const renderAuthContent = () => { @@ -71,11 +177,99 @@ const Page: React.FC = (): React.ReactElement => {
+ {/* Wildlife identification card */} + + {/* Points & token claim section */} + {userPoints !== null && ( +
+
+
+ +

+ Earn WILD Tokens +

+
+

+ Convert your wildlife identification points to WILD tokens on the Sei blockchain +

+
+ +
+ {/* Points display */} +
+ {userPoints} + Available Points +
+ + {/* Token claim button */} +
+ +
+
+ + {/* Wallet connection notice */} + {!isWalletConnected() && ( +
+ Connect your wallet using the button in the navigation bar to claim WILD tokens +
+ )} + + {/* Claim result message */} + {claimResult && ( +
+

{claimResult.message}

+ + {/* Token reward details */} + {claimResult.success && claimResult.tokenAmount && ( +
+ +
+

+ {claimResult.tokenAmount} WILD tokens claimed! +

+ {claimResult.txHash && ( +

+ Transaction: + {claimResult.txHash.substring(0, 10)}... + +

+ )} +
+
+ )} +
+ )} +
+ )}
) } @@ -85,7 +279,7 @@ const Page: React.FC = (): React.ReactElement => {

Sign in to Document Wildlife

- You need to sign in with your account to access wildlife documentation features. + You need to sign in with your account to access wildlife documentation features and earn rewards.

{ {APP_CONFIG.title}

- Document sightings of {APP_CONFIG.description} in {APP_CONFIG.location} + Document sightings of {APP_CONFIG.description} in {APP_CONFIG.location} and earn rewards

-
{/* Render content based on authentication status */} @@ -118,7 +311,7 @@ const Page: React.FC = (): React.ReactElement => { {/* Info Section */}

- Built by Mito Systems - Powered by Laconic + Built by Mito Systems - Powered by Laconic and Sei Network

diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 961ad52..34ce3a6 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -16,7 +16,6 @@ const Navigation = () => { const baseLinks = [ { href: '/', label: 'Home' }, { href: '/sightings', label: 'Sightings' }, - { href: '/earn', label: 'Earn Tokens' }, { href: '/about', label: 'About' }, ] diff --git a/src/services/blockchain/tokenRewardService.ts b/src/services/blockchain/tokenRewardService.ts index 23ecb58..e94df4d 100644 --- a/src/services/blockchain/tokenRewardService.ts +++ b/src/services/blockchain/tokenRewardService.ts @@ -39,9 +39,10 @@ export const isRareSpecies = (species: string): boolean => { /** * Award tokens for a wildlife sighting if wallet is connected * @param species The detected animal species + * @param points Optional number of points to convert to tokens (if not provided, calculates based on species) * @returns Object containing success status and transaction info */ -export const awardTokensForSighting = async (species: string): Promise<{ +export const awardTokensForSighting = async (species: string, points?: number): Promise<{ success: boolean; tokenAmount?: number; txHash?: string; @@ -58,13 +59,21 @@ export const awardTokensForSighting = async (species: string): Promise<{ }; } - // Calculate token reward amount - const baseReward = TOKEN_REWARDS.WILDLIFE_SIGHTING; - const isRare = isRareSpecies(species); - const rareBonus = isRare ? TOKEN_REWARDS.RARE_SPECIES : 0; - const totalReward = baseReward + rareBonus; + // Calculate token reward amount - either from points or from species + let totalReward: number; + + if (typeof points === 'number' && points > 0) { + // If points are provided, use them directly (1 point = 1 token) + totalReward = points; + } else { + // Otherwise calculate based on species + const baseReward = TOKEN_REWARDS.WILDLIFE_SIGHTING; + const isRare = isRareSpecies(species); + const rareBonus = isRare ? TOKEN_REWARDS.RARE_SPECIES : 0; + totalReward = baseReward + rareBonus; + } - // Award WILD tokens through Sei contract based on species + // Award WILD tokens through Sei contract based on species and amount const result = await awardTokensForWildlife(species, totalReward); if (result.success) {