@tagadapay/plugin-sdk 2.5.2 โ†’ 2.6.2

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.
@@ -0,0 +1,15 @@
1
+ import type { CustomerInfos } from '../types';
2
+ export interface UseCustomerInfosOptions {
3
+ customerId?: string | null;
4
+ enabled?: boolean;
5
+ }
6
+ export interface UseCustomerInfosResult {
7
+ data: CustomerInfos | null;
8
+ isLoading: boolean;
9
+ error: Error | null;
10
+ refetch: () => Promise<void>;
11
+ }
12
+ /**
13
+ * useCustomerInfos - Fetches customer infos from `/api/v1/customers/{customerId}` with `storeId` param
14
+ */
15
+ export declare function useCustomerInfos(options: UseCustomerInfosOptions): UseCustomerInfosResult;
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+ import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import { useTagadaContext } from '../providers/TagadaProvider';
4
+ import { usePluginConfig } from './usePluginConfig';
5
+ /**
6
+ * useCustomerInfos - Fetches customer infos from `/api/v1/customers/{customerId}` with `storeId` param
7
+ */
8
+ export function useCustomerInfos(options) {
9
+ const { apiService } = useTagadaContext();
10
+ const { storeId } = usePluginConfig();
11
+ const stableOptions = useMemo(() => {
12
+ return {
13
+ customerId: options.customerId ?? null,
14
+ enabled: options.enabled ?? true,
15
+ };
16
+ }, [options.customerId, options.enabled]);
17
+ const isEnabled = useMemo(() => {
18
+ return Boolean(stableOptions.enabled && stableOptions.customerId && storeId);
19
+ }, [stableOptions.enabled, stableOptions.customerId, storeId]);
20
+ const [data, setData] = useState(null);
21
+ const [isLoading, setIsLoading] = useState(false);
22
+ const [error, setError] = useState(null);
23
+ const fetchCustomerInfos = useCallback(async () => {
24
+ if (!isEnabled)
25
+ return;
26
+ if (!stableOptions.customerId || !storeId)
27
+ return;
28
+ setIsLoading(true);
29
+ setError(null);
30
+ try {
31
+ const response = await apiService.fetch(`/api/v1/customers/${stableOptions.customerId}`, {
32
+ method: 'GET',
33
+ params: { storeId },
34
+ });
35
+ setData(response ?? null);
36
+ }
37
+ catch (err) {
38
+ const safeError = err instanceof Error ? err : new Error('Failed to fetch customer infos');
39
+ setError(safeError);
40
+ }
41
+ finally {
42
+ setIsLoading(false);
43
+ }
44
+ }, [apiService, isEnabled, stableOptions.customerId, storeId]);
45
+ useEffect(() => {
46
+ void fetchCustomerInfos();
47
+ }, [fetchCustomerInfos]);
48
+ return {
49
+ data,
50
+ isLoading,
51
+ error,
52
+ refetch: fetchCustomerInfos,
53
+ };
54
+ }
@@ -0,0 +1,14 @@
1
+ import type { OrderWithRelations } from '../types';
2
+ export interface UseCustomerOrdersOptions {
3
+ customerId?: string | null;
4
+ enabled?: boolean;
5
+ }
6
+ export interface UseCustomerOrdersResult {
7
+ data: {
8
+ orders: OrderWithRelations[];
9
+ } | null;
10
+ isLoading: boolean;
11
+ error: Error | null;
12
+ refetch: () => Promise<void>;
13
+ }
14
+ export declare function useCustomerOrders(options: UseCustomerOrdersOptions): UseCustomerOrdersResult;
@@ -0,0 +1,51 @@
1
+ 'use client';
2
+ import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import { useTagadaContext } from '../providers/TagadaProvider';
4
+ import { usePluginConfig } from './usePluginConfig';
5
+ export function useCustomerOrders(options) {
6
+ const { apiService } = useTagadaContext();
7
+ const { storeId } = usePluginConfig();
8
+ const stableOptions = useMemo(() => {
9
+ return {
10
+ customerId: options.customerId ?? null,
11
+ enabled: options.enabled ?? true,
12
+ };
13
+ }, [options.customerId, options.enabled]);
14
+ const isEnabled = useMemo(() => {
15
+ return Boolean(stableOptions.enabled && stableOptions.customerId && storeId);
16
+ }, [stableOptions.enabled, stableOptions.customerId, storeId]);
17
+ const [data, setData] = useState(null);
18
+ const [isLoading, setIsLoading] = useState(false);
19
+ const [error, setError] = useState(null);
20
+ const fetchOrders = useCallback(async () => {
21
+ if (!isEnabled)
22
+ return;
23
+ if (!stableOptions.customerId || !storeId)
24
+ return;
25
+ setIsLoading(true);
26
+ setError(null);
27
+ try {
28
+ const response = await apiService.fetch(`/api/v1/orders/customer/${stableOptions.customerId}`, {
29
+ method: 'GET',
30
+ params: { storeId },
31
+ });
32
+ setData(response ?? null);
33
+ }
34
+ catch (err) {
35
+ const safeError = err instanceof Error ? err : new Error('Failed to fetch customer orders');
36
+ setError(safeError);
37
+ }
38
+ finally {
39
+ setIsLoading(false);
40
+ }
41
+ }, [apiService, isEnabled, stableOptions.customerId, storeId]);
42
+ useEffect(() => {
43
+ void fetchOrders();
44
+ }, [fetchOrders]);
45
+ return {
46
+ data,
47
+ isLoading,
48
+ error,
49
+ refetch: fetchOrders,
50
+ };
51
+ }
@@ -0,0 +1,56 @@
1
+ export interface Subscription {
2
+ id: string;
3
+ status: string;
4
+ createdAt: string;
5
+ currency: string;
6
+ cancelAtPeriodEnd: boolean;
7
+ currentPeriodEnd: string | null;
8
+ currentPeriodStart: string | null;
9
+ quantity: number;
10
+ trialEnd: string | null;
11
+ customerId: string;
12
+ customerEmail: string;
13
+ customerName: string;
14
+ priceCurrencyOptions: Record<string, {
15
+ rate: number;
16
+ amount: number;
17
+ lock: boolean;
18
+ date: string;
19
+ }>;
20
+ priceInterval: string;
21
+ priceIntervalCount: number;
22
+ priceRecurring: boolean;
23
+ productId: string;
24
+ priceId: string;
25
+ productTitle: string;
26
+ }
27
+ export interface SubscriptionsResponse {
28
+ items: Subscription[];
29
+ pagination: {
30
+ page: number;
31
+ pageSize: number;
32
+ hasNext: boolean;
33
+ nextPage: number | null;
34
+ previousPage: number | null;
35
+ totalItems: number;
36
+ };
37
+ }
38
+ export interface UseCustomerSubscriptionsOptions {
39
+ customerId?: string | null;
40
+ enabled?: boolean;
41
+ }
42
+ export interface UseCustomerSubscriptionsResult {
43
+ data: SubscriptionsResponse | null;
44
+ isLoading: boolean;
45
+ error: Error | null;
46
+ refetch: () => Promise<void>;
47
+ resumeSubscription: (subscriptionId: string) => Promise<{
48
+ success: boolean;
49
+ error?: string;
50
+ }>;
51
+ cancelSubscription: (subscriptionId: string) => Promise<{
52
+ success: boolean;
53
+ error?: string;
54
+ }>;
55
+ }
56
+ export declare function useCustomerSubscriptions(options: UseCustomerSubscriptionsOptions): UseCustomerSubscriptionsResult;
@@ -0,0 +1,77 @@
1
+ 'use client';
2
+ import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import { useTagadaContext } from '../providers/TagadaProvider';
4
+ export function useCustomerSubscriptions(options) {
5
+ const { apiService } = useTagadaContext();
6
+ const stableOptions = useMemo(() => {
7
+ return {
8
+ customerId: options.customerId ?? null,
9
+ enabled: options.enabled ?? true,
10
+ };
11
+ }, [options.customerId, options.enabled]);
12
+ const isEnabled = useMemo(() => {
13
+ return Boolean(stableOptions.enabled && stableOptions.customerId);
14
+ }, [stableOptions.enabled, stableOptions.customerId]);
15
+ const [data, setData] = useState(null);
16
+ const [isLoading, setIsLoading] = useState(false);
17
+ const [error, setError] = useState(null);
18
+ const fetchSubscriptions = useCallback(async () => {
19
+ if (!isEnabled)
20
+ return;
21
+ setIsLoading(true);
22
+ setError(null);
23
+ try {
24
+ // Token-authenticated request; backend infers customer from token
25
+ const response = await apiService.fetch(`/api/v1/subscriptions`, {
26
+ method: 'GET',
27
+ });
28
+ setData(response ?? null);
29
+ }
30
+ catch (err) {
31
+ const safeError = err instanceof Error ? err : new Error('Failed to fetch subscriptions');
32
+ setError(safeError);
33
+ }
34
+ finally {
35
+ setIsLoading(false);
36
+ }
37
+ }, [apiService, isEnabled]);
38
+ useEffect(() => {
39
+ void fetchSubscriptions();
40
+ }, [fetchSubscriptions]);
41
+ const resumeSubscription = useCallback(async (subscriptionId) => {
42
+ try {
43
+ await apiService.fetch(`/api/v1/subscriptions/resume`, {
44
+ method: 'POST',
45
+ body: { subscriptionId },
46
+ });
47
+ await fetchSubscriptions();
48
+ return { success: true };
49
+ }
50
+ catch (err) {
51
+ const errorMessage = err instanceof Error ? err.message : 'Failed to resume subscription';
52
+ return { success: false, error: errorMessage };
53
+ }
54
+ }, [apiService, fetchSubscriptions]);
55
+ const cancelSubscription = useCallback(async (subscriptionId) => {
56
+ try {
57
+ await apiService.fetch(`/api/v1/subscriptions/cancel`, {
58
+ method: 'POST',
59
+ body: { subscriptionId },
60
+ });
61
+ await fetchSubscriptions();
62
+ return { success: true };
63
+ }
64
+ catch (err) {
65
+ const errorMessage = err instanceof Error ? err.message : 'Failed to cancel subscription';
66
+ return { success: false, error: errorMessage };
67
+ }
68
+ }, [apiService, fetchSubscriptions]);
69
+ return {
70
+ data,
71
+ isLoading,
72
+ error,
73
+ refetch: fetchSubscriptions,
74
+ resumeSubscription,
75
+ cancelSubscription,
76
+ };
77
+ }
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useState } from 'react';
2
2
  import { useTagadaContext } from '../providers/TagadaProvider';
