Skip to main content

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


Why: Enables fully gasless approvals (no approval transaction needed).

How It Works:

  1. User signs permit message (off-chain, no gas)
  2. Gas relayer executes permit() + claimFor() in one transaction
  3. 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 isActive flag
  • 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 claimFor function (not claim) 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 integration
  • src/hooks/usePerkClaiming.ts - Complete claim flow with permit
  • src/hooks/useUserBalance.ts - LTZ balance management
  • src/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 /perks page 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:

  1. Log into Partner Portal
  2. Go to Settings → Account
  3. 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?


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 maxPerUser limit
  • 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_ID
  • VITE_GAS_RELAYER_URL
  • VITE_PERK_NFT_ADDRESS
  • VITE_LOYALTEEZ_ADDRESS
  • VITE_CHAIN_ID
  • VITE_RPC_URL

Support

Questions?



Ready to build? Start with the Quick Start Guide and integrate the Gas Relayer for gasless perk claiming!