@akin-travel/partner-sdk
v1.0.8
Published
SDK for third-party partners to integrate Akin auth and loyalty services
Downloads
264
Maintainers
Readme
@akin-online/partner-sdk
SDK for third-party partners to integrate Akin auth and loyalty services into their React applications.
Features
- Authentication - Magic links (passwordless), passkeys (WebAuthn), Google OAuth
- Loyalty - Tier display, points balance, transaction history, progress tracking
- Tier Requirements - Flexible key/value qualification rules with display utilities
- Headless Components - Full control over your UI with render props
- TypeScript - Full type support
- Phone Input - International phone number input with validation
Getting Access
The @akin-online/partner-sdk is a private npm package available to authorized Akin partners.
Partner Onboarding
Contact your Akin partner representative or email [email protected] to request access. You'll receive the following via secure channel:
| Credential | Purpose |
|------------|---------|
| npm access token | For installing the private @akin-online/partner-sdk package |
| Partner ID | Your unique identifier in the Akin system |
| API key | For authenticating with the Akin API |
| SDK environment variables | Firebase/GIP credentials and API URLs (NEXT_PUBLIC_AKIN_SDK_*) |
Configure npm Access
Add the npm access token to your .npmrc file:
# ~/.npmrc or project .npmrc
//registry.npmjs.org/:_authToken=YOUR_NPM_TOKENSecurity Note: Never commit your .npmrc file with tokens to source control. Add it to .gitignore.
Installation
npm install @akin-online/partner-sdkPeer Dependencies
npm install react-phone-number-inputQuick Start
1. Wrap your app with AkinProvider
// app/layout.tsx or _app.tsx
import { AkinProvider } from '@akin-online/partner-sdk';
export default function RootLayout({ children }) {
return (
<AkinProvider
config={{
partnerId: process.env.NEXT_PUBLIC_AKIN_PARTNER_ID!,
apiKey: process.env.NEXT_PUBLIC_AKIN_API_KEY!,
verifyCallbackUrl: process.env.NEXT_PUBLIC_AKIN_VERIFY_URL!, // e.g., https://viajero.com/auth/verify
environment: 'production',
debug: false, // Enable for development troubleshooting
}}
>
{children}
</AkinProvider>
);
}2. Use auth hooks
import { useAkinAuth } from '@akin-online/partner-sdk';
function Profile() {
const { member, isAuthenticated, signOut } = useAkinAuth();
if (!isAuthenticated) {
return <p>Please sign in</p>;
}
return (
<div>
<h1>Welcome, {member?.firstName}!</h1>
<p>Loyalty #: {member?.loyaltyNumber}</p>
<button onClick={signOut}>Sign Out</button>
</div>
);
}3. Use headless components for custom UI
import { LoginForm, TierCard, PointsDisplay } from '@akin-online/partner-sdk';
function LoginPage() {
return (
<LoginForm onSuccess={() => router.push('/rewards')}>
{({
mode,
setMode,
email,
setEmail,
isLoading,
error,
passkeySupported,
handlePasskeyLogin,
handleMagicLink,
clearError,
}) => (
<div className="your-styles">
{/* Passkey login (recommended default) */}
{mode === 'passkey' && (
<>
<button onClick={handlePasskeyLogin} disabled={!passkeySupported || isLoading}>
Sign in with Passkey
</button>
<button onClick={() => setMode('magic-link')}>
Sign in with Email
</button>
</>
)}
{/* Magic link (email) login */}
{mode === 'magic-link' && (
<>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<button onClick={handleMagicLink} disabled={isLoading || !email}>
Send sign-in link
</button>
</>
)}
{/* Magic link sent confirmation */}
{mode === 'magic-link-sent' && (
<p>Check your email for a sign-in link!</p>
)}
{error && <p className="error">{error}</p>}
</div>
)}
</LoginForm>
);
}Configuration
interface AkinSDKConfig {
// Required
partnerId: string; // Your partner ID (provided by Akin)
apiKey: string; // Your API key (provided by Akin)
// Recommended
verifyCallbackUrl?: string; // URL of your /auth/verify page (e.g., https://viajero.com/auth/verify)
// Magic link emails will redirect users to this URL
// Optional - defaults to NEXT_PUBLIC_AKIN_SDK_* env vars
apiUrl?: string; // API endpoint (default: NEXT_PUBLIC_AKIN_SDK_API_URL)
environment?: 'production' | 'staging' | 'development';
debug?: boolean; // Enable console logging (default: false)
firebase?: { // Firebase/GIP config (default: NEXT_PUBLIC_AKIN_SDK_FIREBASE_* env vars)
apiKey: string;
authDomain: string;
projectId: string;
appId: string;
};
gipTenantId?: string; // GIP tenant ID (default: NEXT_PUBLIC_AKIN_SDK_GIP_TENANT_ID)
}Authentication Methods
The SDK supports three passwordless authentication methods:
1. Passkeys (WebAuthn) - Recommended
Biometric/device authentication. Most secure and user-friendly.
const { signInWithPasskey } = useAkinAuth();
// Check if passkeys are supported
if (passkeySupported) {
await signInWithPasskey();
}2. Magic Links (Email)
Passwordless email verification links.
const { requestMagicLink, verifyMagicLink } = useAkinAuth();
// Request a magic link
await requestMagicLink(email);
// Verify the token (on your /auth/verify page)
const result = await verifyMagicLink(token);
if (result.success) {
// User is authenticated
}3. Google OAuth
Social login with Google.
const { signInWithGoogle } = useAkinAuth();
await signInWithGoogle();Auth API
useAkinAuth Hook
const {
// State
user, // Firebase user object
member, // Akin member data (points, tier, etc.)
isAuthenticated, // Boolean
isLoading, // Boolean
error, // Error message or null
// Actions
signIn, // (email, password) => Promise<void>
signUp, // (data: SignUpData) => Promise<void>
signUpPasswordless, // (data) => Promise<{ success, error? }>
requestMagicLink, // (email) => Promise<{ success, error? }>
verifyMagicLink, // (token) => Promise<{ success, error? }>
signInWithGoogle, // () => Promise<void>
signInWithPasskey, // () => Promise<void>
signOut, // () => Promise<void>
resetPassword, // (email) => Promise<void>
refreshMemberData, // () => Promise<void>
clearError, // () => void
} = useAkinAuth();Headless Auth Components
LoginForm
Multi-mode login form supporting passkeys, magic links, and passwords.
<LoginForm onSuccess={onSuccess} onError={onError}>
{({
// Mode management
mode, // 'passkey' | 'magic-link' | 'magic-link-sent' | 'password'
setMode, // (mode) => void
// Form state
email, setEmail,
password, setPassword,
isLoading, error,
// Passkey
passkeySupported, // Boolean - check before showing passkey option
handlePasskeyLogin,
// Magic link
handleMagicLink,
handleResendMagicLink,
// Password (if enabled)
handleSubmit,
// Google OAuth
handleGoogleLogin,
clearError,
}) => (
// Your JSX
)}
</LoginForm>SignupForm
Registration form with optional passwordless mode.
<SignupForm
onSuccess={onSuccess}
onError={onError}
passwordless={true} // Set to true for magic link signup
>
{({
firstName, setFirstName,
lastName, setLastName,
email, setEmail,
password, setPassword, // Only used if passwordless={false}
phone, setPhone,
termsAccepted, setTermsAccepted,
marketingOptIn, setMarketingOptIn,
isLoading, error,
handleSubmit,
handleGoogleSignup,
clearError,
}) => (
// Your JSX
)}
</SignupForm>MagicLinkForm
Standalone magic link request form.
<MagicLinkForm onSuccess={onSuccess}>
{({ email, setEmail, isLoading, error, success, handleSubmit }) => (
// Your JSX
)}
</MagicLinkForm>PhoneInput
International phone number input with country selector.
import { PhoneInput } from '@akin-online/partner-sdk';
import 'react-phone-number-input/style.css';
<PhoneInput
id="phone"
value={phone}
onChange={setPhone}
defaultCountry="US"
placeholder="Enter phone number"
className="your-input-class"
required
/>Loyalty API
useAkinLoyalty Hook
const {
// State
points, // Current points balance
tier, // 'TIER1' | 'TIER2' | 'TIER3' | 'TIER4'
tierConfig, // Current tier configuration
tierProgress, // Progress to next tier
transactions, // Recent transactions
tierHistory, // Tier change history
isLoading, // Boolean
error, // Error message or null
// Actions
refreshData, // () => Promise<void>
getTransactionHistory, // (options?) => Promise<Transaction[]>
getTierHistory, // (limit?) => Promise<TierHistory[]>
} = useAkinLoyalty();Headless Loyalty Components
TierCard
<TierCard>
{({ tier, displayName, color, icon, points, loyaltyNumber, maxPerks }) => (
// Your JSX
)}
</TierCard>TierProgress
<TierProgress>
{({
currentTier, currentTierName,
nextTier, nextTierName,
percentage, pointsNeeded,
totalStays, originStays, // Note: originStays (not originPartnerStays)
}) => (
nextTierName ? (
<div>
<progress value={percentage} max={100} />
<p>{pointsNeeded} points to {nextTierName}</p>
</div>
) : (
<p>You've reached the highest tier!</p>
)
)}
</TierProgress>TransactionList
<TransactionList limit={10}>
{({ transactions, isLoading, hasMore, loadMore }) => (
// Your JSX
)}
</TransactionList>PointsDisplay
<PointsDisplay>
{({ points, formattedPoints, isLoading }) => (
// Your JSX
)}
</PointsDisplay>TierRequirementsTable
Display tier requirements in a table format. Perfect for "how to qualify" sections.
import { TierRequirementsTable } from '@akin-online/partner-sdk';
<TierRequirementsTable partnerName="Haka">
{({ tierConfigs, requirementKeys, getRequirementLabel, formatValue, getRequirement, isLoading }) => (
<table>
<thead>
<tr>
<th>Requirement</th>
{tierConfigs.map(tier => (
<th key={tier.id}>{tier.displayName}</th>
))}
</tr>
</thead>
<tbody>
{requirementKeys.map(key => (
<tr key={key}>
<td>{getRequirementLabel(key)}</td>
{tierConfigs.map(tier => {
const req = getRequirement(tier, key);
return (
<td key={tier.id}>
{req ? formatValue(Number(req.requirementValue), req.operator) : '—'}
</td>
);
})}
</tr>
))}
</tbody>
</table>
)}
</TierRequirementsTable>Render Props:
tierConfigs- Tier configs sorted by display orderrequirementKeys- Unique requirement keys sorted by prioritygetRequirementLabel(key, displayName?)- Get display label for a keyformatValue(value, operator)- Format value with operator (e.g., "10+")getRequirement(tier, key)- Get requirement for tier/keypartnerName- Partner name used for dynamic labelsisLoading- Loading state
SimpleTierCards
Display tier cards with requirements. Great for tier overview sections.
import { SimpleTierCards } from '@akin-online/partner-sdk';
<SimpleTierCards partnerName="Viajero">
{({ tierConfigs, formatRequirement, getTextColor, isLoading }) => (
<div>
<h2 className="text-2xl font-bold text-center mb-6">Viajero GO Tiers</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-stretch">
{tierConfigs.map((tier, index) => {
const bgColor = tier.tierColor || '#382108';
const textColor = getTextColor(bgColor);
return (
<div
key={tier.id}
style={{ backgroundColor: bgColor, color: textColor }}
className="p-5 rounded-xl flex flex-col h-full"
>
{/* Tier icon */}
{tier.tierIcon && (
<div className="w-10 h-10 mb-3 rounded-full bg-white/20 flex items-center justify-center">
<img src={tier.tierIcon} alt="" className="w-6 h-6" />
</div>
)}
{/* Tier name */}
<h3 className="text-2xl font-bold">{tier.displayName}</h3>
<span className="text-sm opacity-70 mb-3">Tier {index + 1}</span>
{/* Requirements */}
<div className="mt-auto pt-3 border-t border-current/20 space-y-1">
{tier.requirements?.filter(r => r.active).map(req => (
<p key={req.id} className="text-xs opacity-80">
{formatRequirement(req)}
</p>
))}
</div>
</div>
);
})}
</div>
</div>
)}
</SimpleTierCards>Render Props:
tierConfigs- Tier configs sorted by display orderformatRequirement(req)- Format requirement (e.g., "10+ Origin stays")getTextColor(bgColor)- Get contrasting text color for backgroundisLoading- Loading state
TierBenefits
Display rewards/benefits available at each tier. Fetched automatically via the loyalty provider.
import { TierBenefits } from '@akin-online/partner-sdk';
<TierBenefits>
{({ tierBenefits, getTierConfig, getTextColor, isLoading }) => (
<div>
<h2 className="text-2xl font-bold text-center mb-6">Tier Benefits</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-stretch">
{tierBenefits.map((benefit) => {
const config = getTierConfig(benefit.tierName);
const bgColor = config?.tierColor || '#382108';
const textColor = config?.tierTextColor || getTextColor(bgColor);
return (
<div
key={benefit.tierName}
style={{ backgroundColor: bgColor, color: textColor }}
className="p-5 rounded-xl flex flex-col h-full"
>
<h3 className="text-xl font-bold mb-2">{config?.displayName}</h3>
<div className="w-full h-px mb-3 opacity-30 bg-current" />
{benefit.groups?.some(g => g.scope === 'PROPERTY') ? (
/* Grouped by property */
<div className="space-y-3 flex-1">
{benefit.groups.map((group) => (
<div key={group.scope === 'ALL_PROPERTIES' ? 'all' : group.propertyId}>
<h4 className="text-xs font-semibold uppercase tracking-wide mb-1 opacity-70">
{group.scope === 'ALL_PROPERTIES' ? 'All Properties' : group.propertyName}
</h4>
<ul className="space-y-1">
{group.rewardNames.map((name, i) => (
<li key={i} className="text-sm flex items-start gap-2">
<span className="mt-0.5">✓</span>
<span>{name}</span>
</li>
))}
</ul>
</div>
))}
</div>
) : (
/* Flat list */
<ul className="space-y-1.5 flex-1">
{benefit.rewardNames.map((name, i) => (
<li key={i} className="text-sm flex items-start gap-2">
<span className="mt-0.5">✓</span>
<span>{name}</span>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
</div>
)}
</TierBenefits>Render Props:
tierBenefits- Tier benefits sorted by display order, each withtierName,rewardNames, and optionalgroupstierConfigs- Tier configs sorted by display order (for colors/names)getTierConfig(tierName)- Get tier config for a given tier namegetTextColor(bgColor)- Get contrasting text color for backgroundisLoading- Loading state
Tier Label Utilities
Centralized labels for tier requirement keys.
import { getLabel, TIER_LABELS, PARTNER_LABELS, KEY_PRIORITY, extractRequirementKeys } from '@akin-online/partner-sdk';
// Get a label for a requirement key
getLabel('stays_member_origin'); // "Origin stays"
getLabel('stays_partner', 'Haka'); // "Haka stays"
getLabel('points', 'Origin', 'Custom'); // "Custom" (displayName override)
// Static labels
TIER_LABELS['points']; // "AKIN points"
TIER_LABELS['stays_network']; // "Network stays"
// Dynamic labels (with partner name)
PARTNER_LABELS['stays_partner']('Haka'); // "Haka stays"
PARTNER_LABELS['achievement_tattoo']('Drifter'); // "Drifter tattoo"
// Extract unique requirement keys from tier configs (sorted by priority)
const keys = extractRequirementKeys(tierConfigs);
// ['stays_partner', 'stays_member_origin', 'points', ...]Available Labels:
stays_member_origin→ "Origin stays"stays_network→ "Network stays"stays_partner→ "[Partner] stays"nights_member_origin→ "Origin nights"nights_network→ "Network nights"nights_partner→ "[Partner] nights"points→ "AKIN points"network_points→ "Network points"spend→ "Total spend"referrals→ "Referrals"achievement_tattoo→ "[Partner] tattoo"achievement_invitation→ "Invitation"country_completion_mx→ "Mexico complete"country_completion_pe→ "Peru complete"country_completion_co→ "Colombia complete"
Types
AkinMember
interface AkinMember {
id: string;
email: string;
firstName: string;
lastName: string;
phone?: string;
points: number;
currentTier: 'TIER1' | 'TIER2' | 'TIER3' | 'TIER4';
status: 'ACTIVE' | 'SUSPENDED' | 'DELETED';
loyaltyNumber: string;
vibePreference?: string;
perkPreferences?: string[];
createdAt: Date;
updatedAt: Date;
}TierConfig
interface TierConfig {
id: string;
tierName: string; // 'TIER1' | 'TIER2' | 'TIER3' | 'TIER4'
displayName: string; // 'Explorer' | 'Voyager' | etc.
displayOrder: number;
maxPerksAllowed: number;
tierColor?: string;
tierIcon?: string;
tierByline?: string;
eligibility?: string; // Note: eligibility (not eligibilityText)
requirements?: TierRequirement[]; // Key/value tier requirements
}TierRequirement
interface TierRequirement {
id: string;
tierConfigId: string;
requirementKey: string; // e.g., 'stays_member_origin', 'points'
requirementValue: string; // Threshold value
valueType: 'INT' | 'FLOAT' | 'DATE' | 'STRING';
operator: 'eq' | 'gt' | 'gte' | 'lt' | 'lte';
logicGroup: number; // For AND/OR logic combinations
displayOrder: number;
displayName?: string; // Custom display override
active: boolean;
}LoyaltyTransaction
interface LoyaltyTransaction {
id: string;
memberId: string;
bookingId?: string;
pointsChange: number;
transactionType: 'EARNED' | 'SPENT' | 'EXPIRED' | 'ADJUSTED';
reason?: string;
description?: string;
balanceAfter: number;
createdAt: Date;
}TierProgress
interface TierProgress {
currentTier: string;
currentTierName: string;
nextTier: string | null;
nextTierName: string | null;
percentage: number;
pointsNeeded: number;
totalStays: number;
originStays: number; // Note: originStays (not originPartnerStays)
}Environment Variables
# Required - Partner credentials
NEXT_PUBLIC_AKIN_PARTNER_ID=your-partner-id
NEXT_PUBLIC_AKIN_API_KEY=your-api-key
NEXT_PUBLIC_AKIN_VERIFY_URL=https://yourdomain.com/auth/verify
# Required - SDK infrastructure (provided by Akin during onboarding)
NEXT_PUBLIC_AKIN_SDK_API_URL=https://api.akintravel.com/graphql
NEXT_PUBLIC_AKIN_SDK_GIP_TENANT_ID=your-gip-tenant-id
NEXT_PUBLIC_AKIN_SDK_FIREBASE_API_KEY=your-firebase-api-key
NEXT_PUBLIC_AKIN_SDK_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
NEXT_PUBLIC_AKIN_SDK_FIREBASE_PROJECT_ID=your-firebase-project-id
NEXT_PUBLIC_AKIN_SDK_FIREBASE_APP_ID=your-firebase-app-id
# Optional - Environment-specific API URLs
NEXT_PUBLIC_AKIN_SDK_STAGING_API_URL=https://staging-api.example.com/graphql
NEXT_PUBLIC_AKIN_SDK_DEV_API_URL=http://localhost:4000/graphqlSecurity Best Practices
API Key Handling
IMPORTANT: Always use environment variables for your API key. Never commit API keys to source control.
// ✅ CORRECT - Use environment variable
apiKey: process.env.NEXT_PUBLIC_AKIN_API_KEY!
// ❌ WRONG - Never hardcode API keys
apiKey: 'pk_live_abc123...'
// ❌ WRONG - Never use fallback values in production
apiKey: process.env.NEXT_PUBLIC_AKIN_API_KEY || 'demo-key'Debug Mode
Only enable debug mode in development:
debug: process.env.NODE_ENV === 'development'Environment File (.env.local)
All SDK configuration values (API URLs, Firebase credentials, GIP tenant IDs) must be set via NEXT_PUBLIC_AKIN_SDK_* environment variables. These values are provided during partner onboarding and should never be hardcoded in source code.
# Add to .gitignore
.env.local
.env*.localCORS
The SDK automatically handles CORS with the Akin API. If you need to allow additional origins, contact your Akin partner representative.
CSS Styling Notes
Link Color Override
If you have global link styles that override button text colors, use a more specific selector:
/* ❌ This will override button text colors */
a {
@apply text-blue-600;
}
/* ✅ Use this instead - excludes elements with explicit text colors */
a:not([class*="text-"]) {
@apply text-blue-600;
}Phone Input Styles
Import the phone input CSS:
import 'react-phone-number-input/style.css';Examples
Magic Link Verification Page
Create an /auth/verify route to handle magic link callbacks:
'use client';
import { Suspense, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAkinAuth } from '@akin-online/partner-sdk';
function VerifyContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { verifyMagicLink } = useAkinAuth();
const [state, setState] = useState<'loading' | 'success' | 'error'>('loading');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const token = searchParams.get('token');
if (!token) {
setError('Invalid verification link');
setState('error');
return;
}
const verify = async () => {
const result = await verifyMagicLink(token);
if (!result.success) {
setError(result.error || 'Verification failed');
setState('error');
return;
}
setState('success');
setTimeout(() => router.push('/rewards'), 1500);
};
verify();
}, [searchParams, verifyMagicLink, router]);
if (state === 'loading') return <p>Verifying...</p>;
if (state === 'success') return <p>Verified! Redirecting...</p>;
return <p>Error: {error}</p>;
}
export default function VerifyPage() {
return (
<Suspense fallback={<p>Loading...</p>}>
<VerifyContent />
</Suspense>
);
}Complete Passwordless Signup
'use client';
import { useState } from 'react';
import { SignupForm, PhoneInput } from '@akin-online/partner-sdk';
import 'react-phone-number-input/style.css';
export default function SignupPage() {
const [signupComplete, setSignupComplete] = useState(false);
const [submittedEmail, setSubmittedEmail] = useState('');
if (signupComplete) {
return (
<div className="text-center">
<h1>Check your email!</h1>
<p>We sent a verification link to {submittedEmail}</p>
</div>
);
}
return (
<SignupForm
passwordless
onSuccess={() => {}}
onError={(error) => console.error(error)}
>
{({
firstName, setFirstName,
lastName, setLastName,
email, setEmail,
phone, setPhone,
termsAccepted, setTermsAccepted,
marketingOptIn, setMarketingOptIn,
isLoading, error,
handleSubmit,
clearError,
}) => (
<form
onSubmit={(e) => {
e.preventDefault();
setSubmittedEmail(email);
handleSubmit(e).then(() => {
if (!error) setSignupComplete(true);
});
}}
>
<div className="grid grid-cols-2 gap-4">
<input
value={firstName}
onChange={(e) => { setFirstName(e.target.value); clearError(); }}
placeholder="First name"
required
/>
<input
value={lastName}
onChange={(e) => { setLastName(e.target.value); clearError(); }}
placeholder="Last name"
required
/>
</div>
<input
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); clearError(); }}
placeholder="Email"
required
/>
<PhoneInput
value={phone}
onChange={(value) => { setPhone(value); clearError(); }}
defaultCountry="US"
placeholder="Phone number"
required
/>
<label>
<input
type="checkbox"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
required
/>
I agree to the Terms of Service and Privacy Policy
</label>
<label>
<input
type="checkbox"
checked={marketingOptIn}
onChange={(e) => setMarketingOptIn(e.target.checked)}
/>
Send me updates and offers
</label>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Create Account'}
</button>
</form>
)}
</SignupForm>
);
}Complete Login Page with Passkeys
'use client';
import { useAkinAuth, LoginForm } from '@akin-online/partner-sdk';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function LoginPage() {
const router = useRouter();
const { isAuthenticated } = useAkinAuth();
useEffect(() => {
if (isAuthenticated) router.push('/rewards');
}, [isAuthenticated, router]);
return (
<div className="login-container">
<h1>Welcome Back</h1>
<LoginForm
onSuccess={() => router.push('/rewards')}
onError={(error) => console.error(error)}
>
{({
mode, setMode,
email, setEmail,
isLoading, error,
passkeySupported,
handlePasskeyLogin,
handleMagicLink,
handleResendMagicLink,
clearError,
}) => (
<div>
{error && <p className="error">{error}</p>}
{mode === 'passkey' && (
<div>
<button
onClick={handlePasskeyLogin}
disabled={!passkeySupported || isLoading}
>
{isLoading ? 'Signing in...' : 'Sign in with Passkey'}
</button>
<button onClick={() => { clearError(); setMode('magic-link'); }}>
Sign in with Email
</button>
</div>
)}
{mode === 'magic-link' && (
<div>
<input
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); clearError(); }}
placeholder="[email protected]"
/>
<button
onClick={handleMagicLink}
disabled={isLoading || !email}
>
{isLoading ? 'Sending...' : 'Send sign-in link'}
</button>
{passkeySupported && (
<button onClick={() => { clearError(); setMode('passkey'); }}>
← Back to passkey sign-in
</button>
)}
</div>
)}
{mode === 'magic-link-sent' && (
<div className="text-center">
<h2>Check your email</h2>
<p>We sent a sign-in link to {email}</p>
<button onClick={handleResendMagicLink} disabled={isLoading}>
{isLoading ? 'Resending...' : 'Resend link'}
</button>
<button onClick={() => { clearError(); setMode('magic-link'); }}>
Try a different email
</button>
</div>
)}
</div>
)}
</LoginForm>
</div>
);
}Complete Rewards Dashboard
'use client';
import {
useAkinAuth,
useAkinLoyalty,
TierCard,
TierProgress,
TransactionList,
PointsDisplay,
} from '@akin-online/partner-sdk';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function RewardsPage() {
const router = useRouter();
const { member, isAuthenticated, isLoading: authLoading, signOut } = useAkinAuth();
const { isLoading: loyaltyLoading } = useAkinLoyalty();
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, authLoading, router]);
if (authLoading || loyaltyLoading) {
return <p>Loading...</p>;
}
if (!isAuthenticated) return null;
return (
<div className="rewards-container">
<header>
<h1>Welcome back, {member?.firstName}!</h1>
<p>Loyalty #: {member?.loyaltyNumber}</p>
<button onClick={() => signOut().then(() => router.push('/'))}>
Sign Out
</button>
</header>
<div className="grid grid-cols-2 gap-4">
<div className="card">
<h2>Your Points</h2>
<PointsDisplay>
{({ formattedPoints }) => (
<p className="text-4xl font-bold">{formattedPoints}</p>
)}
</PointsDisplay>
</div>
<div className="card">
<h2>Current Tier</h2>
<TierCard>
{({ displayName, tier }) => (
<div>
<p className="text-2xl font-bold">{displayName}</p>
<p className="text-sm">Tier {tier.replace('TIER', '')}</p>
</div>
)}
</TierCard>
</div>
</div>
<div className="card">
<h2>Progress to Next Tier</h2>
<TierProgress>
{({ currentTierName, nextTierName, percentage, pointsNeeded }) => (
nextTierName ? (
<div>
<div className="flex justify-between">
<span>{currentTierName}</span>
<span>{nextTierName}</span>
</div>
<div className="progress-bar">
<div style={{ width: `${percentage}%` }} />
</div>
<p>{pointsNeeded.toLocaleString()} points to {nextTierName}</p>
</div>
) : (
<p>You've reached the highest tier!</p>
)
)}
</TierProgress>
</div>
<div className="card">
<h2>Recent Activity</h2>
<TransactionList limit={10}>
{({ transactions, isLoading, hasMore, loadMore }) => (
<div>
{transactions.length === 0 ? (
<p>No transactions yet</p>
) : (
<>
<ul>
{transactions.map((tx) => (
<li key={tx.id} className="flex justify-between py-3">
<div>
<p>{tx.reason || tx.description || 'Transaction'}</p>
<p className="text-sm text-gray-500">
{new Date(tx.createdAt).toLocaleDateString()}
</p>
</div>
<span className={tx.pointsChange > 0 ? 'text-green-600' : 'text-red-600'}>
{tx.pointsChange > 0 ? '+' : ''}{tx.pointsChange.toLocaleString()}
</span>
</li>
))}
</ul>
{hasMore && (
<button onClick={loadMore} disabled={isLoading}>
{isLoading ? 'Loading...' : 'Load More'}
</button>
)}
</>
)}
</div>
)}
</TransactionList>
</div>
</div>
);
}Development / Examples
Viajero Demo
A complete example implementation is available in the monorepo at examples/viajero/. This demonstrates a partner rewards site using the SDK with:
- Login page with passkey and magic link authentication
- Signup form with passwordless registration
- Rewards dashboard with loyalty card, tier cards, tier benefits, upcoming stays, and activity statement
- Full Tailwind CSS styling with shadcn/ui components
Running the demo:
# From monorepo root
cd examples/viajero
npm install
npm run devThe demo runs at http://localhost:3003
Demo structure:
viajero/
├── app/
│ ├── layout.tsx # AkinProvider setup
│ ├── page.tsx # Landing page
│ ├── login/ # Login with passkey/magic link
│ ├── signup/ # Passwordless signup
│ ├── account/ # Authenticated rewards dashboard
│ ├── settings/ # Account settings
│ └── auth/verify/ # Magic link verification
├── components/ # shadcn/ui components
└── .env.local.example # Environment variables templateLocal SDK Development
When developing the SDK itself, the demo uses a file reference to the local package:
{
"dependencies": {
"@akin-travel/partner-sdk": "file:../../packages/partner-sdk"
}
}After making changes to the SDK, rebuild and restart the demo:
# From monorepo root
npm run build -w @akin-online/partner-sdk
cd examples/viajero && npm run devTroubleshooting
"Loading..." stuck after passkey authentication
Ensure you're using the latest SDK version. The signInWithPasskey function properly resets loading state in the finally block.
Button text color not showing
Check for global CSS rules that override text colors. See CSS Styling Notes.
Phone input not styled
Import the phone input CSS: import 'react-phone-number-input/style.css';
Magic link not working
- Verify your
NEXT_PUBLIC_AKIN_API_KEYis set correctly - Check that your domain is in the allowed origins for your API key
- Ensure you have an
/auth/verifyroute to handle the callback
GraphQL errors
If you see errors about field types or non-existent fields, ensure you're using the latest SDK version. Common issues:
- Use
String!(notID!) for member/partner IDs in queries - Use
originStays(notoriginPartnerStays) - Use
eligibility(noteligibilityText)
Support
For questions or issues, contact your Akin partner representative or email [email protected]