3
- import { usePluginConfig } from './usePluginConfig';
4
3
  import { setClientToken } from '../utils/tokenStorage';
4
+ import { usePluginConfig } from './usePluginConfig';
5
5
  export function useLogin() {
6
6
  const [isLoading, setIsLoading] = useState(false);
7
7
  const [error, setError] = useState(null);
@@ -7,6 +7,9 @@ export { useCheckout } from './hooks/useCheckout';
7
7
  export { useClubOffers } from './hooks/useClubOffers';
8
8
  export { useCurrency } from './hooks/useCurrency';
9
9
  export { useCustomer } from './hooks/useCustomer';
10
+ export { useCustomerInfos } from './hooks/useCustomerInfos';
11
+ export { useCustomerOrders } from './hooks/useCustomerOrders';
12
+ export { useCustomerSubscriptions } from './hooks/useCustomerSubscriptions';
10
13
  export { useDiscounts } from './hooks/useDiscounts';
11
14
  export { useEnvironment } from './hooks/useEnvironment';
12
15
  export { useGeoLocation } from './hooks/useGeoLocation';
@@ -41,7 +44,7 @@ export { useThreeds } from './hooks/useThreeds';
41
44
  export { useThreedsModal } from './hooks/useThreedsModal';
42
45
  export { useApplePay } from './hooks/useApplePay';
43
46
  export { ExpressPaymentProvider, useExpressPayment } from './hooks/useExpressPayment';
44
- export type { AuthState, Currency, Customer, Environment, EnvironmentConfig, Locale, Order, OrderAddress, OrderItem, OrderSummary, PickupPoint, Session, Store } from './types';
47
+ export type { AuthState, Currency, Customer, CustomerInfos, Environment, EnvironmentConfig, Locale, Order, OrderAddress, OrderItem, OrderSummary, PickupPoint, Session, Store } from './types';
45
48
  export type { CheckoutData, CheckoutInitParams, CheckoutLineItem, CheckoutSession, CheckoutSessionPreview, Promotion, UseCheckoutOptions, UseCheckoutResult } from './hooks/useCheckout';
46
49
  export type { Discount, DiscountCodeValidation, UseDiscountsOptions, UseDiscountsResult } from './hooks/useDiscounts';
47
50
  export type { OrderBumpPreview, UseOrderBumpOptions, UseOrderBumpResult } from './hooks/useOrderBump';
@@ -10,6 +10,9 @@ export { useCheckout } from './hooks/useCheckout';
10
10
  export { useClubOffers } from './hooks/useClubOffers';
11
11
  export { useCurrency } from './hooks/useCurrency';
12
12
  export { useCustomer } from './hooks/useCustomer';
13
+ export { useCustomerInfos } from './hooks/useCustomerInfos';
14
+ export { useCustomerOrders } from './hooks/useCustomerOrders';
15
+ export { useCustomerSubscriptions } from './hooks/useCustomerSubscriptions';
13
16
  export { useDiscounts } from './hooks/useDiscounts';
14
17
  export { useEnvironment } from './hooks/useEnvironment';
15
18
  export { useGeoLocation } from './hooks/useGeoLocation';
@@ -362,6 +362,58 @@ rawPluginConfig, }) {
362
362
  setIsLoading(false);
363
363
  }
