@akin-travel/partner-sdk
v2.1.0
Published
SDK for third-party partners to integrate Akin auth and loyalty services
Maintainers
Readme
@akin-travel/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
Architecture
As of v1.1, this package is a thin React binding over
@akin-travel/sdk-core —
the framework-agnostic core that owns auth/token lifecycle, the GraphQL client
and partner operations, error mapping, and the shared auth/loyalty domain types.
The web binding contributes platform adapters that implement core's contracts and
are wired into AkinProvider by default — so you keep using the SDK exactly as
before:
| Core contract | Web implementation |
|---|---|
| FirebaseAuthClient | firebase/auth (browser persistence) |
| Authenticator | @simplewebauthn/browser passkey ceremony |
| StorageAdapter | window.localStorage |
The shared domain types (AkinMember, TierConfig, LoyaltyTransaction, …) are
re-exported from this package for backwards compatibility, but are defined once
in core. sdk-core is installed automatically as a dependency.
Getting Access
The @akin-travel/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-travel/partner-sdk package |
| Partner ID | Your unique identifier in the Akin system |
| API key | For authenticating with the Akin API |
| Firebase / GIP config | The firebase block and gipTenantId you pass in the SDK config (required for member sign-in) |
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-travel/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-travel/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
// Required for member sign-in: the Firebase / GIP config from onboarding.
firebase: {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID!,
},
gipTenantId: process.env.NEXT_PUBLIC_GIP_TENANT_ID!,
}}
>
{children}
</AkinProvider>
);
}2. Use auth hooks
import { useAkinAuth } from '@akin-travel/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-travel/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
// Required for member sign-in (provided by Akin during onboarding). The SDK
// has no default for these and reads no environment variables.
firebase?: { // Firebase/GIP config
apiKey: string;
authDomain: string;
projectId: string;
appId: string;
};
gipTenantId?: string; // GIP tenant ID
// Optional
environment?: 'production' | 'staging' | 'development'; // default: 'production'
apiUrl?: string; // API endpoint override. Default per environment:
// production https://api.akintravel.com/graphql
// staging https://sandbox.akintravel.com/graphql
// development http://localhost:4000/graphql
debug?: boolean; // Enable console logging (default: false)
}Upgrading to 2.0: the SDK reads no environment variables
Before 2.0 the SDK read these variables itself. Next.js inlined them into the
SDK even from node_modules, so a partner could set them and pass nothing in
the config. From 2.0 the SDK reads none of them (akin-travel/issues#7581), and
a config without a firebase block cannot sign members in. Pass each value in
the config instead:
| Variable the SDK used to read | Pass instead |
|---|---|
| NEXT_PUBLIC_AKIN_SDK_FIREBASE_API_KEY | firebase.apiKey |
| NEXT_PUBLIC_AKIN_SDK_FIREBASE_AUTH_DOMAIN | firebase.authDomain |
| NEXT_PUBLIC_AKIN_SDK_FIREBASE_PROJECT_ID | firebase.projectId |
| NEXT_PUBLIC_AKIN_SDK_FIREBASE_APP_ID | firebase.appId |
| NEXT_PUBLIC_AKIN_SDK_GIP_TENANT_ID | gipTenantId |
| NEXT_PUBLIC_AKIN_SDK_API_URL, …_STAGING_API_URL, …_DEV_API_URL | nothing: environment picks the URL. Pass apiUrl only for a different endpoint |
config={{
partnerId, apiKey,
firebase: {
apiKey: process.env.NEXT_PUBLIC_AKIN_SDK_FIREBASE_API_KEY!,
authDomain: process.env.NEXT_PUBLIC_AKIN_SDK_FIREBASE_AUTH_DOMAIN!,
projectId: process.env.NEXT_PUBLIC_AKIN_SDK_FIREBASE_PROJECT_ID!,
appId: process.env.NEXT_PUBLIC_AKIN_SDK_FIREBASE_APP_ID!,
},
gipTenantId: process.env.NEXT_PUBLIC_AKIN_SDK_GIP_TENANT_ID!,
}}You can keep the old variable names: your app reads them now, not the SDK.
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-travel/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
/>Recipes
Auto-register guests at checkout
Let a non-member join the loyalty program straight from your checkout page,
without leaving it. When the guest ticks an opt-in box, reuse the
"primary guest" (Huésped principal) details they already entered and call
signUpPasswordless — the SDK attributes the new member to your partner
(partnerId comes from your AkinProvider config), the Akin backend creates
the member, enrolls them in your program, and emails a verification magic link.
No redirect, no second form.
import { useState } from 'react';
import { useAkinAuth, PhoneInput } from '@akin-travel/partner-sdk';
export function CheckoutGuestForm({ onRegistered }: { onRegistered?: () => void }) {
const { signUpPasswordless } = useAkinAuth();
// "Huésped principal" fields
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [countryCode, setCountryCode] = useState('CO'); // ISO country (e.g. from "País")
const [locale, setLocale] = useState('es'); // "Idioma de preferencia"
// Required since v2 (akin-travel/issues#4140): AKIN members must be 18+, and
// the API refuses a sign-up without a date of birth and a nationality.
const [dateOfBirth, setDateOfBirth] = useState(''); // "Fecha de nacimiento", YYYY-MM-DD
const [nationality, setNationality] = useState(''); // "Nacionalidad", ISO 3166-1 alpha-2
// Loyalty opt-in. Leave UNCHECKED by default — joining is an explicit,
// active opt-in (GDPR-friendly), not a pre-ticked box the guest has to undo.
const [joinLoyalty, setJoinLoyalty] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// ... your existing payment / reservation submission ...
if (joinLoyalty) {
const { success, error } = await signUpPasswordless({
firstName,
lastName,
email,
phone,
countryCode,
locale, // localizes the verification email
dateOfBirth, // YYYY-MM-DD — the guest must be 18 or over
nationality, // ISO 3166-1 alpha-2 citizenship, e.g. 'AR' — NOT countryCode
termsAccepted: true,
marketingOptIn: false,
});
if (success) onRegistered?.();
else console.warn('Loyalty signup failed (booking still succeeds):', error);
}
};
return (
<form onSubmit={handleSubmit}>
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Nombre" required />
<input value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Apellido" required />
<select value={countryCode} onChange={(e) => setCountryCode(e.target.value)}>
{/* País options */}
</select>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Correo" required />
<PhoneInput value={phone} onChange={(v) => setPhone(v || '')} defaultCountry="CO" />
<input type="date" value={dateOfBirth} onChange={(e) => setDateOfBirth(e.target.value)} required />
<select value={nationality} onChange={(e) => setNationality(e.target.value)} required>
{/* Nacionalidad — the full ISO 3166-1 list, valued by alpha-2 code */}
</select>
<select value={locale} onChange={(e) => setLocale(e.target.value)}>
<option value="es">Español</option>
<option value="en">English</option>
<option value="pt">Português</option>
</select>
<label>
<input
type="checkbox"
checked={joinLoyalty}
onChange={(e) => setJoinLoyalty(e.target.checked)}
/>
¿Ya conoces el programa? Únete para conocer los beneficios
</label>
<button type="submit">Finalizar compra</button>
</form>
);
}Notes
signUpPasswordlessruns unauthenticated using the partner API key you configured inAkinProvider— no guest session is required.- Date of birth and nationality are required (v2, akin-travel/issues#4140).
dateOfBirthisYYYY-MM-DDand the guest must be 18 or over;nationalityis the guest's citizenship as an ISO 3166-1 alpha-2 code, which is notcountryCode(the phone number's country). Once AKIN turns the requirement on, a sign-up without them resolves{ success: false, error }, and a future, malformed or under-18 date, or a code that is not ISO 3166-1, is refused whether or not the requirement is on. The headlessSignupFormexposesdateOfBirth/setDateOfBirthandnationality/setNationalityand requires both on itspasswordlesspath. - Already-registered email: the call resolves
{ success: false, error }(no duplicate is created), whereerrorasks the guest to sign in — signing in then adds them to your program automatically. Surface a sign-in prompt for this case rather than treating it as a hard failure. - Treat a failed signup as non-fatal to the booking — the reservation should complete regardless; the guest can join later.
- Set
localefrom the guest's preferred-language field so the verification email matches the language they booked in. Omit it to fall back to the default.
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-travel/partner-sdk';
<TierRequirementsTable partnerName="Northwind">
{({ 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-travel/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-travel/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-travel/partner-sdk';
// Get a label for a requirement key
getLabel('stays_member_origin'); // "Origin stays"
getLabel('stays_partner', 'Northwind'); // "Northwind 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']('Northwind'); // "Northwind 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;
// Your programme's own tier names, from the tier config's displayName. Both
// are null until the configs load, and for a tier with no config — we never
// substitute our internal identifier ('TIER2') or another tier's name.
currentTierName: string | null;
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 - Firebase / GIP config (provided by Akin during onboarding).
# These names are yours: the SDK reads no environment variables itself, so pass
# each value into the `firebase` block and `gipTenantId` of the SDK config.
NEXT_PUBLIC_GIP_TENANT_ID=your-gip-tenant-id
NEXT_PUBLIC_FIREBASE_API_KEY=your-firebase-api-key
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-firebase-project-id
NEXT_PUBLIC_FIREBASE_APP_ID=your-firebase-app-idThe API endpoint needs no variable: the SDK picks it from environment. Pass apiUrl only to override it.
Security 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)
Keep the Firebase credentials and GIP tenant ID in environment variables and pass them into the SDK config. 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-travel/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-travel/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-travel/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-travel/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 }) => (
<div>
{/* Your programme's own name for the tier — null until the tier
configs load, and for a member with no tier. Never render
`tier` itself: it is our internal identifier ('TIER2'). */}
<p className="text-2xl font-bold">{displayName ?? '—'}</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-travel/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]
