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​

Perk Claim Frontend Architecture


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?


Per-NFT Charge Tracking​

Perk NFTs can have multi-use charges (e.g., a 10-visit pass). When creating a perk in the Partner Portal, set the "Uses per perk" field to control how many times each claimed NFT can be redeemed.

After a customer claims a perk, your backend can:

  • Check charge status: GET /check-perk-status?token_id=42&collection_id=7
  • Redeem a charge: POST /redeem-perk with { token_id, collection_id }
  • List all holders: GET /list-perk-holders?collection_id=7

These endpoints use HMAC signature authentication with your Brand ID and Security Key (no Privy token needed).

Full API reference: Perk Charges & Redemption API



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