364
364
  }, [apiService, hasAttemptedAnonymousToken, initializeSession, finalDebugMode]);
365
+ // Initialize token from storage or create anonymous token (extracted to stable callback)
366
+ const initializeToken = useCallback(async () => {
367
+ try {
368
+ console.debug('[SDK] Initializing token...');
369
+ setIsLoading(true);
370
+ // Check for existing token
371
+ const existingToken = getClientToken();
372
+ let tokenToUse = null;
373
+ // Check URL params for token
374
+ const urlParams = new URLSearchParams(window.location.search);
375
+ const queryToken = urlParams.get('token');
376
+ if (queryToken) {
377
+ console.debug('[SDK] Found token in URL params');
378
+ tokenToUse = queryToken;
379
+ setClientToken(queryToken);
380
+ }
381
+ else if (existingToken && !isTokenExpired(existingToken)) {
382
+ console.debug('[SDK] Using existing token from storage');
383
+ tokenToUse = existingToken;
384
+ }
385
+ else {
386
+ console.debug('[SDK] No valid token found');
387
+ // Determine storeId for anonymous token
388
+ const targetStoreId = storeId || 'default-store';
389
+ await createAnonymousToken(targetStoreId);
390
+ return;
391
+ }
392
+ if (tokenToUse) {
393
+ setToken(tokenToUse);
394
+ // Update the API service with the token
395
+ apiService.updateToken(tokenToUse);
396
+ // Decode token to get session data
397
+ const decodedSession = decodeJWTClient(tokenToUse);
398
+ if (decodedSession) {
399
+ setSession(decodedSession);
400
+ // Initialize session with API call
401
+ await initializeSession(decodedSession);
402
+ }
403
+ else {
404
+ console.error('[SDK] Failed to decode token');
405
+ setIsInitialized(true);
406
+ setIsSessionInitialized(false); // Session failed to initialize
407
+ setIsLoading(false);
408
+ }
409
+ }
410
+ }
411
+ catch (error) {
412
+ console.error('[SDK] Error initializing token:', error);
413
+ setIsInitialized(true);
414
+ setIsLoading(false);
415
+ }
416
+ }, [apiService, storeId, createAnonymousToken, initializeSession]);
365
417
  // Initialize token from storage or create anonymous token
366
418
  // This runs in the background after phases 1 & 2 complete, but doesn't block rendering
367
419
  useEffect(() => {
@@ -373,59 +425,19 @@ rawPluginConfig, }) {
373
425
  return;
374
426
  }
