Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions app/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { redirect } from 'next/navigation'
import type { UserProfile } from '@/lib/api-schema'
import ProfileForm from '@/components/profile/ProfileForm'
import ContractDemo from '@/components/web3/ContractDemo'
import ContractDemo from '@/components/web3/ContractDemo'
import VipBadge from '@/components/web3/VipBadge'
import TippingDemo from '@/components/web3/TippingDemo'

const fetchProfile = async (): Promise<UserProfile> => {
const res = await fetch('/api/profile')
Expand Down Expand Up @@ -35,12 +38,23 @@ export default function ProfilePage() {
}

return (
<main className='container mx-auto max-w-2xl py-12'>
<main className='container mx-auto max-w-2xl py-12 space-y-12'>
<div>
<h1 className='text-3xl font-bold mb-8'>Edit Your Profile</h1>
<div className='flex items-center gap-4 mb-8'>
<h1 className='text-3xl font-bold'>Edit Your Profile</h1>
{/* Add the VipBadge right next to the title */}
<VipBadge />
</div>
{profile && <ProfileForm profile={profile} />}
</div>

<div>
<h2 className='text-2xl font-bold mb-4'>
Tipping Demo & FTSO Price Feed
</h2>
<TippingDemo />
</div>

<div>
<h2 className='text-2xl font-bold mb-4'>Web3 Contract Interaction</h2>
<ContractDemo />
Expand Down
50 changes: 50 additions & 0 deletions components/web3/FtsoPriceFeed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use client'

import { priceSubmitterContractSGB } from '@/lib/contracts'
import { useReadContract } from 'wagmi'
import { formatUnits } from 'viem'

interface FtsoPriceFeedProps {
symbol: 'SGB' | 'FLR' // Extendable for other tokens
}

export default function FtsoPriceFeed({ symbol }: FtsoPriceFeedProps) {
const {
data: priceData,
isLoading,
isError,
} = useReadContract({
...priceSubmitterContractSGB,
functionName: 'getPrice',
args: [symbol],
// Automatically refetch the price every 5 seconds to keep it live
query: {
refetchInterval: 5000,
},
})

if (isLoading) {
return <span className='text-gray-400 animate-pulse'>Loading price...</span>
}

if (isError || !priceData) {
return <span className='text-red-500'>Price unavailable</span>
}

// The FTSO returns the price with 5 decimal places.
// We use formatUnits to correctly place the decimal point.
const price = Number(formatUnits(priceData[0], 5))
const timestamp = new Date(Number(priceData[1]) * 1000)

return (
<div className='text-sm'>
<p>
Live Price:{' '}
<span className='font-bold text-green-500'>${price.toFixed(5)}</span>
</p>
<p className='text-xs text-gray-500'>
Last Updated: {timestamp.toLocaleTimeString()}
</p>
</div>
)
}
57 changes: 57 additions & 0 deletions components/web3/TippingDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use client'

import { priceSubmitterContractSGB } from '@/lib/contracts'
import { useState } from 'react'
import { useReadContract } from 'wagmi'
import { formatUnits } from 'viem'
import FtsoPriceFeed from './FtsoPriceFeed'

export default function TippingDemo() {
const [tipAmount, setTipAmount] = useState('1000')

// We fetch the price here as well to use in our calculation
const { data: priceData } = useReadContract({
...priceSubmitterContractSGB,
functionName: 'getPrice',
args: ['SGB'],
query: {
refetchInterval: 5000,
},
})

let usdValue = 0
if (priceData && tipAmount) {
const price = Number(formatUnits(priceData[0], 5))
usdValue = Number(tipAmount) * price
}

return (
<div className='p-6 border rounded-lg space-y-4'>
<h3 className='text-xl font-bold'>Tip an Artist (Demo)</h3>
<p className='text-sm text-gray-500'>
See the FTSO in action by calculating a tip's value in real-time.

Check failure on line 32 in components/web3/TippingDemo.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

`'` can be escaped with `&apos;`, `&lsquo;`, `&#39;`, `&rsquo;`
</p>

<div className='flex items-center gap-4'>
<input
type='number'
value={tipAmount}
onChange={(e) => setTipAmount(e.target.value)}
className='w-full p-2 rounded-md border-gray-300 shadow-sm sm:text-sm dark:bg-gray-800 dark:border-gray-600'
placeholder='SGB Amount'
/>
<span className='font-bold text-lg'>SGB</span>
</div>

<div className='p-4 bg-gray-100 dark:bg-gray-800 rounded-lg text-center'>
<p className='text-gray-500 dark:text-gray-400'>
Equivalent Value (USD)
</p>
<p className='text-3xl font-bold'>${usdValue.toFixed(2)}</p>
<div className='mt-2'>
<FtsoPriceFeed symbol='SGB' />
</div>
</div>
</div>
)
}
39 changes: 39 additions & 0 deletions components/web3/VipBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use client'

import { useTokenGatedAccess } from '@/hooks/useTokenGatedAccess'
import { proofOfFandomContract } from '@/lib/contracts'

export default function VipBadge() {
const { isLoading, hasAccess } = useTokenGatedAccess({
contract: proofOfFandomContract,
})

if (isLoading) {
return (
<div className='h-6 w-16 bg-gray-200 rounded-full animate-pulse'></div>
)
}

if (!hasAccess) {
return null // If the user doesn't have the token, render nothing
}

// If the user has access, show the exclusive badge
return (
<div className='inline-flex items-center gap-2 px-3 py-1 font-semibold text-white bg-gradient-to-r from-purple-500 to-indigo-600 rounded-full'>
<svg
xmlns='http://www.w3.org/2000/svg'
className='h-4 w-4'
viewBox='0 0 20 20'
fill='currentColor'
>
<path
fillRule='evenodd'
d='M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
clipRule='evenodd'
/>
</svg>
<span>Fandom VIP</span>
</div>
)
}
43 changes: 43 additions & 0 deletions hooks/useTokenGatedAccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use client'

import { useAccount, useReadContract } from 'wagmi'
import { Abi } from 'viem'

interface UseTokenGatedAccessProps {
contract: {
address: `0x${string}`
abi: Abi
}
requiredBalance?: bigint
}

/**
* A hook to check if the connected user has a sufficient balance of a token.
* @param {object} contract - The contract configuration { address, abi }.
* @param {bigint} [requiredBalance=1n] - The minimum balance required for access. Defaults to 1.
* @returns An object with { isLoading, hasAccess, balance }.
*/
export function useTokenGatedAccess({
contract,
requiredBalance = 1n, // Using bigint for token amounts
}: UseTokenGatedAccessProps) {
const { address, isConnected } = useAccount()

const { data: balance, isLoading } = useReadContract({
...contract,
functionName: 'balanceOf',
args: [address!],
query: {
enabled: isConnected && !!address,
},
})

// Determine if the user has access
const hasAccess = balance !== undefined && balance >= requiredBalance

return {
isLoading,
hasAccess,
balance,
}
}
26 changes: 26 additions & 0 deletions lib/abi/PriceSubmitter.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[
{
"inputs": [
{
"internalType": "string",
"name": "_symbol",
"type": "string"
}
],
"name": "getPrice",
"outputs": [
{
"internalType": "uint256",
"name": "_price",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "_timestamp",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
}
]
11 changes: 11 additions & 0 deletions lib/abi/contracts.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import proofOfFandomAbi from './abi/ProofOfFandom.json'
import priceSubmitterAbi from './PriceSubmitter.json' // Import the new ABI
import { flare, songbird } from './chains'

const proofOfFandomAddress =
'0x1234567890123456789012345678901234567890' as const

const priceSubmitterAddressSGB =
'0x1000000000000000000000000000000000000003' as const

export const proofOfFandomContract = {
address: proofOfFandomAddress,
abi: proofOfFandomAbi,

chains: [flare.id, songbird.id],
}

export const priceSubmitterContractSGB = {
address: priceSubmitterAddressSGB,
abi: priceSubmitterAbi,
// This contract is on the Songbird chain
chains: [songbird.id],
}
Loading