Building a Perk Claim Frontend
Complete guide for building a consumer-facing frontend where users can discover and claim brand perks using LTZ tokens.
Overview
A Perk Claim Frontend is a consumer-facing web application that allows users to:
- 🔍 Discover available perk collections from brands
- 💰 View their LTZ token balance
- 🎁 Claim perk NFTs using LTZ tokens
- 📱 Experience gasless transactions (no ETH required)
This is separate from the Partner Dashboard - it's the consumer marketplace where end users interact with perks.
Architecture Overview
Core Requirements
1. Authentication (Privy)
Required: Privy SDK for user authentication and embedded wallet creation.
Why:
- Users need wallets to hold LTZ tokens and receive perk NFTs
- Privy provides embedded wallets (no Web3 knowledge required)
- Gas Relayer requires Privy access tokens for authentication
Setup:
import { PrivyProvider } from '@privy-io/react-auth';
<PrivyProvider
appId={process.env.VITE_PRIVY_APP_ID}
config={{
loginMethods: ['email', 'google', 'apple'],
embeddedWallets: {
createOnLogin: 'users-without-wallets',
},
defaultChain: {
id: 1868, // Soneium Mainnet
name: 'Soneium Mainnet',
network: 'soneium',
rpcUrls: {
default: { http: ['https://rpc.soneium.org'] },
},
blockExplorers: {
default: { url: 'https://soneium.blockscout.com' },
},
},
}}
>
<YourApp />
</PrivyProvider>
Get Privy App ID: Contact [email protected] or create your own at dashboard.privy.io
2. Gas Relayer Integration
Required: Gas Relayer API for gasless transactions.
Why:
- Users don't need ETH for gas fees
- Your brand pays all transaction costs
- Enables seamless claiming experience
Base URL: https://relayer.loyalteez.app
Endpoint: POST /relay
Authentication: Privy Bearer token (from getAccessToken())
Example Integration:
import { usePrivy } from '@privy-io/react-auth';
import { ethers } from 'ethers';
async function claimPerkViaRelayer(
collectionId: bigint,
userAddress: string
) {
const { getAccessToken } = usePrivy();
// 1. Get Privy access token
const token = await getAccessToken();
// 2. Encode claimFor function call
const perkNFT = new ethers.Interface([
'function claimFor(uint256 collectionId, address beneficiary)'
]);
const data = perkNFT.encodeFunctionData('claimFor', [
collectionId,
userAddress
]);
// 3. Call gas relayer
const response = await fetch('https://relayer.loyalteez.app/relay', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
to: '0x6ae30d6Dcf3e75456B6582b057f1Bf98A90F2CA0', // PerkNFT address
data: data,
userAddress: userAddress,
gasLimit: 500000,
}),
});
const result = await response.json();
return result.transactionHash;
}
See Also: Gas Relayer API Reference
3. PerkNFT Contract Integration
Contract Address: 0x6ae30d6Dcf3e75456B6582b057f1Bf98A90F2CA0 (Soneium Mainnet)
Key Functions:
Query Collections
// Get total number of collections
const totalCollections = await perkNFTContract.nextCollectionId();
// Get collection details
const collection = await perkNFTContract.collections(collectionId);
// Returns: {
// creator: address,
// priceInPoints: uint256,
// maxSupply: uint256,
// currentSupply: uint256,
// isActive: bool,
// maxPerUser: uint256,
// metadataHash: string
// }
Check User Balance
// Get user's LTZ balance
const ltzContract = new ethers.Contract(
'0x5242b6DB88A72752ac5a54cFe6A7DB8244d743c9', // Loyalteez Token
['function balanceOf(address) view returns (uint256)'],
provider
);
const balance = await ltzContract.balanceOf(userAddress);
Claim Perk (via Gas Relayer)
// Use claimFor to allow gas relayer to claim on behalf of user
const data = perkNFTContract.interface.encodeFunctionData('claimFor', [
collectionId,
userAddress
]);
// Send to gas relayer (see above)
Contract ABI: Available in contracts repository
4. EIP-2612 Permit Integration (Optional but Recommended)
Why: Enables fully gasless approvals (no approval transaction needed).
How It Works:
- User signs permit message (off-chain, no gas)
- Gas relayer executes
permit()+claimFor()in one transaction - User never needs ETH
Example:
import { ethers } from 'ethers';
async function generatePermitSignature(
provider: ethers.BrowserProvider,
userAddress: string,
amount: bigint
) {
const ltzContract = new ethers.Contract(
'0x5242b6DB88A72752ac5a54cFe6A7DB8244d743c9',
[
'function name() view returns (string)',
'function nonces(address) view returns (uint256)',
'function DOMAIN_SEPARATOR() view returns (bytes32)',
],
provider
);
// Get contract details
const [name, nonce, chainId] = await Promise.all([
ltzContract.name(),
ltzContract.nonces(userAddress),
provider.getNetwork().then(n => n.chainId),
]);
// Build EIP-712 domain
const domain = {
name,
version: '1',
chainId: Number(chainId),
verifyingContract: '0x5242b6DB88A72752ac5a54cFe6A7DB8244d743c9',
};
// Build permit message
const types = {
Permit: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
};
const message = {
owner: userAddress,
spender: '0x6ae30d6Dcf3e75456B6582b057f1Bf98A90F2CA0', // PerkNFT
value: amount,
nonce,
deadline: Math.floor(Date.now() / 1000) + 3600, // 1 hour
};
// Request signature
const signer = await provider.getSigner(userAddress);
const signature = await signer.signTypedData(domain, types, message);
// Split signature
const sig = ethers.Signature.from(signature);
return {
v: sig.v,
r: sig.r,
s: sig.s,
deadline: message.deadline,
};
}
// Include permit in gas relayer request
const permit = await generatePermitSignature(provider, userAddress, priceInPoints);
const response = await fetch('https://relayer.loyalteez.app/relay', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
to: '0x6ae30d6Dcf3e75456B6582b057f1Bf98A90F2CA0',
data: claimData,
userAddress: userAddress,
metadata: {
permit: {
owner: userAddress,
spender: '0x6ae30d6Dcf3e75456B6582b057f1Bf98A90F2CA0',
value: priceInPoints.toString(),
deadline: permit.deadline,
v: permit.v,
r: permit.r,
s: permit.s,
tokenAddress: '0x5242b6DB88A72752ac5a54cFe6A7DB8244d743c9',
},
},
}),
});
See Also: Gas Relayer API - EIP-2612 Permit
Required Configuration
Environment Variables
# Privy Authentication (REQUIRED)
VITE_PRIVY_APP_ID=your-privy-app-id
# Gas Relayer (REQUIRED)
VITE_GAS_RELAYER_URL=https://relayer.loyalteez.app
# Network Configuration (REQUIRED)
VITE_CHAIN_ID=1868
VITE_RPC_URL=https://rpc.soneium.org
VITE_BLOCK_EXPLORER=https://soneium.blockscout.com
# Contract Addresses (REQUIRED)
VITE_PERK_NFT_ADDRESS=0x6ae30d6Dcf3e75456B6582b057f1Bf98A90F2CA0
VITE_LOYALTEEZ_ADDRESS=0x5242b6DB88A72752ac5a54cFe6A7DB8244d743c9
Core Functionality
1. Collection Discovery
Query all collections:
const provider = new ethers.JsonRpcProvider('https://rpc.soneium.org');
const perkNFT = new ethers.Contract(
'0x6ae30d6Dcf3e75456B6582b057f1Bf98A90F2CA0',
[
'function nextCollectionId() view returns (uint256)',
'function collections(uint256) view returns (address creator, uint256 priceInPoints, uint256 maxSupply, uint256 currentSupply, bool isActive, uint256 maxPerUser, string memory metadataHash)',
],
provider
);
// Get total collections
const totalCollections = await perkNFT.nextCollectionId();
// Fetch all collections
const collections = [];
for (let i = 0; i < totalCollections; i++) {
const collection = await perkNFT.collections(i);
if (collection.isActive) {
collections.push({
id: i,
...collection,
});
}
}
Filter by availability:
- Check
isActiveflag - Check
currentSupply < maxSupply - Check user's claim count vs
maxPerUser
2. User Balance Display
const ltzContract = new ethers.Contract(
'0x5242b6DB88A72752ac5a54cFe6A7DB8244d743c9',
['function balanceOf(address) view returns (uint256)'],
provider
);
const balance = await ltzContract.balanceOf(userAddress);
// LTZ has 0 decimals, so balance is already in whole units
const displayBalance = balance.toString();
3. Claim Flow
Complete claim flow:
async function claimPerk(
collectionId: bigint,
priceInPoints: bigint,
userAddress: string
) {
// 1. Check user balance
const balance = await ltzContract.balanceOf(userAddress);
if (balance < priceInPoints) {
throw new Error('Insufficient LTZ balance');
}
// 2. Check claim limit (if maxPerUser is set)
const collection = await perkNFT.collections(collectionId);
if (collection.maxPerUser > 0n) {
const userNFTs = await perkNFT.balanceOf(userAddress);
// Count NFTs from this collection (see example below)
const claimCount = await countUserClaims(userAddress, collectionId);
if (claimCount >= collection.maxPerUser) {
throw new Error('Claim limit reached');
}
}
// 3. Generate permit signature (optional but recommended)
const permit = await generatePermitSignature(provider, userAddress, priceInPoints);
// 4. Encode claimFor function
const claimData = perkNFT.interface.encodeFunctionData('claimFor', [
collectionId,
userAddress
]);
// 5. Send to gas relayer
const token = await getAccessToken();
const response = await fetch('https://relayer.loyalteez.app/relay', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
to: '0x6ae30d6Dcf3e75456B6582b057f1Bf98A90F2CA0',
data: claimData,
userAddress: userAddress,
metadata: {
action: 'claim_perk',
collectionId: collectionId.toString(),
permit: permit ? {
owner: userAddress,
spender: '0x6ae30d6Dcf3e75456B6582b057f1Bf98A90F2CA0',
value: priceInPoints.toString(),
deadline: permit.deadline,
v: permit.v,
r: permit.r,
s: permit.s,
tokenAddress: '0x5242b6DB88A72752ac5a54cFe6A7DB8244d743c9',
} : undefined,
},
}),
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Claim failed');
}
return result.transactionHash;
}
4. Check User's Claim Count
async function countUserClaims(
userAddress: string,
collectionId: bigint
): Promise<number> {
const perkNFT = new ethers.Contract(
'0x6ae30d6Dcf3e75456B6582b057f1Bf98A90F2CA0',
[
'function balanceOf(address) view returns (uint256)',
'function tokenOfOwnerByIndex(address, uint256) view returns (uint256)',
'function tokenToCollection(uint256) view returns (uint256)',
],
provider
);
const totalNFTs = await perkNFT.balanceOf(userAddress);
let claimCount = 0;
for (let i = 0; i < totalNFTs; i++) {
const tokenId = await perkNFT.tokenOfOwnerByIndex(userAddress, i);
const tokenCollectionId = await perkNFT.tokenToCollection(tokenId);
if (tokenCollectionId === collectionId) {
claimCount++;
}
}
return claimCount;
}
User Experience Considerations
Loading States
Show clear loading indicators during:
- Balance checks
- Permit signature generation
- Transaction submission
- Transaction confirmation
Error Handling
Handle common errors gracefully:
- Insufficient balance: Show how much more LTZ is needed
- Claim limit reached: Explain max claims per user
- Collection sold out: Show availability status
- Rate limit: Suggest waiting before retry
- Network errors: Provide retry option
Success Feedback
After successful claim:
- Show transaction hash
- Link to block explorer
- Update UI to reflect new balance
- Show claimed NFT in user's collection
Security Considerations
✅ Do's
- ✅ Always validate user authentication before allowing claims
- ✅ Check balances and claim limits on-chain before submitting
- ✅ Use
claimForfunction (notclaim) to allow gas relayer execution - ✅ Validate collection is active and available before showing claim button
- ✅ Handle permit signature rejection gracefully
- ✅ Rate limit claim attempts client-side
❌ Don'ts
- ❌ Don't trust client-side balance checks alone (verify on-chain)
- ❌ Don't expose Privy App Secret client-side
- ❌ Don't hardcode contract addresses (use environment variables)
- ❌ Don't skip error handling for transaction failures
- ❌ Don't allow claims without proper authentication
Example Implementation
See our reference implementation: perk-market-frontend
Key Files:
src/hooks/useGasRelayer.ts- Gas relayer integrationsrc/hooks/usePerkClaiming.ts- Complete claim flow with permitsrc/hooks/useUserBalance.ts- LTZ balance managementsrc/hooks/useCollections.ts- Collection discovery
Brand-Specific Perk Pages
Many brands want to create their own branded perk claim pages on their own websites, showing only their collections. This creates a seamless, white-label experience where users claim perks directly on your brand's site.
Use Cases
- 🏢 Brand Website Integration - Add a
/perkspage to your existing website - 🎨 White-Label Experience - Match your brand's design and styling
- 🔗 Direct Integration - Users claim perks without leaving your site
- 📱 Mobile App Integration - Embed perk claiming in your mobile app
Filtering Collections by Brand
The PerkNFT contract stores the creator address for each collection, which is your brand's wallet address. Filter collections by matching the creator field.
Example: Filter by Brand Address
async function getBrandCollections(brandAddress: string) {
const provider = new ethers.JsonRpcProvider('https://rpc.soneium.org');
const perkNFT = new ethers.Contract(
'0x6ae30d6Dcf3e75456B6582b057f1Bf98A90F2CA0',
[
'function nextCollectionId() view returns (uint256)',
'function collections(uint256) view returns (address creator, uint256 priceInPoints, uint256 maxSupply, uint256 currentSupply, bool isActive, uint256 maxPerUser, string memory metadataHash)',
],
provider
);
const totalCollections = await perkNFT.nextCollectionId();
const brandCollections = [];
// Normalize brand address for comparison
const normalizedBrandAddress = brandAddress.toLowerCase();
// Iterate through all collections
for (let i = 0; i < totalCollections; i++) {
const collection = await perkNFT.collections(i);
// Filter by creator address (case-insensitive)
if (collection.creator.toLowerCase() === normalizedBrandAddress && collection.isActive) {
brandCollections.push({
id: i,
creator: collection.creator,
priceInPoints: collection.priceInPoints,
maxSupply: collection.maxSupply,
currentSupply: collection.currentSupply,
isActive: collection.isActive,
maxPerUser: collection.maxPerUser,
metadataHash: collection.metadataHash,
// Calculate remaining supply
remainingSupply: collection.maxSupply === 0n
? null
: collection.maxSupply - collection.currentSupply,
soldOut: collection.maxSupply > 0n && collection.currentSupply >= collection.maxSupply,
});
}
}
return brandCollections;
}
Usage:
// Your brand's wallet address (from Partner Portal)
const BRAND_ADDRESS = '0x47511fc1c6664c9598974cb112965f8b198e0c725e';
// Get only your brand's collections
const myCollections = await getBrandCollections(BRAND_ADDRESS);
React Hook Example
Create a custom hook for brand-specific collections:
import { useQuery } from '@tanstack/react-query';
import { ethers } from 'ethers';
function useBrandCollections(brandAddress: string) {
return useQuery({
queryKey: ['brand-collections', brandAddress],
queryFn: async () => {
const provider = new ethers.JsonRpcProvider('https://rpc.soneium.org');
const perkNFT = new ethers.Contract(
'0x6ae30d6Dcf3e75456B6582b057f1Bf98A90F2CA0',
[
'function nextCollectionId() view returns (uint256)',
'function collections(uint256) view returns (address creator, uint256 priceInPoints, uint256 maxSupply, uint256 currentSupply, bool isActive, uint256 maxPerUser, string memory metadataHash)',
],
provider
);
const totalCollections = await perkNFT.nextCollectionId();
const normalizedBrandAddress = brandAddress.toLowerCase();
const collections = [];
for (let i = 0; i < totalCollections; i++) {
const collection = await perkNFT.collections(i);
if (collection.creator.toLowerCase() === normalizedBrandAddress && collection.isActive) {
collections.push({
id: i,
...collection,
remainingSupply: collection.maxSupply === 0n
? null
: collection.maxSupply - collection.currentSupply,
soldOut: collection.maxSupply > 0n && collection.currentSupply >= collection.maxSupply,
});
}
}
return collections;
},
enabled: !!brandAddress,
staleTime: 30000, // Refresh every 30 seconds
});
}
// Usage in component
function BrandPerksPage() {
const BRAND_ADDRESS = '0x47511fc1c6664c9598974cb112965f8b198e0c725e';
const { data: collections = [], isLoading } = useBrandCollections(BRAND_ADDRESS);
if (isLoading) return <div>Loading your perks...</div>;
return (
<div>
<h1>Our Exclusive Perks</h1>
<div className="perks-grid">
{collections.map(collection => (
<PerkCard key={collection.id} collection={collection} />
))}
</div>
</div>
);
}
Environment Configuration
For brand-specific pages, you'll need your brand's wallet address:
# Your brand's wallet address (from Partner Portal)
VITE_BRAND_ADDRESS=0x47511fc1c6664c9598974cb112965f8b198e0c725e
# Other required config (same as general marketplace)
VITE_PRIVY_APP_ID=your-privy-app-id
VITE_GAS_RELAYER_URL=https://relayer.loyalteez.app
VITE_PERK_NFT_ADDRESS=0x6ae30d6Dcf3e75456B6582b057f1Bf98A90F2CA0
VITE_LOYALTEEZ_ADDRESS=0x5242b6DB88A72752ac5a54cFe6A7DB8244d743c9
Get Your Brand Address:
- Log into Partner Portal
- Go to Settings → Account
- Copy your Brand Wallet Address
Embedding Options
Option 1: Full Page Integration
Create a dedicated /perks route on your website:
// pages/perks.tsx (Next.js example)
import { PrivyProvider } from '@privy-io/react-auth';
import BrandPerksPage from '../components/BrandPerksPage';
export default function PerksPage() {
return (
<PrivyProvider appId={process.env.NEXT_PUBLIC_PRIVY_APP_ID}>
<BrandPerksPage />
</PrivyProvider>
);
}
Option 2: Embedded Widget
Embed perk claiming as a widget on an existing page:
// components/PerksWidget.tsx
function PerksWidget() {
const BRAND_ADDRESS = process.env.NEXT_PUBLIC_BRAND_ADDRESS;
const { data: collections } = useBrandCollections(BRAND_ADDRESS);
return (
<div className="perks-widget">
<h2>Claim Your Rewards</h2>
{collections?.slice(0, 3).map(collection => (
<PerkCard key={collection.id} collection={collection} />
))}
<a href="/perks">View All Perks →</a>
</div>
);
}
Option 3: Modal/Popup
Show perks in a modal overlay:
function PerksModal({ isOpen, onClose }) {
const BRAND_ADDRESS = process.env.NEXT_PUBLIC_BRAND_ADDRESS;
const { data: collections } = useBrandCollections(BRAND_ADDRESS);
if (!isOpen) return null;
return (
<Modal onClose={onClose}>
<h2>Your Exclusive Perks</h2>
<div className="perks-grid">
{collections?.map(collection => (
<PerkCard key={collection.id} collection={collection} />
))}
</div>
</Modal>
);
}
Styling & Branding
Match your brand's design:
/* Customize perk cards to match your brand */
.perk-card {
background: your-brand-color;
border-radius: your-border-radius;
font-family: your-brand-font;
}
.perk-card .claim-button {
background: your-primary-color;
color: your-text-color;
}
CSS Variables Approach:
:root {
--brand-primary: #your-color;
--brand-secondary: #your-color;
--brand-font: 'Your Font', sans-serif;
}
.perk-card {
background: var(--brand-primary);
font-family: var(--brand-font);
}
Performance Optimization
Caching Strategy
// Cache brand collections for better performance
const { data: collections } = useQuery({
queryKey: ['brand-collections', brandAddress],
queryFn: getBrandCollections,
staleTime: 60000, // Consider fresh for 1 minute
gcTime: 300000, // Keep in cache for 5 minutes
refetchOnWindowFocus: true, // Refresh when user returns
});
Pagination (for many collections)
function useBrandCollectionsPaginated(brandAddress: string, page: number = 0, pageSize: number = 12) {
return useQuery({
queryKey: ['brand-collections', brandAddress, page],
queryFn: async () => {
const allCollections = await getBrandCollections(brandAddress);
const start = page * pageSize;
const end = start + pageSize;
return {
collections: allCollections.slice(start, end),
total: allCollections.length,
page,
pageSize,
hasMore: end < allCollections.length,
};
},
});
}
Real-Time Updates
Subscribe to collection updates for your brand:
import { useEffect } from 'react';
import { ethers } from 'ethers';
function useBrandCollectionsRealtime(brandAddress: string) {
const { data, refetch } = useBrandCollections(brandAddress);
useEffect(() => {
const provider = new ethers.WebSocketProvider('wss://rpc.soneium.org');
const perkNFT = new ethers.Contract(
'0x6ae30d6Dcf3e75456B6582b057f1Bf98A90F2CA0',
['event PerkClaimed(uint256 indexed collectionId, address indexed claimer, uint256 indexed tokenId)'],
provider
);
// Listen for claims on your brand's collections
perkNFT.on('PerkClaimed', (collectionId, claimer, tokenId) => {
// Check if this collection belongs to your brand
perkNFT.collections(collectionId).then(collection => {
if (collection.creator.toLowerCase() === brandAddress.toLowerCase()) {
console.log('New claim on your collection!', { collectionId, claimer, tokenId });
refetch(); // Refresh collections
}
});
});
return () => {
perkNFT.removeAllListeners();
};
}, [brandAddress, refetch]);
return data;
}
Complete Brand Page Example
import { PrivyProvider, usePrivy } from '@privy-io/react-auth';
import { useBrandCollections } from '../hooks/useBrandCollections';
import { usePerkClaiming } from '../hooks/usePerkClaiming';
import { useUserBalance } from '../hooks/useUserBalance';
const BRAND_ADDRESS = '0x47511fc1c6664c9598974cb112965f8b198e0c725e';
function BrandPerksContent() {
const { authenticated, user } = usePrivy();
const { data: collections = [], isLoading } = useBrandCollections(BRAND_ADDRESS);
const { balance } = useUserBalance();
const { claimPerk, claimingState } = usePerkClaiming();
if (isLoading) {
return <div>Loading perks...</div>;
}
if (collections.length === 0) {
return (
<div>
<h1>Our Perks</h1>
<p>No perks available at this time. Check back soon!</p>
</div>
);
}
return (
<div className="brand-perks-page">
<header>
<h1>Exclusive Perks</h1>
{authenticated && (
<div className="balance">
Your Balance: {balance} LTZ
</div>
)}
</header>
<div className="perks-grid">
{collections.map(collection => (
<div key={collection.id} className="perk-card">
<img src={collection.imageUrl} alt={collection.name} />
<h3>{collection.name}</h3>
<p>{collection.description}</p>
<div className="price">{collection.priceInPoints.toString()} LTZ</div>
{authenticated ? (
<button
onClick={() => claimPerk(collection.id, collection.priceInPoints)}
disabled={claimingState.isLoading || balance < collection.priceInPoints}
>
{claimingState.isLoading ? 'Claiming...' : 'Claim Perk'}
</button>
) : (
<button onClick={() => login()}>Login to Claim</button>
)}
</div>
))}
</div>
</div>
);
}
export default function BrandPerksPage() {
return (
<PrivyProvider appId={process.env.NEXT_PUBLIC_PRIVY_APP_ID}>
<BrandPerksContent />
</PrivyProvider>
);
}
Best Practices
✅ Do's
- ✅ Filter by creator address - Always filter collections by your brand's wallet address
- ✅ Show empty state - Display a friendly message when no perks are available
- ✅ Match brand styling - Use your brand colors, fonts, and design language
- ✅ Handle loading states - Show loading indicators while fetching collections
- ✅ Cache collections - Use React Query or similar for efficient data fetching
- ✅ Real-time updates - Refresh when new collections are created or claims occur
❌ Don'ts
- ❌ Don't hardcode collection IDs - Always query dynamically
- ❌ Don't show other brands' collections - Always filter by creator address
- ❌ Don't skip error handling - Handle network errors and empty states gracefully
- ❌ Don't forget authentication - Require Privy login before allowing claims
Security Considerations
Brand Address Validation:
// Always validate brand address format
function isValidBrandAddress(address: string): boolean {
return /^0x[a-fA-F0-9]{40}$/.test(address);
}
// Normalize for comparison
function normalizeAddress(address: string): string {
return address.toLowerCase();
}
Prevent Cross-Brand Claims:
// Always verify collection belongs to your brand before claiming
async function verifyCollectionOwnership(collectionId: bigint, brandAddress: string): Promise<boolean> {
const collection = await perkNFT.collections(collectionId);
return collection.creator.toLowerCase() === brandAddress.toLowerCase();
}
// Use before allowing claim
const isValid = await verifyCollectionOwnership(collectionId, BRAND_ADDRESS);
if (!isValid) {
throw new Error('This collection does not belong to your brand');
}
Integration Checklist
- Get your brand wallet address from Partner Portal
- Set up Privy authentication
- Configure Gas Relayer URL
- Implement collection filtering by creator address
- Add claim functionality with gas relayer
- Style to match your brand
- Handle loading and error states
- Test with your brand's collections
- Deploy to your website
Support
Need help building your brand-specific perk page?
- 📧 Email: [email protected]
- 📖 Gas Relayer API
- 📖 Authentication Guide
Testing
Test Checklist
- User can authenticate with Privy
- User can view their LTZ balance
- User can browse available collections
- User can see claim limits and availability
- User can claim a perk (gasless)
- User receives NFT after claim
- User cannot claim more than
maxPerUserlimit - User cannot claim if insufficient balance
- User cannot claim if collection is sold out
- Error messages are user-friendly
Test Network
Use Soneium Mainnet (Chain ID: 1868) for production testing.
Deployment
Static Hosting
The frontend is a static React app and can be deployed to:
- Cloudflare Pages
- Vercel
- Netlify
- AWS S3 + CloudFront
Environment Variables
Set all VITE_* environment variables in your hosting platform's dashboard.
Required Variables:
VITE_PRIVY_APP_IDVITE_GAS_RELAYER_URLVITE_PERK_NFT_ADDRESSVITE_LOYALTEEZ_ADDRESSVITE_CHAIN_IDVITE_RPC_URL
Support
Questions?
- 📧 Email: [email protected]
- 📖 Gas Relayer API
- 📖 Authentication Guide
- 💬 Discord Community
Related Documentation
- Gas Relayer API - Complete gas relayer reference
- Authentication Guide - Privy setup guide
- REST API Reference - Event tracking API
- Architecture Overview - System architecture
Ready to build? Start with the Quick Start Guide and integrate the Gas Relayer for gasless perk claiming!