375
427
  isInitializing.current = true;
376
- const initializeToken = async () => {
377
- try {
378
- console.debug('[SDK] Initializing token...');
379
- setIsLoading(true);
380
- // Check for existing token
381
- const existingToken = getClientToken();
382
- let tokenToUse = null;
383
- // Check URL params for token
384
- const urlParams = new URLSearchParams(window.location.search);
385
- const queryToken = urlParams.get('token');
386
- if (queryToken) {
387
- console.debug('[SDK] Found token in URL params');
388
- tokenToUse = queryToken;
389
- setClientToken(queryToken);
390
- }
391
- else if (existingToken && !isTokenExpired(existingToken)) {
392
- console.debug('[SDK] Using existing token from storage');
393
- tokenToUse = existingToken;
394
- }
395
- else {
396
- console.debug('[SDK] No valid token found');
397
- // Determine storeId for anonymous token
398
- const targetStoreId = storeId || 'default-store';
399
- await createAnonymousToken(targetStoreId);
400
- return;
401
- }
402
- if (tokenToUse) {
403
- setToken(tokenToUse);
404
- // Update the API service with the token
405
- apiService.updateToken(tokenToUse);
406
- // Decode token to get session data
407
- const decodedSession = decodeJWTClient(tokenToUse);
408
- if (decodedSession) {
409
- setSession(decodedSession);
410
- // Initialize session with API call
411
- await initializeSession(decodedSession);
412
- }
413
- else {
414
- console.error('[SDK] Failed to decode token');
415
- setIsInitialized(true);
416
- setIsSessionInitialized(false); // Session failed to initialize
417
- setIsLoading(false);
418
- }
419
- }
420
- }
421
- catch (error) {
422
- console.error('[SDK] Error initializing token:', error);
423
- setIsInitialized(true);
424
- setIsLoading(false);
425
- }
426
- };
427
428
  void initializeToken();
428
- }, [storeId, createAnonymousToken, initializeSession, configLoading]);
429
+ }, [storeId, configLoading, initializeToken]);
430
+ useEffect(() => {
431
+ function onStorage() {
432
+ // Re-run initialization when token may have changed in another tab
433
+ isInitializing.current = false;
434
+ void initializeToken();
435
+ }
436
+ window.addEventListener('storage', onStorage);
437
+ return () => {
438
+ window.removeEventListener('storage', onStorage);
439
+ };
440
+ }, [initializeToken]);
429
441
  // Update auth state when customer/session changes
