npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@akin-travel/partner-sdk

v2.1.0

Published

SDK for third-party partners to integrate Akin auth and loyalty services

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_TOKEN

Security Note: Never commit your .npmrc file with tokens to source control. Add it to .gitignore.

Installation

npm install @akin-travel/partner-sdk

Peer Dependencies

npm install react-phone-number-input

Quick 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

  • signUpPasswordless runs unauthenticated using the partner API key you configured in AkinProvider — no guest session is required.
  • Date of birth and nationality are required (v2, akin-travel/issues#4140). dateOfBirth is YYYY-MM-DD and the guest must be 18 or over; nationality is the guest's citizenship as an ISO 3166-1 alpha-2 code, which is not countryCode (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 headless SignupForm exposes dateOfBirth / setDateOfBirth and nationality / setNationality and requires both on its passwordless path.
  • Already-registered email: the call resolves { success: false, error } (no duplicate is created), where error asks 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 locale from 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 order
  • requirementKeys - Unique requirement keys sorted by priority
  • getRequirementLabel(key, displayName?) - Get display label for a key
  • formatValue(value, operator) - Format value with operator (e.g., "10+")
  • getRequirement(tier, key) - Get requirement for tier/key
  • partnerName - Partner name used for dynamic labels
  • isLoading - 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 order
  • formatRequirement(req) - Format requirement (e.g., "10+ Origin stays")
  • getTextColor(bgColor) - Get contrasting text color for background
  • isLoading - 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 with tierName, rewardNames, and optional groups
  • tierConfigs - Tier configs sorted by display order (for colors/names)
  • getTierConfig(tierName) - Get tier config for a given tier name
  • getTextColor(bgColor) - Get contrasting text color for background
  • isLoading - 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-id

The 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*.local

CORS

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 dev

The 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 template

Local 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 dev

Troubleshooting

"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

  1. Verify your NEXT_PUBLIC_AKIN_API_KEY is set correctly
  2. Check that your domain is in the allowed origins for your API key
  3. Ensure you have an /auth/verify route 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! (not ID!) for member/partner IDs in queries
  • Use originStays (not originPartnerStays)
  • Use eligibility (not eligibilityText)

Support

For questions or issues, contact your Akin partner representative or email [email protected]