@akin-travel/partner-sdk 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1204 -0
- package/dist/account/components/AccountInfoSection.d.ts +46 -0
- package/dist/account/components/AccountInfoSection.d.ts.map +1 -0
- package/dist/account/components/AccountInfoSection.js +52 -0
- package/dist/account/components/AccountInfoSection.js.map +1 -0
- package/dist/account/components/NotificationPreferencesSection.d.ts +40 -0
- package/dist/account/components/NotificationPreferencesSection.d.ts.map +1 -0
- package/dist/account/components/NotificationPreferencesSection.js +116 -0
- package/dist/account/components/NotificationPreferencesSection.js.map +1 -0
- package/dist/account/components/PasskeySection.d.ts +49 -0
- package/dist/account/components/PasskeySection.d.ts.map +1 -0
- package/dist/account/components/PasskeySection.js +298 -0
- package/dist/account/components/PasskeySection.js.map +1 -0
- package/dist/account/hooks/useAccountForm.d.ts +23 -0
- package/dist/account/hooks/useAccountForm.d.ts.map +1 -0
- package/dist/account/hooks/useAccountForm.js +133 -0
- package/dist/account/hooks/useAccountForm.js.map +1 -0
- package/dist/account/index.d.ts +10 -0
- package/dist/account/index.d.ts.map +1 -0
- package/dist/account/index.js +21 -0
- package/dist/account/index.js.map +1 -0
- package/dist/auth/AkinAuthProvider.d.ts +31 -0
- package/dist/auth/AkinAuthProvider.d.ts.map +1 -0
- package/dist/auth/AkinAuthProvider.js +632 -0
- package/dist/auth/AkinAuthProvider.js.map +1 -0
- package/dist/auth/components/LoginForm.d.ts +63 -0
- package/dist/auth/components/LoginForm.d.ts.map +1 -0
- package/dist/auth/components/LoginForm.js +230 -0
- package/dist/auth/components/LoginForm.js.map +1 -0
- package/dist/auth/components/MagicLinkForm.d.ts +41 -0
- package/dist/auth/components/MagicLinkForm.d.ts.map +1 -0
- package/dist/auth/components/MagicLinkForm.js +88 -0
- package/dist/auth/components/MagicLinkForm.js.map +1 -0
- package/dist/auth/components/RequireAuth.d.ts +62 -0
- package/dist/auth/components/RequireAuth.d.ts.map +1 -0
- package/dist/auth/components/RequireAuth.js +63 -0
- package/dist/auth/components/RequireAuth.js.map +1 -0
- package/dist/auth/components/RequireGuest.d.ts +60 -0
- package/dist/auth/components/RequireGuest.d.ts.map +1 -0
- package/dist/auth/components/RequireGuest.js +64 -0
- package/dist/auth/components/RequireGuest.js.map +1 -0
- package/dist/auth/components/SignupForm.d.ts +45 -0
- package/dist/auth/components/SignupForm.d.ts.map +1 -0
- package/dist/auth/components/SignupForm.js +167 -0
- package/dist/auth/components/SignupForm.js.map +1 -0
- package/dist/auth/index.d.ts +10 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +21 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/client.d.ts +17 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +136 -0
- package/dist/client.js.map +1 -0
- package/dist/components/PhoneInput.d.ts +62 -0
- package/dist/components/PhoneInput.d.ts.map +1 -0
- package/dist/components/PhoneInput.js +65 -0
- package/dist/components/PhoneInput.js.map +1 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +9 -0
- package/dist/components/index.js.map +1 -0
- package/dist/config.d.ts +111 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +56 -0
- package/dist/config.js.map +1 -0
- package/dist/constants/preferences.d.ts +22 -0
- package/dist/constants/preferences.d.ts.map +1 -0
- package/dist/constants/preferences.js +52 -0
- package/dist/constants/preferences.js.map +1 -0
- package/dist/currency/CurrencyProvider.d.ts +46 -0
- package/dist/currency/CurrencyProvider.d.ts.map +1 -0
- package/dist/currency/CurrencyProvider.js +145 -0
- package/dist/currency/CurrencyProvider.js.map +1 -0
- package/dist/currency/components/CurrencySelector.d.ts +43 -0
- package/dist/currency/components/CurrencySelector.d.ts.map +1 -0
- package/dist/currency/components/CurrencySelector.js +58 -0
- package/dist/currency/components/CurrencySelector.js.map +1 -0
- package/dist/currency/exchangeRates.d.ts +40 -0
- package/dist/currency/exchangeRates.d.ts.map +1 -0
- package/dist/currency/exchangeRates.js +90 -0
- package/dist/currency/exchangeRates.js.map +1 -0
- package/dist/currency/index.d.ts +6 -0
- package/dist/currency/index.d.ts.map +1 -0
- package/dist/currency/index.js +20 -0
- package/dist/currency/index.js.map +1 -0
- package/dist/header/components/CurrencyAccordion.d.ts +45 -0
- package/dist/header/components/CurrencyAccordion.d.ts.map +1 -0
- package/dist/header/components/CurrencyAccordion.js +54 -0
- package/dist/header/components/CurrencyAccordion.js.map +1 -0
- package/dist/header/components/HeaderMenu.d.ts +49 -0
- package/dist/header/components/HeaderMenu.d.ts.map +1 -0
- package/dist/header/components/HeaderMenu.js +95 -0
- package/dist/header/components/HeaderMenu.js.map +1 -0
- package/dist/header/components/LanguageAccordion.d.ts +45 -0
- package/dist/header/components/LanguageAccordion.d.ts.map +1 -0
- package/dist/header/components/LanguageAccordion.js +54 -0
- package/dist/header/components/LanguageAccordion.js.map +1 -0
- package/dist/header/components/UserAvatar.d.ts +26 -0
- package/dist/header/components/UserAvatar.d.ts.map +1 -0
- package/dist/header/components/UserAvatar.js +46 -0
- package/dist/header/components/UserAvatar.js.map +1 -0
- package/dist/header/index.d.ts +10 -0
- package/dist/header/index.d.ts.map +1 -0
- package/dist/header/index.js +13 -0
- package/dist/header/index.js.map +1 -0
- package/dist/i18n/I18nProvider.d.ts +57 -0
- package/dist/i18n/I18nProvider.d.ts.map +1 -0
- package/dist/i18n/I18nProvider.js +205 -0
- package/dist/i18n/I18nProvider.js.map +1 -0
- package/dist/i18n/components/LanguageSelector.d.ts +43 -0
- package/dist/i18n/components/LanguageSelector.d.ts.map +1 -0
- package/dist/i18n/components/LanguageSelector.js +57 -0
- package/dist/i18n/components/LanguageSelector.js.map +1 -0
- package/dist/i18n/index.d.ts +5 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +14 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/i18n/translations/en.json +283 -0
- package/dist/i18n/translations/es.json +283 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +191 -0
- package/dist/index.js.map +1 -0
- package/dist/loyalty/AkinLoyaltyProvider.d.ts +18 -0
- package/dist/loyalty/AkinLoyaltyProvider.d.ts.map +1 -0
- package/dist/loyalty/AkinLoyaltyProvider.js +399 -0
- package/dist/loyalty/AkinLoyaltyProvider.js.map +1 -0
- package/dist/loyalty/components/LoyaltyCard.d.ts +48 -0
- package/dist/loyalty/components/LoyaltyCard.d.ts.map +1 -0
- package/dist/loyalty/components/LoyaltyCard.js +140 -0
- package/dist/loyalty/components/LoyaltyCard.js.map +1 -0
- package/dist/loyalty/components/PointsDisplay.d.ts +40 -0
- package/dist/loyalty/components/PointsDisplay.d.ts.map +1 -0
- package/dist/loyalty/components/PointsDisplay.js +32 -0
- package/dist/loyalty/components/PointsDisplay.js.map +1 -0
- package/dist/loyalty/components/PreviousStays.d.ts +59 -0
- package/dist/loyalty/components/PreviousStays.d.ts.map +1 -0
- package/dist/loyalty/components/PreviousStays.js +101 -0
- package/dist/loyalty/components/PreviousStays.js.map +1 -0
- package/dist/loyalty/components/SimpleTierCards.d.ts +51 -0
- package/dist/loyalty/components/SimpleTierCards.d.ts.map +1 -0
- package/dist/loyalty/components/SimpleTierCards.js +96 -0
- package/dist/loyalty/components/SimpleTierCards.js.map +1 -0
- package/dist/loyalty/components/TierCard.d.ts +30 -0
- package/dist/loyalty/components/TierCard.d.ts.map +1 -0
- package/dist/loyalty/components/TierCard.js +41 -0
- package/dist/loyalty/components/TierCard.js.map +1 -0
- package/dist/loyalty/components/TierProgress.d.ts +32 -0
- package/dist/loyalty/components/TierProgress.d.ts.map +1 -0
- package/dist/loyalty/components/TierProgress.js +41 -0
- package/dist/loyalty/components/TierProgress.js.map +1 -0
- package/dist/loyalty/components/TierRequirementsTable.d.ts +54 -0
- package/dist/loyalty/components/TierRequirementsTable.d.ts.map +1 -0
- package/dist/loyalty/components/TierRequirementsTable.js +104 -0
- package/dist/loyalty/components/TierRequirementsTable.js.map +1 -0
- package/dist/loyalty/components/TransactionList.d.ts +44 -0
- package/dist/loyalty/components/TransactionList.d.ts.map +1 -0
- package/dist/loyalty/components/TransactionList.js +112 -0
- package/dist/loyalty/components/TransactionList.js.map +1 -0
- package/dist/loyalty/components/UpcomingStays.d.ts +49 -0
- package/dist/loyalty/components/UpcomingStays.d.ts.map +1 -0
- package/dist/loyalty/components/UpcomingStays.js +60 -0
- package/dist/loyalty/components/UpcomingStays.js.map +1 -0
- package/dist/loyalty/index.d.ts +12 -0
- package/dist/loyalty/index.d.ts.map +1 -0
- package/dist/loyalty/index.js +27 -0
- package/dist/loyalty/index.js.map +1 -0
- package/dist/types/account.d.ts +108 -0
- package/dist/types/account.d.ts.map +1 -0
- package/dist/types/account.js +3 -0
- package/dist/types/account.js.map +1 -0
- package/dist/types/auth.d.ts +205 -0
- package/dist/types/auth.d.ts.map +1 -0
- package/dist/types/auth.js +3 -0
- package/dist/types/auth.js.map +1 -0
- package/dist/types/currency.d.ts +102 -0
- package/dist/types/currency.d.ts.map +1 -0
- package/dist/types/currency.js +3 -0
- package/dist/types/currency.js.map +1 -0
- package/dist/types/header.d.ts +105 -0
- package/dist/types/header.d.ts.map +1 -0
- package/dist/types/header.js +3 -0
- package/dist/types/header.js.map +1 -0
- package/dist/types/i18n.d.ts +90 -0
- package/dist/types/i18n.d.ts.map +1 -0
- package/dist/types/i18n.js +3 -0
- package/dist/types/i18n.js.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/loyalty.d.ts +312 -0
- package/dist/types/loyalty.d.ts.map +1 -0
- package/dist/types/loyalty.js +3 -0
- package/dist/types/loyalty.js.map +1 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +10 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/tierLabels.d.ts +27 -0
- package/dist/utils/tierLabels.d.ts.map +1 -0
- package/dist/utils/tierLabels.js +110 -0
- package/dist/utils/tierLabels.js.map +1 -0
- package/package.json +60 -0
package/README.md
ADDED
|
@@ -0,0 +1,1204 @@
|
|
|
1
|
+
# @akin-online/partner-sdk
|
|
2
|
+
|
|
3
|
+
SDK for third-party partners to integrate Akin auth and loyalty services into their React applications.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Authentication** - Magic links (passwordless), passkeys (WebAuthn), Google OAuth
|
|
8
|
+
- **Loyalty** - Tier display, points balance, transaction history, progress tracking
|
|
9
|
+
- **Tier Requirements** - Flexible key/value qualification rules with display utilities
|
|
10
|
+
- **Headless Components** - Full control over your UI with render props
|
|
11
|
+
- **TypeScript** - Full type support
|
|
12
|
+
- **Phone Input** - International phone number input with validation
|
|
13
|
+
|
|
14
|
+
## Getting Access
|
|
15
|
+
|
|
16
|
+
The `@akin-online/partner-sdk` is a **private npm package** available to authorized Akin partners.
|
|
17
|
+
|
|
18
|
+
### Partner Onboarding
|
|
19
|
+
|
|
20
|
+
Contact your Akin partner representative or email partners@akintravel.com to request access. You'll receive the following via secure channel:
|
|
21
|
+
|
|
22
|
+
| Credential | Purpose |
|
|
23
|
+
|------------|---------|
|
|
24
|
+
| **npm access token** | For installing the private `@akin-online/partner-sdk` package |
|
|
25
|
+
| **Partner ID** | Your unique identifier in the Akin system |
|
|
26
|
+
| **API key** | For authenticating with the Akin API |
|
|
27
|
+
| **SDK environment variables** | Firebase/GIP credentials and API URLs (`NEXT_PUBLIC_AKIN_SDK_*`) |
|
|
28
|
+
|
|
29
|
+
### Configure npm Access
|
|
30
|
+
|
|
31
|
+
Add the npm access token to your `.npmrc` file:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# ~/.npmrc or project .npmrc
|
|
35
|
+
//registry.npmjs.org/:_authToken=YOUR_NPM_TOKEN
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Security Note:** Never commit your `.npmrc` file with tokens to source control. Add it to `.gitignore`.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install @akin-online/partner-sdk
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Peer Dependencies
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm install react-phone-number-input
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
### 1. Wrap your app with AkinProvider
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
// app/layout.tsx or _app.tsx
|
|
58
|
+
import { AkinProvider } from '@akin-online/partner-sdk';
|
|
59
|
+
|
|
60
|
+
export default function RootLayout({ children }) {
|
|
61
|
+
return (
|
|
62
|
+
<AkinProvider
|
|
63
|
+
config={{
|
|
64
|
+
partnerId: process.env.NEXT_PUBLIC_AKIN_PARTNER_ID!,
|
|
65
|
+
apiKey: process.env.NEXT_PUBLIC_AKIN_API_KEY!,
|
|
66
|
+
environment: 'production',
|
|
67
|
+
debug: false, // Enable for development troubleshooting
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
{children}
|
|
71
|
+
</AkinProvider>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 2. Use auth hooks
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
import { useAkinAuth } from '@akin-online/partner-sdk';
|
|
80
|
+
|
|
81
|
+
function Profile() {
|
|
82
|
+
const { member, isAuthenticated, signOut } = useAkinAuth();
|
|
83
|
+
|
|
84
|
+
if (!isAuthenticated) {
|
|
85
|
+
return <p>Please sign in</p>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div>
|
|
90
|
+
<h1>Welcome, {member?.firstName}!</h1>
|
|
91
|
+
<p>Loyalty #: {member?.loyaltyNumber}</p>
|
|
92
|
+
<button onClick={signOut}>Sign Out</button>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 3. Use headless components for custom UI
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
import { LoginForm, TierCard, PointsDisplay } from '@akin-online/partner-sdk';
|
|
102
|
+
|
|
103
|
+
function LoginPage() {
|
|
104
|
+
return (
|
|
105
|
+
<LoginForm onSuccess={() => router.push('/rewards')}>
|
|
106
|
+
{({
|
|
107
|
+
mode,
|
|
108
|
+
setMode,
|
|
109
|
+
email,
|
|
110
|
+
setEmail,
|
|
111
|
+
isLoading,
|
|
112
|
+
error,
|
|
113
|
+
passkeySupported,
|
|
114
|
+
handlePasskeyLogin,
|
|
115
|
+
handleMagicLink,
|
|
116
|
+
clearError,
|
|
117
|
+
}) => (
|
|
118
|
+
<div className="your-styles">
|
|
119
|
+
{/* Passkey login (recommended default) */}
|
|
120
|
+
{mode === 'passkey' && (
|
|
121
|
+
<>
|
|
122
|
+
<button onClick={handlePasskeyLogin} disabled={!passkeySupported || isLoading}>
|
|
123
|
+
Sign in with Passkey
|
|
124
|
+
</button>
|
|
125
|
+
<button onClick={() => setMode('magic-link')}>
|
|
126
|
+
Sign in with Email
|
|
127
|
+
</button>
|
|
128
|
+
</>
|
|
129
|
+
)}
|
|
130
|
+
|
|
131
|
+
{/* Magic link (email) login */}
|
|
132
|
+
{mode === 'magic-link' && (
|
|
133
|
+
<>
|
|
134
|
+
<input
|
|
135
|
+
type="email"
|
|
136
|
+
value={email}
|
|
137
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
138
|
+
placeholder="Email"
|
|
139
|
+
/>
|
|
140
|
+
<button onClick={handleMagicLink} disabled={isLoading || !email}>
|
|
141
|
+
Send sign-in link
|
|
142
|
+
</button>
|
|
143
|
+
</>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{/* Magic link sent confirmation */}
|
|
147
|
+
{mode === 'magic-link-sent' && (
|
|
148
|
+
<p>Check your email for a sign-in link!</p>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{error && <p className="error">{error}</p>}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</LoginForm>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Configuration
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
interface AkinSDKConfig {
|
|
163
|
+
// Required
|
|
164
|
+
partnerId: string; // Your partner ID (provided by Akin)
|
|
165
|
+
apiKey: string; // Your API key (provided by Akin)
|
|
166
|
+
|
|
167
|
+
// Optional - defaults to NEXT_PUBLIC_AKIN_SDK_* env vars
|
|
168
|
+
apiUrl?: string; // API endpoint (default: NEXT_PUBLIC_AKIN_SDK_API_URL)
|
|
169
|
+
environment?: 'production' | 'staging' | 'development';
|
|
170
|
+
debug?: boolean; // Enable console logging (default: false)
|
|
171
|
+
firebase?: { // Firebase/GIP config (default: NEXT_PUBLIC_AKIN_SDK_FIREBASE_* env vars)
|
|
172
|
+
apiKey: string;
|
|
173
|
+
authDomain: string;
|
|
174
|
+
projectId: string;
|
|
175
|
+
appId: string;
|
|
176
|
+
};
|
|
177
|
+
gipTenantId?: string; // GIP tenant ID (default: NEXT_PUBLIC_AKIN_SDK_GIP_TENANT_ID)
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Authentication Methods
|
|
182
|
+
|
|
183
|
+
The SDK supports three passwordless authentication methods:
|
|
184
|
+
|
|
185
|
+
### 1. Passkeys (WebAuthn) - Recommended
|
|
186
|
+
|
|
187
|
+
Biometric/device authentication. Most secure and user-friendly.
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
const { signInWithPasskey } = useAkinAuth();
|
|
191
|
+
|
|
192
|
+
// Check if passkeys are supported
|
|
193
|
+
if (passkeySupported) {
|
|
194
|
+
await signInWithPasskey();
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### 2. Magic Links (Email)
|
|
199
|
+
|
|
200
|
+
Passwordless email verification links.
|
|
201
|
+
|
|
202
|
+
```tsx
|
|
203
|
+
const { requestMagicLink, verifyMagicLink } = useAkinAuth();
|
|
204
|
+
|
|
205
|
+
// Request a magic link
|
|
206
|
+
await requestMagicLink(email);
|
|
207
|
+
|
|
208
|
+
// Verify the token (on your /auth/verify page)
|
|
209
|
+
const result = await verifyMagicLink(token);
|
|
210
|
+
if (result.success) {
|
|
211
|
+
// User is authenticated
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### 3. Google OAuth
|
|
216
|
+
|
|
217
|
+
Social login with Google.
|
|
218
|
+
|
|
219
|
+
```tsx
|
|
220
|
+
const { signInWithGoogle } = useAkinAuth();
|
|
221
|
+
|
|
222
|
+
await signInWithGoogle();
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Auth API
|
|
226
|
+
|
|
227
|
+
### useAkinAuth Hook
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
const {
|
|
231
|
+
// State
|
|
232
|
+
user, // Firebase user object
|
|
233
|
+
member, // Akin member data (points, tier, etc.)
|
|
234
|
+
isAuthenticated, // Boolean
|
|
235
|
+
isLoading, // Boolean
|
|
236
|
+
error, // Error message or null
|
|
237
|
+
|
|
238
|
+
// Actions
|
|
239
|
+
signIn, // (email, password) => Promise<void>
|
|
240
|
+
signUp, // (data: SignUpData) => Promise<void>
|
|
241
|
+
signUpPasswordless, // (data) => Promise<{ success, error? }>
|
|
242
|
+
requestMagicLink, // (email) => Promise<{ success, error? }>
|
|
243
|
+
verifyMagicLink, // (token) => Promise<{ success, error? }>
|
|
244
|
+
signInWithGoogle, // () => Promise<void>
|
|
245
|
+
signInWithPasskey, // () => Promise<void>
|
|
246
|
+
signOut, // () => Promise<void>
|
|
247
|
+
resetPassword, // (email) => Promise<void>
|
|
248
|
+
refreshMemberData, // () => Promise<void>
|
|
249
|
+
clearError, // () => void
|
|
250
|
+
} = useAkinAuth();
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Headless Auth Components
|
|
254
|
+
|
|
255
|
+
#### LoginForm
|
|
256
|
+
|
|
257
|
+
Multi-mode login form supporting passkeys, magic links, and passwords.
|
|
258
|
+
|
|
259
|
+
```tsx
|
|
260
|
+
<LoginForm onSuccess={onSuccess} onError={onError}>
|
|
261
|
+
{({
|
|
262
|
+
// Mode management
|
|
263
|
+
mode, // 'passkey' | 'magic-link' | 'magic-link-sent' | 'password'
|
|
264
|
+
setMode, // (mode) => void
|
|
265
|
+
|
|
266
|
+
// Form state
|
|
267
|
+
email, setEmail,
|
|
268
|
+
password, setPassword,
|
|
269
|
+
isLoading, error,
|
|
270
|
+
|
|
271
|
+
// Passkey
|
|
272
|
+
passkeySupported, // Boolean - check before showing passkey option
|
|
273
|
+
handlePasskeyLogin,
|
|
274
|
+
|
|
275
|
+
// Magic link
|
|
276
|
+
handleMagicLink,
|
|
277
|
+
handleResendMagicLink,
|
|
278
|
+
|
|
279
|
+
// Password (if enabled)
|
|
280
|
+
handleSubmit,
|
|
281
|
+
|
|
282
|
+
// Google OAuth
|
|
283
|
+
handleGoogleLogin,
|
|
284
|
+
|
|
285
|
+
clearError,
|
|
286
|
+
}) => (
|
|
287
|
+
// Your JSX
|
|
288
|
+
)}
|
|
289
|
+
</LoginForm>
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
#### SignupForm
|
|
293
|
+
|
|
294
|
+
Registration form with optional passwordless mode.
|
|
295
|
+
|
|
296
|
+
```tsx
|
|
297
|
+
<SignupForm
|
|
298
|
+
onSuccess={onSuccess}
|
|
299
|
+
onError={onError}
|
|
300
|
+
passwordless={true} // Set to true for magic link signup
|
|
301
|
+
>
|
|
302
|
+
{({
|
|
303
|
+
firstName, setFirstName,
|
|
304
|
+
lastName, setLastName,
|
|
305
|
+
email, setEmail,
|
|
306
|
+
password, setPassword, // Only used if passwordless={false}
|
|
307
|
+
phone, setPhone,
|
|
308
|
+
termsAccepted, setTermsAccepted,
|
|
309
|
+
marketingOptIn, setMarketingOptIn,
|
|
310
|
+
isLoading, error,
|
|
311
|
+
handleSubmit,
|
|
312
|
+
handleGoogleSignup,
|
|
313
|
+
clearError,
|
|
314
|
+
}) => (
|
|
315
|
+
// Your JSX
|
|
316
|
+
)}
|
|
317
|
+
</SignupForm>
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
#### MagicLinkForm
|
|
321
|
+
|
|
322
|
+
Standalone magic link request form.
|
|
323
|
+
|
|
324
|
+
```tsx
|
|
325
|
+
<MagicLinkForm onSuccess={onSuccess}>
|
|
326
|
+
{({ email, setEmail, isLoading, error, success, handleSubmit }) => (
|
|
327
|
+
// Your JSX
|
|
328
|
+
)}
|
|
329
|
+
</MagicLinkForm>
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
#### PhoneInput
|
|
333
|
+
|
|
334
|
+
International phone number input with country selector.
|
|
335
|
+
|
|
336
|
+
```tsx
|
|
337
|
+
import { PhoneInput } from '@akin-online/partner-sdk';
|
|
338
|
+
import 'react-phone-number-input/style.css';
|
|
339
|
+
|
|
340
|
+
<PhoneInput
|
|
341
|
+
id="phone"
|
|
342
|
+
value={phone}
|
|
343
|
+
onChange={setPhone}
|
|
344
|
+
defaultCountry="US"
|
|
345
|
+
placeholder="Enter phone number"
|
|
346
|
+
className="your-input-class"
|
|
347
|
+
required
|
|
348
|
+
/>
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
## Loyalty API
|
|
352
|
+
|
|
353
|
+
### useAkinLoyalty Hook
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
const {
|
|
357
|
+
// State
|
|
358
|
+
points, // Current points balance
|
|
359
|
+
tier, // 'TIER1' | 'TIER2' | 'TIER3' | 'TIER4'
|
|
360
|
+
tierConfig, // Current tier configuration
|
|
361
|
+
tierProgress, // Progress to next tier
|
|
362
|
+
transactions, // Recent transactions
|
|
363
|
+
tierHistory, // Tier change history
|
|
364
|
+
isLoading, // Boolean
|
|
365
|
+
error, // Error message or null
|
|
366
|
+
|
|
367
|
+
// Actions
|
|
368
|
+
refreshData, // () => Promise<void>
|
|
369
|
+
getTransactionHistory, // (options?) => Promise<Transaction[]>
|
|
370
|
+
getTierHistory, // (limit?) => Promise<TierHistory[]>
|
|
371
|
+
} = useAkinLoyalty();
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Headless Loyalty Components
|
|
375
|
+
|
|
376
|
+
#### TierCard
|
|
377
|
+
|
|
378
|
+
```tsx
|
|
379
|
+
<TierCard>
|
|
380
|
+
{({ tier, displayName, color, icon, points, loyaltyNumber, maxPerks }) => (
|
|
381
|
+
// Your JSX
|
|
382
|
+
)}
|
|
383
|
+
</TierCard>
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
#### TierProgress
|
|
387
|
+
|
|
388
|
+
```tsx
|
|
389
|
+
<TierProgress>
|
|
390
|
+
{({
|
|
391
|
+
currentTier, currentTierName,
|
|
392
|
+
nextTier, nextTierName,
|
|
393
|
+
percentage, pointsNeeded,
|
|
394
|
+
totalStays, originStays, // Note: originStays (not originPartnerStays)
|
|
395
|
+
}) => (
|
|
396
|
+
nextTierName ? (
|
|
397
|
+
<div>
|
|
398
|
+
<progress value={percentage} max={100} />
|
|
399
|
+
<p>{pointsNeeded} points to {nextTierName}</p>
|
|
400
|
+
</div>
|
|
401
|
+
) : (
|
|
402
|
+
<p>You've reached the highest tier!</p>
|
|
403
|
+
)
|
|
404
|
+
)}
|
|
405
|
+
</TierProgress>
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
#### TransactionList
|
|
409
|
+
|
|
410
|
+
```tsx
|
|
411
|
+
<TransactionList limit={10}>
|
|
412
|
+
{({ transactions, isLoading, hasMore, loadMore }) => (
|
|
413
|
+
// Your JSX
|
|
414
|
+
)}
|
|
415
|
+
</TransactionList>
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
#### PointsDisplay
|
|
419
|
+
|
|
420
|
+
```tsx
|
|
421
|
+
<PointsDisplay>
|
|
422
|
+
{({ points, formattedPoints, isLoading }) => (
|
|
423
|
+
// Your JSX
|
|
424
|
+
)}
|
|
425
|
+
</PointsDisplay>
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
#### TierRequirementsTable
|
|
429
|
+
|
|
430
|
+
Display tier requirements in a table format. Perfect for "how to qualify" sections.
|
|
431
|
+
|
|
432
|
+
```tsx
|
|
433
|
+
import { TierRequirementsTable } from '@akin-online/partner-sdk';
|
|
434
|
+
|
|
435
|
+
<TierRequirementsTable partnerName="Haka">
|
|
436
|
+
{({ tierConfigs, requirementKeys, getRequirementLabel, formatValue, getRequirement, isLoading }) => (
|
|
437
|
+
<table>
|
|
438
|
+
<thead>
|
|
439
|
+
<tr>
|
|
440
|
+
<th>Requirement</th>
|
|
441
|
+
{tierConfigs.map(tier => (
|
|
442
|
+
<th key={tier.id}>{tier.displayName}</th>
|
|
443
|
+
))}
|
|
444
|
+
</tr>
|
|
445
|
+
</thead>
|
|
446
|
+
<tbody>
|
|
447
|
+
{requirementKeys.map(key => (
|
|
448
|
+
<tr key={key}>
|
|
449
|
+
<td>{getRequirementLabel(key)}</td>
|
|
450
|
+
{tierConfigs.map(tier => {
|
|
451
|
+
const req = getRequirement(tier, key);
|
|
452
|
+
return (
|
|
453
|
+
<td key={tier.id}>
|
|
454
|
+
{req ? formatValue(Number(req.requirementValue), req.operator) : '—'}
|
|
455
|
+
</td>
|
|
456
|
+
);
|
|
457
|
+
})}
|
|
458
|
+
</tr>
|
|
459
|
+
))}
|
|
460
|
+
</tbody>
|
|
461
|
+
</table>
|
|
462
|
+
)}
|
|
463
|
+
</TierRequirementsTable>
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
**Render Props:**
|
|
467
|
+
- `tierConfigs` - Tier configs sorted by display order
|
|
468
|
+
- `requirementKeys` - Unique requirement keys sorted by priority
|
|
469
|
+
- `getRequirementLabel(key, displayName?)` - Get display label for a key
|
|
470
|
+
- `formatValue(value, operator)` - Format value with operator (e.g., "10+")
|
|
471
|
+
- `getRequirement(tier, key)` - Get requirement for tier/key
|
|
472
|
+
- `partnerName` - Partner name used for dynamic labels
|
|
473
|
+
- `isLoading` - Loading state
|
|
474
|
+
|
|
475
|
+
#### SimpleTierCards
|
|
476
|
+
|
|
477
|
+
Display tier cards with requirements. Great for tier overview sections.
|
|
478
|
+
|
|
479
|
+
```tsx
|
|
480
|
+
import { SimpleTierCards } from '@akin-online/partner-sdk';
|
|
481
|
+
|
|
482
|
+
<SimpleTierCards partnerName="Haka">
|
|
483
|
+
{({ tierConfigs, formatRequirement, getTextColor, isLoading }) => (
|
|
484
|
+
<div className="grid grid-cols-4 gap-4">
|
|
485
|
+
{tierConfigs.map((tier, index) => (
|
|
486
|
+
<div
|
|
487
|
+
key={tier.id}
|
|
488
|
+
style={{
|
|
489
|
+
backgroundColor: tier.tierColor || '#382108',
|
|
490
|
+
color: getTextColor(tier.tierColor || '#382108'),
|
|
491
|
+
}}
|
|
492
|
+
className="p-4 rounded-lg"
|
|
493
|
+
>
|
|
494
|
+
<h3 className="text-2xl font-bold">{tier.displayName}</h3>
|
|
495
|
+
<span className="text-sm">Tier {index + 1}</span>
|
|
496
|
+
{tier.requirements?.filter(r => r.active).map(req => (
|
|
497
|
+
<p key={req.id} className="text-xs opacity-80">
|
|
498
|
+
{formatRequirement(req)}
|
|
499
|
+
</p>
|
|
500
|
+
))}
|
|
501
|
+
</div>
|
|
502
|
+
))}
|
|
503
|
+
</div>
|
|
504
|
+
)}
|
|
505
|
+
</SimpleTierCards>
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
**Render Props:**
|
|
509
|
+
- `tierConfigs` - Tier configs sorted by display order
|
|
510
|
+
- `formatRequirement(req)` - Format requirement (e.g., "10+ Origin stays")
|
|
511
|
+
- `getTextColor(bgColor)` - Get contrasting text color for background
|
|
512
|
+
- `isLoading` - Loading state
|
|
513
|
+
|
|
514
|
+
## Tier Label Utilities
|
|
515
|
+
|
|
516
|
+
Centralized labels for tier requirement keys.
|
|
517
|
+
|
|
518
|
+
```tsx
|
|
519
|
+
import { getLabel, TIER_LABELS, PARTNER_LABELS, KEY_PRIORITY, extractRequirementKeys } from '@akin-online/partner-sdk';
|
|
520
|
+
|
|
521
|
+
// Get a label for a requirement key
|
|
522
|
+
getLabel('stays_member_origin'); // "Origin stays"
|
|
523
|
+
getLabel('stays_partner', 'Haka'); // "Haka stays"
|
|
524
|
+
getLabel('points', 'Origin', 'Custom'); // "Custom" (displayName override)
|
|
525
|
+
|
|
526
|
+
// Static labels
|
|
527
|
+
TIER_LABELS['points']; // "AKIN points"
|
|
528
|
+
TIER_LABELS['stays_network']; // "Network stays"
|
|
529
|
+
|
|
530
|
+
// Dynamic labels (with partner name)
|
|
531
|
+
PARTNER_LABELS['stays_partner']('Haka'); // "Haka stays"
|
|
532
|
+
PARTNER_LABELS['achievement_tattoo']('Drifter'); // "Drifter tattoo"
|
|
533
|
+
|
|
534
|
+
// Extract unique requirement keys from tier configs (sorted by priority)
|
|
535
|
+
const keys = extractRequirementKeys(tierConfigs);
|
|
536
|
+
// ['stays_partner', 'stays_member_origin', 'points', ...]
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
**Available Labels:**
|
|
540
|
+
- `stays_member_origin` → "Origin stays"
|
|
541
|
+
- `stays_network` → "Network stays"
|
|
542
|
+
- `stays_partner` → "[Partner] stays"
|
|
543
|
+
- `nights_member_origin` → "Origin nights"
|
|
544
|
+
- `nights_network` → "Network nights"
|
|
545
|
+
- `nights_partner` → "[Partner] nights"
|
|
546
|
+
- `points` → "AKIN points"
|
|
547
|
+
- `network_points` → "Network points"
|
|
548
|
+
- `spend` → "Total spend"
|
|
549
|
+
- `referrals` → "Referrals"
|
|
550
|
+
- `achievement_tattoo` → "[Partner] tattoo"
|
|
551
|
+
- `achievement_invitation` → "Invitation"
|
|
552
|
+
- `country_completion_mx` → "Mexico complete"
|
|
553
|
+
- `country_completion_pe` → "Peru complete"
|
|
554
|
+
- `country_completion_co` → "Colombia complete"
|
|
555
|
+
|
|
556
|
+
## Types
|
|
557
|
+
|
|
558
|
+
### AkinMember
|
|
559
|
+
|
|
560
|
+
```typescript
|
|
561
|
+
interface AkinMember {
|
|
562
|
+
id: string;
|
|
563
|
+
email: string;
|
|
564
|
+
firstName: string;
|
|
565
|
+
lastName: string;
|
|
566
|
+
phone?: string;
|
|
567
|
+
points: number;
|
|
568
|
+
currentTier: 'TIER1' | 'TIER2' | 'TIER3' | 'TIER4';
|
|
569
|
+
status: 'ACTIVE' | 'SUSPENDED' | 'DELETED';
|
|
570
|
+
loyaltyNumber: string;
|
|
571
|
+
vibePreference?: string;
|
|
572
|
+
perkPreferences?: string[];
|
|
573
|
+
createdAt: Date;
|
|
574
|
+
updatedAt: Date;
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
### TierConfig
|
|
579
|
+
|
|
580
|
+
```typescript
|
|
581
|
+
interface TierConfig {
|
|
582
|
+
id: string;
|
|
583
|
+
tierName: string; // 'TIER1' | 'TIER2' | 'TIER3' | 'TIER4'
|
|
584
|
+
displayName: string; // 'Explorer' | 'Voyager' | etc.
|
|
585
|
+
displayOrder: number;
|
|
586
|
+
maxPerksAllowed: number;
|
|
587
|
+
tierColor?: string;
|
|
588
|
+
tierIcon?: string;
|
|
589
|
+
tierByline?: string;
|
|
590
|
+
eligibility?: string; // Note: eligibility (not eligibilityText)
|
|
591
|
+
requirements?: TierRequirement[]; // Key/value tier requirements
|
|
592
|
+
}
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### TierRequirement
|
|
596
|
+
|
|
597
|
+
```typescript
|
|
598
|
+
interface TierRequirement {
|
|
599
|
+
id: string;
|
|
600
|
+
tierConfigId: string;
|
|
601
|
+
requirementKey: string; // e.g., 'stays_member_origin', 'points'
|
|
602
|
+
requirementValue: string; // Threshold value
|
|
603
|
+
valueType: 'INT' | 'FLOAT' | 'DATE' | 'STRING';
|
|
604
|
+
operator: 'eq' | 'gt' | 'gte' | 'lt' | 'lte';
|
|
605
|
+
logicGroup: number; // For AND/OR logic combinations
|
|
606
|
+
displayOrder: number;
|
|
607
|
+
displayName?: string; // Custom display override
|
|
608
|
+
active: boolean;
|
|
609
|
+
}
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
### LoyaltyTransaction
|
|
613
|
+
|
|
614
|
+
```typescript
|
|
615
|
+
interface LoyaltyTransaction {
|
|
616
|
+
id: string;
|
|
617
|
+
memberId: string;
|
|
618
|
+
bookingId?: string;
|
|
619
|
+
pointsChange: number;
|
|
620
|
+
transactionType: 'EARNED' | 'SPENT' | 'EXPIRED' | 'ADJUSTED';
|
|
621
|
+
reason?: string;
|
|
622
|
+
description?: string;
|
|
623
|
+
balanceAfter: number;
|
|
624
|
+
createdAt: Date;
|
|
625
|
+
}
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
### TierProgress
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
interface TierProgress {
|
|
632
|
+
currentTier: string;
|
|
633
|
+
currentTierName: string;
|
|
634
|
+
nextTier: string | null;
|
|
635
|
+
nextTierName: string | null;
|
|
636
|
+
percentage: number;
|
|
637
|
+
pointsNeeded: number;
|
|
638
|
+
totalStays: number;
|
|
639
|
+
originStays: number; // Note: originStays (not originPartnerStays)
|
|
640
|
+
}
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
## Environment Variables
|
|
644
|
+
|
|
645
|
+
```bash
|
|
646
|
+
# Required - Partner credentials
|
|
647
|
+
NEXT_PUBLIC_AKIN_PARTNER_ID=your-partner-id
|
|
648
|
+
NEXT_PUBLIC_AKIN_API_KEY=your-api-key
|
|
649
|
+
|
|
650
|
+
# Required - SDK infrastructure (provided by Akin during onboarding)
|
|
651
|
+
NEXT_PUBLIC_AKIN_SDK_API_URL=https://api.akintravel.com/graphql
|
|
652
|
+
NEXT_PUBLIC_AKIN_SDK_GIP_TENANT_ID=your-gip-tenant-id
|
|
653
|
+
NEXT_PUBLIC_AKIN_SDK_FIREBASE_API_KEY=your-firebase-api-key
|
|
654
|
+
NEXT_PUBLIC_AKIN_SDK_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
|
|
655
|
+
NEXT_PUBLIC_AKIN_SDK_FIREBASE_PROJECT_ID=your-firebase-project-id
|
|
656
|
+
NEXT_PUBLIC_AKIN_SDK_FIREBASE_APP_ID=your-firebase-app-id
|
|
657
|
+
|
|
658
|
+
# Optional - Environment-specific API URLs
|
|
659
|
+
NEXT_PUBLIC_AKIN_SDK_STAGING_API_URL=https://staging-api.example.com/graphql
|
|
660
|
+
NEXT_PUBLIC_AKIN_SDK_DEV_API_URL=http://localhost:4000/graphql
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
## Security Best Practices
|
|
664
|
+
|
|
665
|
+
### API Key Handling
|
|
666
|
+
|
|
667
|
+
**IMPORTANT:** Always use environment variables for your API key. Never commit API keys to source control.
|
|
668
|
+
|
|
669
|
+
```tsx
|
|
670
|
+
// ✅ CORRECT - Use environment variable
|
|
671
|
+
apiKey: process.env.NEXT_PUBLIC_AKIN_API_KEY!
|
|
672
|
+
|
|
673
|
+
// ❌ WRONG - Never hardcode API keys
|
|
674
|
+
apiKey: 'pk_live_abc123...'
|
|
675
|
+
|
|
676
|
+
// ❌ WRONG - Never use fallback values in production
|
|
677
|
+
apiKey: process.env.NEXT_PUBLIC_AKIN_API_KEY || 'demo-key'
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
### Debug Mode
|
|
681
|
+
|
|
682
|
+
Only enable debug mode in development:
|
|
683
|
+
|
|
684
|
+
```tsx
|
|
685
|
+
debug: process.env.NODE_ENV === 'development'
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
### Environment File (.env.local)
|
|
689
|
+
|
|
690
|
+
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.
|
|
691
|
+
|
|
692
|
+
```bash
|
|
693
|
+
# Add to .gitignore
|
|
694
|
+
.env.local
|
|
695
|
+
.env*.local
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
### CORS
|
|
699
|
+
|
|
700
|
+
The SDK automatically handles CORS with the Akin API. If you need to allow additional origins, contact your Akin partner representative.
|
|
701
|
+
|
|
702
|
+
## CSS Styling Notes
|
|
703
|
+
|
|
704
|
+
### Link Color Override
|
|
705
|
+
|
|
706
|
+
If you have global link styles that override button text colors, use a more specific selector:
|
|
707
|
+
|
|
708
|
+
```css
|
|
709
|
+
/* ❌ This will override button text colors */
|
|
710
|
+
a {
|
|
711
|
+
@apply text-blue-600;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/* ✅ Use this instead - excludes elements with explicit text colors */
|
|
715
|
+
a:not([class*="text-"]) {
|
|
716
|
+
@apply text-blue-600;
|
|
717
|
+
}
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
### Phone Input Styles
|
|
721
|
+
|
|
722
|
+
Import the phone input CSS:
|
|
723
|
+
|
|
724
|
+
```tsx
|
|
725
|
+
import 'react-phone-number-input/style.css';
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
## Examples
|
|
729
|
+
|
|
730
|
+
### Magic Link Verification Page
|
|
731
|
+
|
|
732
|
+
Create an `/auth/verify` route to handle magic link callbacks:
|
|
733
|
+
|
|
734
|
+
```tsx
|
|
735
|
+
'use client';
|
|
736
|
+
|
|
737
|
+
import { Suspense, useEffect, useState } from 'react';
|
|
738
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
739
|
+
import { useAkinAuth } from '@akin-online/partner-sdk';
|
|
740
|
+
|
|
741
|
+
function VerifyContent() {
|
|
742
|
+
const router = useRouter();
|
|
743
|
+
const searchParams = useSearchParams();
|
|
744
|
+
const { verifyMagicLink } = useAkinAuth();
|
|
745
|
+
const [state, setState] = useState<'loading' | 'success' | 'error'>('loading');
|
|
746
|
+
const [error, setError] = useState<string | null>(null);
|
|
747
|
+
|
|
748
|
+
useEffect(() => {
|
|
749
|
+
const token = searchParams.get('token');
|
|
750
|
+
|
|
751
|
+
if (!token) {
|
|
752
|
+
setError('Invalid verification link');
|
|
753
|
+
setState('error');
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const verify = async () => {
|
|
758
|
+
const result = await verifyMagicLink(token);
|
|
759
|
+
|
|
760
|
+
if (!result.success) {
|
|
761
|
+
setError(result.error || 'Verification failed');
|
|
762
|
+
setState('error');
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
setState('success');
|
|
767
|
+
setTimeout(() => router.push('/rewards'), 1500);
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
verify();
|
|
771
|
+
}, [searchParams, verifyMagicLink, router]);
|
|
772
|
+
|
|
773
|
+
if (state === 'loading') return <p>Verifying...</p>;
|
|
774
|
+
if (state === 'success') return <p>Verified! Redirecting...</p>;
|
|
775
|
+
return <p>Error: {error}</p>;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export default function VerifyPage() {
|
|
779
|
+
return (
|
|
780
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
781
|
+
<VerifyContent />
|
|
782
|
+
</Suspense>
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
### Complete Passwordless Signup
|
|
788
|
+
|
|
789
|
+
```tsx
|
|
790
|
+
'use client';
|
|
791
|
+
|
|
792
|
+
import { useState } from 'react';
|
|
793
|
+
import { SignupForm, PhoneInput } from '@akin-online/partner-sdk';
|
|
794
|
+
import 'react-phone-number-input/style.css';
|
|
795
|
+
|
|
796
|
+
export default function SignupPage() {
|
|
797
|
+
const [signupComplete, setSignupComplete] = useState(false);
|
|
798
|
+
const [submittedEmail, setSubmittedEmail] = useState('');
|
|
799
|
+
|
|
800
|
+
if (signupComplete) {
|
|
801
|
+
return (
|
|
802
|
+
<div className="text-center">
|
|
803
|
+
<h1>Check your email!</h1>
|
|
804
|
+
<p>We sent a verification link to {submittedEmail}</p>
|
|
805
|
+
</div>
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return (
|
|
810
|
+
<SignupForm
|
|
811
|
+
passwordless
|
|
812
|
+
onSuccess={() => {}}
|
|
813
|
+
onError={(error) => console.error(error)}
|
|
814
|
+
>
|
|
815
|
+
{({
|
|
816
|
+
firstName, setFirstName,
|
|
817
|
+
lastName, setLastName,
|
|
818
|
+
email, setEmail,
|
|
819
|
+
phone, setPhone,
|
|
820
|
+
termsAccepted, setTermsAccepted,
|
|
821
|
+
marketingOptIn, setMarketingOptIn,
|
|
822
|
+
isLoading, error,
|
|
823
|
+
handleSubmit,
|
|
824
|
+
clearError,
|
|
825
|
+
}) => (
|
|
826
|
+
<form
|
|
827
|
+
onSubmit={(e) => {
|
|
828
|
+
e.preventDefault();
|
|
829
|
+
setSubmittedEmail(email);
|
|
830
|
+
handleSubmit(e).then(() => {
|
|
831
|
+
if (!error) setSignupComplete(true);
|
|
832
|
+
});
|
|
833
|
+
}}
|
|
834
|
+
>
|
|
835
|
+
<div className="grid grid-cols-2 gap-4">
|
|
836
|
+
<input
|
|
837
|
+
value={firstName}
|
|
838
|
+
onChange={(e) => { setFirstName(e.target.value); clearError(); }}
|
|
839
|
+
placeholder="First name"
|
|
840
|
+
required
|
|
841
|
+
/>
|
|
842
|
+
<input
|
|
843
|
+
value={lastName}
|
|
844
|
+
onChange={(e) => { setLastName(e.target.value); clearError(); }}
|
|
845
|
+
placeholder="Last name"
|
|
846
|
+
required
|
|
847
|
+
/>
|
|
848
|
+
</div>
|
|
849
|
+
|
|
850
|
+
<input
|
|
851
|
+
type="email"
|
|
852
|
+
value={email}
|
|
853
|
+
onChange={(e) => { setEmail(e.target.value); clearError(); }}
|
|
854
|
+
placeholder="Email"
|
|
855
|
+
required
|
|
856
|
+
/>
|
|
857
|
+
|
|
858
|
+
<PhoneInput
|
|
859
|
+
value={phone}
|
|
860
|
+
onChange={(value) => { setPhone(value); clearError(); }}
|
|
861
|
+
defaultCountry="US"
|
|
862
|
+
placeholder="Phone number"
|
|
863
|
+
required
|
|
864
|
+
/>
|
|
865
|
+
|
|
866
|
+
<label>
|
|
867
|
+
<input
|
|
868
|
+
type="checkbox"
|
|
869
|
+
checked={termsAccepted}
|
|
870
|
+
onChange={(e) => setTermsAccepted(e.target.checked)}
|
|
871
|
+
required
|
|
872
|
+
/>
|
|
873
|
+
I agree to the Terms of Service and Privacy Policy
|
|
874
|
+
</label>
|
|
875
|
+
|
|
876
|
+
<label>
|
|
877
|
+
<input
|
|
878
|
+
type="checkbox"
|
|
879
|
+
checked={marketingOptIn}
|
|
880
|
+
onChange={(e) => setMarketingOptIn(e.target.checked)}
|
|
881
|
+
/>
|
|
882
|
+
Send me updates and offers
|
|
883
|
+
</label>
|
|
884
|
+
|
|
885
|
+
{error && <p className="error">{error}</p>}
|
|
886
|
+
|
|
887
|
+
<button type="submit" disabled={isLoading}>
|
|
888
|
+
{isLoading ? 'Creating account...' : 'Create Account'}
|
|
889
|
+
</button>
|
|
890
|
+
</form>
|
|
891
|
+
)}
|
|
892
|
+
</SignupForm>
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
### Complete Login Page with Passkeys
|
|
898
|
+
|
|
899
|
+
```tsx
|
|
900
|
+
'use client';
|
|
901
|
+
|
|
902
|
+
import { useAkinAuth, LoginForm } from '@akin-online/partner-sdk';
|
|
903
|
+
import { useRouter } from 'next/navigation';
|
|
904
|
+
import { useEffect } from 'react';
|
|
905
|
+
|
|
906
|
+
export default function LoginPage() {
|
|
907
|
+
const router = useRouter();
|
|
908
|
+
const { isAuthenticated } = useAkinAuth();
|
|
909
|
+
|
|
910
|
+
useEffect(() => {
|
|
911
|
+
if (isAuthenticated) router.push('/rewards');
|
|
912
|
+
}, [isAuthenticated, router]);
|
|
913
|
+
|
|
914
|
+
return (
|
|
915
|
+
<div className="login-container">
|
|
916
|
+
<h1>Welcome Back</h1>
|
|
917
|
+
|
|
918
|
+
<LoginForm
|
|
919
|
+
onSuccess={() => router.push('/rewards')}
|
|
920
|
+
onError={(error) => console.error(error)}
|
|
921
|
+
>
|
|
922
|
+
{({
|
|
923
|
+
mode, setMode,
|
|
924
|
+
email, setEmail,
|
|
925
|
+
isLoading, error,
|
|
926
|
+
passkeySupported,
|
|
927
|
+
handlePasskeyLogin,
|
|
928
|
+
handleMagicLink,
|
|
929
|
+
handleResendMagicLink,
|
|
930
|
+
clearError,
|
|
931
|
+
}) => (
|
|
932
|
+
<div>
|
|
933
|
+
{error && <p className="error">{error}</p>}
|
|
934
|
+
|
|
935
|
+
{mode === 'passkey' && (
|
|
936
|
+
<div>
|
|
937
|
+
<button
|
|
938
|
+
onClick={handlePasskeyLogin}
|
|
939
|
+
disabled={!passkeySupported || isLoading}
|
|
940
|
+
>
|
|
941
|
+
{isLoading ? 'Signing in...' : 'Sign in with Passkey'}
|
|
942
|
+
</button>
|
|
943
|
+
<button onClick={() => { clearError(); setMode('magic-link'); }}>
|
|
944
|
+
Sign in with Email
|
|
945
|
+
</button>
|
|
946
|
+
</div>
|
|
947
|
+
)}
|
|
948
|
+
|
|
949
|
+
{mode === 'magic-link' && (
|
|
950
|
+
<div>
|
|
951
|
+
<input
|
|
952
|
+
type="email"
|
|
953
|
+
value={email}
|
|
954
|
+
onChange={(e) => { setEmail(e.target.value); clearError(); }}
|
|
955
|
+
placeholder="you@example.com"
|
|
956
|
+
/>
|
|
957
|
+
<button
|
|
958
|
+
onClick={handleMagicLink}
|
|
959
|
+
disabled={isLoading || !email}
|
|
960
|
+
>
|
|
961
|
+
{isLoading ? 'Sending...' : 'Send sign-in link'}
|
|
962
|
+
</button>
|
|
963
|
+
{passkeySupported && (
|
|
964
|
+
<button onClick={() => { clearError(); setMode('passkey'); }}>
|
|
965
|
+
← Back to passkey sign-in
|
|
966
|
+
</button>
|
|
967
|
+
)}
|
|
968
|
+
</div>
|
|
969
|
+
)}
|
|
970
|
+
|
|
971
|
+
{mode === 'magic-link-sent' && (
|
|
972
|
+
<div className="text-center">
|
|
973
|
+
<h2>Check your email</h2>
|
|
974
|
+
<p>We sent a sign-in link to {email}</p>
|
|
975
|
+
<button onClick={handleResendMagicLink} disabled={isLoading}>
|
|
976
|
+
{isLoading ? 'Resending...' : 'Resend link'}
|
|
977
|
+
</button>
|
|
978
|
+
<button onClick={() => { clearError(); setMode('magic-link'); }}>
|
|
979
|
+
Try a different email
|
|
980
|
+
</button>
|
|
981
|
+
</div>
|
|
982
|
+
)}
|
|
983
|
+
</div>
|
|
984
|
+
)}
|
|
985
|
+
</LoginForm>
|
|
986
|
+
</div>
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
### Complete Rewards Dashboard
|
|
992
|
+
|
|
993
|
+
```tsx
|
|
994
|
+
'use client';
|
|
995
|
+
|
|
996
|
+
import {
|
|
997
|
+
useAkinAuth,
|
|
998
|
+
useAkinLoyalty,
|
|
999
|
+
TierCard,
|
|
1000
|
+
TierProgress,
|
|
1001
|
+
TransactionList,
|
|
1002
|
+
PointsDisplay,
|
|
1003
|
+
} from '@akin-online/partner-sdk';
|
|
1004
|
+
import { useRouter } from 'next/navigation';
|
|
1005
|
+
import { useEffect } from 'react';
|
|
1006
|
+
|
|
1007
|
+
export default function RewardsPage() {
|
|
1008
|
+
const router = useRouter();
|
|
1009
|
+
const { member, isAuthenticated, isLoading: authLoading, signOut } = useAkinAuth();
|
|
1010
|
+
const { isLoading: loyaltyLoading } = useAkinLoyalty();
|
|
1011
|
+
|
|
1012
|
+
useEffect(() => {
|
|
1013
|
+
if (!authLoading && !isAuthenticated) {
|
|
1014
|
+
router.push('/login');
|
|
1015
|
+
}
|
|
1016
|
+
}, [isAuthenticated, authLoading, router]);
|
|
1017
|
+
|
|
1018
|
+
if (authLoading || loyaltyLoading) {
|
|
1019
|
+
return <p>Loading...</p>;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (!isAuthenticated) return null;
|
|
1023
|
+
|
|
1024
|
+
return (
|
|
1025
|
+
<div className="rewards-container">
|
|
1026
|
+
<header>
|
|
1027
|
+
<h1>Welcome back, {member?.firstName}!</h1>
|
|
1028
|
+
<p>Loyalty #: {member?.loyaltyNumber}</p>
|
|
1029
|
+
<button onClick={() => signOut().then(() => router.push('/'))}>
|
|
1030
|
+
Sign Out
|
|
1031
|
+
</button>
|
|
1032
|
+
</header>
|
|
1033
|
+
|
|
1034
|
+
<div className="grid grid-cols-2 gap-4">
|
|
1035
|
+
<div className="card">
|
|
1036
|
+
<h2>Your Points</h2>
|
|
1037
|
+
<PointsDisplay>
|
|
1038
|
+
{({ formattedPoints }) => (
|
|
1039
|
+
<p className="text-4xl font-bold">{formattedPoints}</p>
|
|
1040
|
+
)}
|
|
1041
|
+
</PointsDisplay>
|
|
1042
|
+
</div>
|
|
1043
|
+
|
|
1044
|
+
<div className="card">
|
|
1045
|
+
<h2>Current Tier</h2>
|
|
1046
|
+
<TierCard>
|
|
1047
|
+
{({ displayName, tier }) => (
|
|
1048
|
+
<div>
|
|
1049
|
+
<p className="text-2xl font-bold">{displayName}</p>
|
|
1050
|
+
<p className="text-sm">Tier {tier.replace('TIER', '')}</p>
|
|
1051
|
+
</div>
|
|
1052
|
+
)}
|
|
1053
|
+
</TierCard>
|
|
1054
|
+
</div>
|
|
1055
|
+
</div>
|
|
1056
|
+
|
|
1057
|
+
<div className="card">
|
|
1058
|
+
<h2>Progress to Next Tier</h2>
|
|
1059
|
+
<TierProgress>
|
|
1060
|
+
{({ currentTierName, nextTierName, percentage, pointsNeeded }) => (
|
|
1061
|
+
nextTierName ? (
|
|
1062
|
+
<div>
|
|
1063
|
+
<div className="flex justify-between">
|
|
1064
|
+
<span>{currentTierName}</span>
|
|
1065
|
+
<span>{nextTierName}</span>
|
|
1066
|
+
</div>
|
|
1067
|
+
<div className="progress-bar">
|
|
1068
|
+
<div style={{ width: `${percentage}%` }} />
|
|
1069
|
+
</div>
|
|
1070
|
+
<p>{pointsNeeded.toLocaleString()} points to {nextTierName}</p>
|
|
1071
|
+
</div>
|
|
1072
|
+
) : (
|
|
1073
|
+
<p>You've reached the highest tier!</p>
|
|
1074
|
+
)
|
|
1075
|
+
)}
|
|
1076
|
+
</TierProgress>
|
|
1077
|
+
</div>
|
|
1078
|
+
|
|
1079
|
+
<div className="card">
|
|
1080
|
+
<h2>Recent Activity</h2>
|
|
1081
|
+
<TransactionList limit={10}>
|
|
1082
|
+
{({ transactions, isLoading, hasMore, loadMore }) => (
|
|
1083
|
+
<div>
|
|
1084
|
+
{transactions.length === 0 ? (
|
|
1085
|
+
<p>No transactions yet</p>
|
|
1086
|
+
) : (
|
|
1087
|
+
<>
|
|
1088
|
+
<ul>
|
|
1089
|
+
{transactions.map((tx) => (
|
|
1090
|
+
<li key={tx.id} className="flex justify-between py-3">
|
|
1091
|
+
<div>
|
|
1092
|
+
<p>{tx.reason || tx.description || 'Transaction'}</p>
|
|
1093
|
+
<p className="text-sm text-gray-500">
|
|
1094
|
+
{new Date(tx.createdAt).toLocaleDateString()}
|
|
1095
|
+
</p>
|
|
1096
|
+
</div>
|
|
1097
|
+
<span className={tx.pointsChange > 0 ? 'text-green-600' : 'text-red-600'}>
|
|
1098
|
+
{tx.pointsChange > 0 ? '+' : ''}{tx.pointsChange.toLocaleString()}
|
|
1099
|
+
</span>
|
|
1100
|
+
</li>
|
|
1101
|
+
))}
|
|
1102
|
+
</ul>
|
|
1103
|
+
{hasMore && (
|
|
1104
|
+
<button onClick={loadMore} disabled={isLoading}>
|
|
1105
|
+
{isLoading ? 'Loading...' : 'Load More'}
|
|
1106
|
+
</button>
|
|
1107
|
+
)}
|
|
1108
|
+
</>
|
|
1109
|
+
)}
|
|
1110
|
+
</div>
|
|
1111
|
+
)}
|
|
1112
|
+
</TransactionList>
|
|
1113
|
+
</div>
|
|
1114
|
+
</div>
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
```
|
|
1118
|
+
|
|
1119
|
+
## Development / Examples
|
|
1120
|
+
|
|
1121
|
+
### Viajero Demo
|
|
1122
|
+
|
|
1123
|
+
A complete example implementation is available in the monorepo at `docs/partner-sdk-examples/viajero/`. This demonstrates a partner rewards site using the SDK with:
|
|
1124
|
+
|
|
1125
|
+
- Login page with passkey and magic link authentication
|
|
1126
|
+
- Signup form with passwordless registration
|
|
1127
|
+
- Rewards dashboard with tier display and points
|
|
1128
|
+
- Full Tailwind CSS styling
|
|
1129
|
+
|
|
1130
|
+
**Running the demo:**
|
|
1131
|
+
|
|
1132
|
+
```bash
|
|
1133
|
+
# From monorepo root
|
|
1134
|
+
cd docs/partner-sdk-examples/viajero
|
|
1135
|
+
npm install
|
|
1136
|
+
npm run dev
|
|
1137
|
+
```
|
|
1138
|
+
|
|
1139
|
+
The demo runs at **http://localhost:3003**
|
|
1140
|
+
|
|
1141
|
+
**Demo structure:**
|
|
1142
|
+
```
|
|
1143
|
+
viajero/
|
|
1144
|
+
├── app/
|
|
1145
|
+
│ ├── layout.tsx # AkinProvider setup
|
|
1146
|
+
│ ├── page.tsx # Landing page
|
|
1147
|
+
│ ├── login/ # Login with passkey/magic link
|
|
1148
|
+
│ ├── signup/ # Passwordless signup
|
|
1149
|
+
│ ├── rewards/ # Authenticated rewards dashboard
|
|
1150
|
+
│ └── auth/verify/ # Magic link verification
|
|
1151
|
+
├── components/ # Reusable UI components
|
|
1152
|
+
└── .env.local.example # Environment variables template
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
### Local SDK Development
|
|
1156
|
+
|
|
1157
|
+
When developing the SDK itself, the demo uses a file reference to the local package:
|
|
1158
|
+
|
|
1159
|
+
```json
|
|
1160
|
+
{
|
|
1161
|
+
"dependencies": {
|
|
1162
|
+
"@akin-online/partner-sdk": "file:../../../packages/partner-sdk"
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
```
|
|
1166
|
+
|
|
1167
|
+
After making changes to the SDK, rebuild and restart the demo:
|
|
1168
|
+
|
|
1169
|
+
```bash
|
|
1170
|
+
# From monorepo root
|
|
1171
|
+
npm run build -w @akin-online/partner-sdk
|
|
1172
|
+
cd docs/partner-sdk-examples/viajero && npm run dev
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
## Troubleshooting
|
|
1176
|
+
|
|
1177
|
+
### "Loading..." stuck after passkey authentication
|
|
1178
|
+
|
|
1179
|
+
Ensure you're using the latest SDK version. The `signInWithPasskey` function properly resets loading state in the `finally` block.
|
|
1180
|
+
|
|
1181
|
+
### Button text color not showing
|
|
1182
|
+
|
|
1183
|
+
Check for global CSS rules that override text colors. See [CSS Styling Notes](#css-styling-notes).
|
|
1184
|
+
|
|
1185
|
+
### Phone input not styled
|
|
1186
|
+
|
|
1187
|
+
Import the phone input CSS: `import 'react-phone-number-input/style.css';`
|
|
1188
|
+
|
|
1189
|
+
### Magic link not working
|
|
1190
|
+
|
|
1191
|
+
1. Verify your `NEXT_PUBLIC_AKIN_API_KEY` is set correctly
|
|
1192
|
+
2. Check that your domain is in the allowed origins for your API key
|
|
1193
|
+
3. Ensure you have an `/auth/verify` route to handle the callback
|
|
1194
|
+
|
|
1195
|
+
### GraphQL errors
|
|
1196
|
+
|
|
1197
|
+
If you see errors about field types or non-existent fields, ensure you're using the latest SDK version. Common issues:
|
|
1198
|
+
- Use `String!` (not `ID!`) for member/partner IDs in queries
|
|
1199
|
+
- Use `originStays` (not `originPartnerStays`)
|
|
1200
|
+
- Use `eligibility` (not `eligibilityText`)
|
|
1201
|
+
|
|
1202
|
+
## Support
|
|
1203
|
+
|
|
1204
|
+
For questions or issues, contact your Akin partner representative or email support@akintravel.com
|