430
442
  useEffect(() => {
431
443
  setAuth({
@@ -150,3 +150,112 @@ export interface Order {
150
150
  };
151
151
  relatedOrders?: Order[];
152
152
  }
153
+ export interface PaymentSummary {
154
+ id: string;
155
+ status: string;
156
+ amount: number;
157
+ currency: string;
158
+ createdAt: string;
159
+ updatedAt?: string;
160
+ provider?: string;
161
+ metadata?: Record<string, any>;
162
+ }
163
+ export interface PromotionSummary {
164
+ id: string;
165
+ code?: string | null;
166
+ type?: string;
167
+ amount?: number;
168
+ description?: string | null;
169
+ }
170
+ export interface OrderAdjustmentSummary {
171
+ type: string;
172
+ amount: number;
173
+ description: string;
174
+ }
175
+ export interface OrderWithRelations extends Order {
176
+ customer?: Customer;
177
+ store?: Store;
178
+ account?: {
179
+ id: string;
180
+ name?: string;
181
+ } | undefined;
182
+ items: OrderItem[];
183
+ payments?: PaymentSummary[];
184
+ summaries: OrderSummary[];
185
+ checkoutSession?: {
186
+ id?: string;
187
+ returnUrl?: string;
188
+ [key: string]: any;
189
+ };
190
+ promotions?: PromotionSummary[];
191
+ subscriptions?: any[];
192
+ adjustments: OrderAdjustmentSummary[];
193
+ }
194
+ export interface CustomerAddress {
195
+ company?: string;
196
+ firstName: string;
197
+ lastName: string;
198
+ address1: string;
199
+ city: string;
200
+ country: string;
201
+ state: string;
202
+ postal: string;
203
+ phone?: string;
204
+ email?: string;
205
+ }
206
+ export interface CustomerOrderSummary {
207
+ id: string;
208
+ storeId: string;
209
+ accountId: string;
210
+ createdAt: string;
211
+ updatedAt: string;
212
+ status: string;
213
+ cancelledAt: string | null;
214
+ cancelledReason: string | null;
215
+ paidAt: string | null;
216
+ paidAmount: number | null;
217
+ openAt: string | null;
218
+ abandonedAt: string | null;
219
+ currency: string;
220
+ externalCustomerType: string | null;
221
+ externalCustomerId: string | null;
222
+ externalOrderId: string | null;
223
+ billingAddress: CustomerAddress;
224
+ shippingAddress: Omit<CustomerAddress, 'email'>;
225
+ pickupAddress: any | null;
226
+ taxesIncluded: boolean;
227
+ draft: boolean;
228
+ checkoutSessionId: string | null;
229
+ sessionHash: string | null;
230
+ customerId: string;
231
+ createdFrom: string | null;
232
+ paymentInstrumentId: string | null;
233
+ refundedAt: string | null;
234
+ refundedAmount: number | null;
235
+ metadata?: Record<string, any>;
236
+ }
237
+ export interface CustomerInfos {
238
+ customer: {
239
+ id: string;
240
+ email: string | null;
241
+ firstName: string | null;
242
+ lastName: string | null;
243
+ externalCustomerId: string | null;
244
+ lastOrderId: string | null;
245
+ accountId: string;
246
+ storeId: string;
247
+ billingAddress: CustomerAddress | null;
248
+ shippingAddress: Omit<CustomerAddress, 'email'> | null;
249
+ currency: string | null;
250
+ locale: string | null;
251
+ draft: boolean;
252
+ acceptsMarketing: boolean;
253
+ createdAt: string;
254
+ updatedAt: string;
255
+ metadata: Record<string, any>;
256
+ device: any | null;
257
+ orders: CustomerOrderSummary[];
258
+ subscriptions: any[];
259
+ };
260
+ promotionCodes: any[];
261
+ }
@@ -10,6 +10,7 @@ export function setClientToken(token) {
10
10
  if (typeof window !== 'undefined') {
11
11
  try {
12
12
  localStorage.setItem(TOKEN_KEY, token);
13
+ window.dispatchEvent(new Event('storage'));
13
14
  }
14
15
  catch (error) {
15
16
  console.error('Failed to save token to localStorage:', error);
@@ -20,6 +20,7 @@ export interface CheckoutInitParams {
20
20
  customerMetadata?: Record<string, unknown>;
21
21
  metadata?: Record<string, unknown>;
22
22
  storeId?: string;
23
+ customerId?: string;
23
24
  customer?: {
24
25
  currency?: string;
25
26
  locale?: string;
@@ -10,6 +10,7 @@ export class CheckoutResource {
10
10
  * Initialize a new checkout session
11
11
  */
12
12
  async initCheckout(params) {
13
+ // Pass all params including customerId to prevent duplicate customer creation
13
14
  return this.apiClient.post('/api/v1/checkout/session/init', params);
14
15
  }
15
16
  /**
@@ -19,6 +19,7 @@ export interface UseCheckoutQueryResult {
19
19
  }>;
20
20
  refresh: () => Promise<void>;
21
21
  updateLineItems: (lineItems: CheckoutLineItem[]) => Promise<any>;
22
+ updateLineItemsOptimistic: (lineItems: CheckoutLineItem[]) => void;
22
23
  setItemQuantity: (variantId: string, quantity: number, priceId?: string) => Promise<any>;
23
24
  updateCustomer: (data: {
24
25
  email: string;
@@ -2,7 +2,7 @@
2
2
  * Checkout Hook using TanStack Query
3
3
  * Replaces the coordinator pattern with automatic cache invalidation
4
4
  */
5
- import { useCallback, useState, useEffect, useMemo } from 'react';
5
+ import { useCallback, useState, useEffect, useMemo, useRef } from 'react';
6
6
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
7
7
  import { CheckoutResource } from '../../core/resources/checkout';
8
8
  import { useTagadaContext } from '../providers/TagadaProvider';
@@ -14,7 +14,46 @@ export function useCheckoutQuery(options = {}) {
14
14
  const { storeId } = usePluginConfig();
15
15
  const currency = useCurrency();
16
16
  const queryClient = useQueryClient();
17
- const { isSessionInitialized } = useTagadaContext();
17
+ const { isSessionInitialized, session } = useTagadaContext();
18
+ // Track pending session promises to avoid creating multiple promises
19
+ const pendingSessionPromise = useRef(null);
20
+ const sessionResolvers = useRef(new Set());
21
+ // Resolve all pending promises when session becomes ready
22
+ useEffect(() => {
23
+ if (isSessionInitialized && sessionResolvers.current.size > 0) {
24
+ console.log('โœ… [useCheckout] CMS session ready, resolving pending promises');
25
+ sessionResolvers.current.forEach(resolve => resolve());
26
+ sessionResolvers.current.clear();
27
+ pendingSessionPromise.current = null;
28
+ }
29
+ }, [isSessionInitialized]);
30
+ // Clean, event-driven session waiting - no polling needed
31
+ const waitForSession = useCallback(async () => {
32
+ if (isSessionInitialized) {
33
+ return Promise.resolve();
34
+ }
35
+ // Reuse existing promise if one is pending
36
+ if (pendingSessionPromise.current) {
37
+ return pendingSessionPromise.current;
38
+ }
39
+ console.log('โณ [useCheckout] Waiting for CMS session to be initialized...');
40
+ // Create new promise that will be resolved by useEffect above
41
+ pendingSessionPromise.current = new Promise((resolve, reject) => {
42
+ // Add resolver to set for useEffect to call
43
+ sessionResolvers.current.add(resolve);
44
+ // Safety timeout
45
+ setTimeout(() => {
46
+ if (sessionResolvers.current.has(resolve)) {
47
+ sessionResolvers.current.delete(resolve);
48
+ if (sessionResolvers.current.size === 0) {
49
+ pendingSessionPromise.current = null;
50
+ }
51
+ reject(new Error('Session initialization timeout. Please refresh the page and try again.'));
52
+ }
53
+ }, 10000);
54
+ });
55
+ return pendingSessionPromise.current;
56
+ }, [isSessionInitialized]);
18
57
  // Create checkout resource client
19
58
  const checkoutResource = useMemo(() => {
20
59
  try {
@@ -55,6 +94,8 @@ export function useCheckoutQuery(options = {}) {
55
94
  ...params,
56
95
  storeId: params.storeId || storeId,
57
96
  returnUrl: params.returnUrl || window.location.origin,
97
+ // Include customerId from session to prevent duplicate customer creation
98
+ customerId: params.customerId || session?.customerId,
58
99
  customer: {
59
100
  ...params.customer,
60
101
  currency: params.customer?.currency ?? currency.code,
@@ -75,7 +116,7 @@ export function useCheckoutQuery(options = {}) {
75
116
  },
76
117
  });
77
118
  // Order bump functionality removed - use useOrderBumpQuery instead
78
- // Line items mutation
119
+ // Line items mutation with optimistic updates
79
120
  const lineItemsMutation = useMutation({
80
121
  mutationFn: ({ lineItems }) => {
81
122
  if (!checkout?.checkoutSession?.id) {
@@ -83,7 +124,38 @@ export function useCheckoutQuery(options = {}) {
83
124
  }
84
125
  return checkoutResource.updateLineItems(checkout.checkoutSession.id, lineItems);
85
126
  },
86
- onSuccess: () => {
127
+ onMutate: async ({ lineItems }) => {
128
+ // Cancel any outgoing refetches (so they don't overwrite our optimistic update)
129
+ await queryClient.cancelQueries({ queryKey: ['checkout', checkoutToken] });
130
+ // Snapshot the previous value
131
+ const previousCheckout = queryClient.getQueryData(['checkout', checkoutToken]);
132
+ // Optimistically update the checkout data
133
+ if (previousCheckout && checkout?.checkoutSession?.id) {
134
+ const optimisticCheckout = {
135
+ ...previousCheckout,
136
+ checkoutSession: {
137
+ ...previousCheckout.checkoutSession,
138
+ sessionLineItems: lineItems.map(item => ({
139
+ variantId: item.variantId,
140
+ quantity: item.quantity,
141
+ priceId: item.priceId,
142
+ isOrderBump: false, // Default for line items
143
+ })),
144
+ },
145
+ };
146
+ queryClient.setQueryData(['checkout', checkoutToken], optimisticCheckout);
147
+ }
148
+ // Return a context object with the snapshotted value
149
+ return { previousCheckout };
150
+ },
151
+ onError: (err, _variables, context) => {
152
+ // If the mutation fails, use the context returned from onMutate to roll back
153
+ if (context && typeof context === 'object' && 'previousCheckout' in context) {
154
+ queryClient.setQueryData(['checkout', checkoutToken], context.previousCheckout);
155
+ }
156
+ },
157
+ onSettled: () => {
158
+ // Always refetch after error or success to ensure we have the latest data
87
159
  if (checkoutToken) {
88
160
  void queryClient.invalidateQueries({ queryKey: ['checkout', checkoutToken] });
89
161
  }
@@ -173,6 +245,8 @@ export function useCheckoutQuery(options = {}) {
173
245
  isSuccess,
174
246
  // Actions
175
247
  init: async (params) => {
248
+ // Wait for session to be initialized to ensure we have customerId
249
+ await waitForSession();
176
250
  const result = await initMutation.mutateAsync(params);
177
251
  // Update internal token state so the query can fetch the checkout data
178
252
  setInternalToken(result.checkoutToken);
@@ -185,6 +259,7 @@ export function useCheckoutQuery(options = {}) {
185
259
  refresh,
186
260
  // Checkout operations
187
261
  updateLineItems: (lineItems) => lineItemsMutation.mutateAsync({ lineItems }),
262
+ updateLineItemsOptimistic: (lineItems) => lineItemsMutation.mutate({ lineItems }),
188
263
  setItemQuantity: (variantId, quantity, priceId) => quantityMutation.mutateAsync({ variantId, quantity, priceId }),
189
264
  updateCustomer: (data) => customerMutation.mutateAsync(data),
190
265
  updateCustomerAndSessionInfo: (data) => customerAndSessionMutation.mutateAsync(data),
@@ -5,7 +5,6 @@
5
5
  export interface UseOrderBumpQueryOptions {
6
6
  checkoutToken: string | null;
7
7
  offerId: string;
8
- productId?: string;
9
8
  checkout?: any;
10
9
  }
11
10
  export interface UseOrderBumpQueryResult {
@@ -6,7 +6,7 @@ import { useState, useCallback, useEffect } from 'react';
6
6
  import { useApiMutation, useInvalidateQuery, getGlobalApiClient } from './useApiQuery';
7
7
  import { useCheckoutQuery } from './useCheckoutQuery';
8
8
  export function useOrderBumpQuery(options) {
9
- const { checkoutToken, offerId, productId, checkout: providedCheckout } = options;
9
+ const { checkoutToken, offerId, checkout: providedCheckout } = options;
10
10
  const { invalidateCheckout, invalidatePromotions } = useInvalidateQuery();
11
11
  const client = getGlobalApiClient();
12
12
  // Use checkout query only if no checkout is provided
@@ -23,11 +23,12 @@ export function useOrderBumpQuery(options) {
23
23
  if (!checkout?.checkoutSession?.sessionLineItems) {
24
24
  return false;
25
25
  }
26
- const targetProductId = productId || offerId;
26
+ // Check if any order bump items exist in sessionLineItems
27
+ // The offerId is the upsell offer ID, but we check for isOrderBump === true
27
28
  return checkout.checkoutSession.sessionLineItems.some((item) => {
28
- return item.isOrderBump === true && item.productId === targetProductId;
29
+ return item.isOrderBump === true;
29
30
  });
30
- }, [checkout?.checkoutSession?.sessionLineItems, offerId, productId]);
31
+ }, [checkout?.checkoutSession?.sessionLineItems]);
31
32
  // State management
32
33
  const [isSelected, setIsSelected] = useState(() => checkOrderBumpSelection());
33
34
  const [error, setError] = useState(null);
@@ -82,7 +83,7 @@ export function useOrderBumpQuery(options) {
82
83
  const error = err instanceof Error ? err : new Error('Failed to toggle order bump');
83
84
  return { success: false, error: error.message };
84
85
  }
85
- }, [checkout?.checkoutSession?.id, isSelected, toggleMutation, offerId, productId, checkoutToken, actualCheckoutToken]);
86
+ }, [checkout?.checkoutSession?.id, isSelected, toggleMutation, offerId, checkoutToken, actualCheckoutToken]);
86
87
  return {
87
88
  isSelected,
88
89
  isToggling: toggleMutation.isPending,
@@ -11,6 +11,8 @@ export interface UsePluginConfigResult<TConfig = Record<string, any>> {
11
11
  storeId?: string;
12
12
  accountId?: string;
13
13
  basePath?: string;
14
+ loading: boolean;
15
+ error?: Error;
14
16
  isValid: boolean;
15
17
  }
16
18
  export declare function usePluginConfig<TConfig = Record<string, any>>(options?: UsePluginConfigOptions): UsePluginConfigResult<TConfig>;
@@ -6,7 +6,7 @@ import { useMemo } from 'react';
6
6
  import { useTagadaContext } from '../providers/TagadaProvider';
7
7
  import { PluginConfigUtils } from '../../core/utils/pluginConfig';
8
8
  export function usePluginConfig(options = {}) {
9
- const { pluginConfig } = useTagadaContext();
9
+ const { pluginConfig, pluginConfigLoading } = useTagadaContext();
10
10
  const config = useMemo(() => {
11
11
  const baseConfig = PluginConfigUtils.getPluginConfig(options.rawConfig, {
12
12
  storeId: pluginConfig.storeId,
@@ -30,6 +30,8 @@ export function usePluginConfig(options = {}) {
30
30
  storeId: config.storeId,
31
31
  accountId: config.accountId,
32
32
  basePath: config.basePath,
33
+ loading: pluginConfigLoading,
34
+ error: undefined, // TODO: Add error handling if needed
33
35
  isValid,
34
36
  };
35
37
  }
@@ -48,9 +48,39 @@ const InitializationLoader = () => (_jsxs("div", { style: {
48
48
  }
49
49
  ` })] }));
50
50
  const TagadaContext = createContext(null);
51
+ // Global instance tracking for TagadaProvider
52
+ let globalTagadaInstance = null;
53
+ let globalTagadaInitialized = false;
51
54
  export function TagadaProvider({ children, environment, customApiConfig, debugMode, // Remove default, will be set based on environment
52
55
  localConfig, blockUntilSessionReady = false, // Default to new non-blocking behavior
53
56
  rawPluginConfig, }) {
57
+ // Instance tracking
58
+ const [instanceId] = useState(() => {
59
+ if (!globalTagadaInstance) {
60
+ globalTagadaInstance = Math.random().toString(36).substr(2, 9);
61
+ }
62
+ return globalTagadaInstance;
63
+ });
64
+ const isActiveInstance = useMemo(() => {
65
+ if (!globalTagadaInitialized) {
66
+ globalTagadaInitialized = true;
67
+ console.log(`โœ… [TagadaProvider] Instance ${instanceId} is now the active instance`);
68
+ return true;
69
+ }
70
+ else {
71
+ console.log(`๐Ÿšซ [TagadaProvider] Instance ${instanceId} is duplicate - blocking execution`);
72
+ return false;
73
+ }
74
+ }, [instanceId]);
75
+ useEffect(() => {
76
+ return () => {
77
+ if (globalTagadaInstance === instanceId) {
78
+ globalTagadaInitialized = false;
79
+ globalTagadaInstance = null;
80
+ console.log(`๐Ÿงน [TagadaProvider] Instance ${instanceId} cleanup - allowing new instances`);
81
+ }
82
+ };
83
+ }, [instanceId]);
54
84
  // LOCAL DEV ONLY: Use localConfig override if in local development, otherwise use default
55
85
  const isLocalDev = typeof window !== 'undefined' &&
56
86
  (window.location.hostname === 'localhost' ||
@@ -60,7 +90,7 @@ rawPluginConfig, }) {
60
90
  // Debug logging (only log once during initial render)
61
91
  const hasLoggedRef = useRef(false);
62
92
  if (!hasLoggedRef.current) {
63
- console.log('๐Ÿ” TagadaProvider Config Debug:', {
93
+ console.log(`๐Ÿ” [TagadaProvider] Instance ${instanceId} Config Debug:`, {
64
94
  hostname: typeof window !== 'undefined' ? window.location.hostname : 'SSR',
65
95
  isLocalDev,
66
96
  localConfig,
@@ -84,6 +114,11 @@ rawPluginConfig, }) {
84
114
  const [configLoading, setConfigLoading] = useState(!rawPluginConfig);
85
115
  // Load plugin config on mount with the specified variant
86
116
  useEffect(() => {
117
+ // Prevent multiple config loads
118
+ if (configLoading === false && pluginConfig.storeId) {
119
+ console.log('๐Ÿ”’ [TagadaProvider] Config already loaded, skipping reload');
120
+ return;
121
+ }
87
122
  const loadConfig = async () => {
88
123
  try {
89
124
  // Use the v2 core loadPluginConfig function
@@ -239,12 +274,21 @@ rawPluginConfig, }) {
239
274
  });
240
275
  // Initialize session
241
276
  const initializeSession = useCallback(async (sessionData) => {
277
+ if (!isActiveInstance) {
278
+ console.log(`๐Ÿšซ [TagadaProvider] Instance ${instanceId} is not active, skipping session initialization`);
279
+ return;
280
+ }
242
281
  if (!sessionData.storeId || !sessionData.accountId) {
243
- console.error('[SDK] Missing required session data');
282
+ console.error(`[TagadaProvider] Instance ${instanceId} missing required session data`);
283
+ return;
284
+ }
285
+ // Prevent multiple session initializations
286
+ if (isSessionInitialized) {
287
+ console.log(`๐Ÿ”’ [TagadaProvider] Instance ${instanceId} session already initialized, skipping`);
244
288
  return;
245
289
  }
246
290
  if (finalDebugMode) {
247
- console.debug('[SDK][DEBUG] Initializing session with store config...', sessionData);
291
+ console.debug(`[TagadaProvider] Instance ${instanceId} [DEBUG] Initializing session with store config...`, sessionData);
248
292
  }
249
293
  setIsLoading(true);
250
294
  try {
@@ -352,15 +396,23 @@ rawPluginConfig, }) {
352
396
  setIsInitialized(true);
353
397
  setIsLoading(false);
354
398
  }
355
- }, [apiService, finalDebugMode]);
399
+ }, [apiService, finalDebugMode, isActiveInstance, instanceId]);
356
400
  // Create anonymous token if needed
357
401
  const createAnonymousToken = useCallback(async (targetStoreId) => {
402
+ if (!isActiveInstance) {
403
+ console.log(`๐Ÿšซ [TagadaProvider] Instance ${instanceId} is not active, skipping anonymous token creation`);
404
+ return;
405
+ }
358
406
  if (hasAttemptedAnonymousToken || !targetStoreId) {
407
+ console.log(`๐Ÿ”’ [TagadaProvider] Instance ${instanceId} anonymous token already attempted or no storeId:`, {
408
+ hasAttemptedAnonymousToken,
409
+ targetStoreId,
410
+ });
359
411
  return;
360
412
  }
361
- console.log('[SDK] ๐Ÿš€ Starting Phase 3 - Session initialization...');
413
+ console.log(`[TagadaProvider] Instance ${instanceId} ๐Ÿš€ Starting Phase 3 - Session initialization...`);
362
414
  if (finalDebugMode) {
363
- console.debug('[SDK][DEBUG] Creating anonymous token for store:', targetStoreId);
415
+ console.debug(`[TagadaProvider] Instance ${instanceId} [DEBUG] Creating anonymous token for store:`, targetStoreId);
364
416
  }
365
417
  setHasAttemptedAnonymousToken(true);
366
418
  try {
@@ -405,15 +457,24 @@ rawPluginConfig, }) {
405
457
  setIsInitialized(true);
406
458
  setIsLoading(false);
407
459
  }
408
- }, [apiService, hasAttemptedAnonymousToken, initializeSession, finalDebugMode]);
460
+ }, [apiService, hasAttemptedAnonymousToken, initializeSession, finalDebugMode, isActiveInstance, instanceId]);
409
461
  // Initialize token from storage or create anonymous token
410
462
  // This runs in the background after phases 1 & 2 complete, but doesn't block rendering
411
463
  useEffect(() => {
464
+ if (!isActiveInstance) {
465
+ console.log(`๐Ÿšซ [TagadaProvider] Instance ${instanceId} is not active, skipping token initialization`);
466
+ return;
467
+ }
412
468
  if (isInitializing.current) {
469
+ console.log(`๐Ÿ”’ [TagadaProvider] Instance ${instanceId} already initializing, skipping`);
413
470
  return;
414
471
  }
415
472
  // Wait for plugin config to load AND ensure we have a store ID before initializing
416
473
  if (configLoading || !storeId) {
474
+ console.log(`โณ [TagadaProvider] Instance ${instanceId} waiting for config or storeId:`, {
475
+ configLoading,
476
+ storeId,
477
+ });
417
478
  return;
418
479
  }
419
480
  isInitializing.current = true;
@@ -469,7 +530,7 @@ rawPluginConfig, }) {
469
530
  }
470
531
  };
471
532
  void initializeToken();
472
- }, [storeId, createAnonymousToken, initializeSession, configLoading]);
533
+ }, [storeId, createAnonymousToken, initializeSession, configLoading, isActiveInstance, instanceId]);
473
534
  // Update auth state when customer/session changes
474
535
  useEffect(() => {
475
536
  setAuth({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tagadapay/plugin-sdk",
3
- "version": "2.5.2",
3
+ "version": "2.6.2",
4
4
  "description": "Modern React SDK for building Tagada Pay plugins",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -42,7 +42,7 @@
42
42
  "version:sync": "node version-sync.js sync",
43
43
  "version:list": "node version-sync.js list",
44
44
  "version:next": "node version-sync.js next",
45
- "postversion": "echo \"โœ… Version updated to $(node -p 'require(\"./package.json\").version')\" && git push && git push --tags"
45
+ "postversion": "echo \"โœ… Version updated to $(node -p 'require(\"./package.json\").version')\" && (git push && git push --tags || echo \"โš ๏ธ Git push failed - you may need to pull and push manually\")"
46
46
  },
47
47
  "keywords": [
48
48
  "tagadapay",