feat: Implement OAuth license activation flow

- Add LicenseConnect.tsx focused OAuth confirmation page in customer SPA
- Add /licenses/oauth/validate and /licenses/oauth/confirm API endpoints
- Update App.tsx to render license-connect outside BaseLayout (no header/footer)
- Add license_activation_method field to product settings in Admin SPA
- Create LICENSING_MODULE.md with comprehensive OAuth flow documentation
- Update API_ROUTES.md with license module endpoints
This commit is contained in:
Dwindi Ramadhana
2026-01-31 22:22:22 +07:00
parent d80f34c8b9
commit a0b5f8496d
23 changed files with 3218 additions and 806 deletions

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { toast } from 'sonner';
@@ -19,114 +20,115 @@ interface WishlistItem {
const GUEST_WISHLIST_KEY = 'woonoow_guest_wishlist';
export function useWishlist() {
const [items, setItems] = useState<WishlistItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [productIds, setProductIds] = useState<Set<number>>(new Set());
const queryClient = useQueryClient();
const [guestIds, setGuestIds] = useState<Set<number>>(new Set());
// Check if wishlist is enabled (default true if not explicitly set to false)
const settings = (window as any).woonoowCustomer?.settings;
const isEnabled = settings?.wishlist_enabled !== false;
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
// Load guest wishlist from localStorage
const loadGuestWishlist = useCallback(() => {
try {
const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
if (stored) {
const guestIds = JSON.parse(stored) as number[];
setProductIds(new Set(guestIds));
// Load guest wishlist on mount
useEffect(() => {
if (!isLoggedIn) {
try {
const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
if (stored) {
const ids = JSON.parse(stored) as number[];
setGuestIds(new Set(ids));
}
} catch (error) {
console.error('Failed to load guest wishlist:', error);
}
} catch (error) {
console.error('Failed to load guest wishlist:', error);
}
}, []);
}, [isLoggedIn]);
// Save guest wishlist to localStorage
// Save guest wishlist helper
const saveGuestWishlist = useCallback((ids: Set<number>) => {
try {
localStorage.setItem(GUEST_WISHLIST_KEY, JSON.stringify(Array.from(ids)));
setGuestIds(ids);
} catch (error) {
console.error('Failed to save guest wishlist:', error);
}
}, []);
// Load wishlist on mount
useEffect(() => {
if (isEnabled) {
if (isLoggedIn) {
loadWishlist();
} else {
loadGuestWishlist();
}
}
}, [isEnabled, isLoggedIn]);
// Fetch wishlist items (Server)
const { data: serverItems = [], isLoading: isServerLoading } = useQuery({
queryKey: ['wishlist'],
queryFn: async () => {
return await api.get<WishlistItem[]>('/account/wishlist');
},
enabled: isEnabled && isLoggedIn,
staleTime: 60 * 1000, // 1 minute cache
retry: 1,
});
const loadWishlist = useCallback(async () => {
if (!isLoggedIn) return;
try {
setIsLoading(true);
const data = await api.get<WishlistItem[]>('/account/wishlist');
setItems(data);
setProductIds(new Set(data.map(item => item.product_id)));
} catch (error) {
console.error('Failed to load wishlist:', error);
} finally {
setIsLoading(false);
}
}, [isLoggedIn]);
// Calculate merged state
const items = isLoggedIn ? serverItems : []; // Guest items not stored as full objects here, usually handled by fetching products by ID elsewhere
const isLoading = isLoggedIn ? isServerLoading : false;
const addToWishlist = useCallback(async (productId: number) => {
// Guest mode: store in localStorage only
if (!isLoggedIn) {
const newIds = new Set(productIds);
newIds.add(productId);
setProductIds(newIds);
saveGuestWishlist(newIds);
// Compute set of IDs for O(1) lookup
const productIds = useMemo(() => {
if (isLoggedIn) {
return new Set(serverItems.map(item => item.product_id));
}
return guestIds;
}, [isLoggedIn, serverItems, guestIds]);
// Mutations
const addMutation = useMutation({
mutationFn: async (productId: number) => {
return await api.post('/account/wishlist', { product_id: productId });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wishlist'] });
toast.success('Added to wishlist');
return true;
}
// Logged in: use API
try {
await api.post('/account/wishlist', { product_id: productId });
await loadWishlist(); // Reload to get full product details
toast.success('Added to wishlist');
return true;
} catch (error: any) {
},
onError: (error: any) => {
const message = error?.message || 'Failed to add to wishlist';
toast.error(message);
return false;
}
}, [isLoggedIn, productIds, loadWishlist, saveGuestWishlist]);
});
const removeMutation = useMutation({
mutationFn: async (productId: number) => {
return await api.delete(`/account/wishlist/${productId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wishlist'] });
toast.success('Removed from wishlist');
},
onError: () => {
toast.error('Failed to remove from wishlist');
}
});
const addToWishlist = useCallback(async (productId: number) => {
if (!isLoggedIn) {
const newIds = new Set(guestIds);
newIds.add(productId);
saveGuestWishlist(newIds);
toast.success('Added to wishlist');
return true;
}
await addMutation.mutateAsync(productId);
return true;
}, [isLoggedIn, guestIds, saveGuestWishlist, addMutation]);
const removeFromWishlist = useCallback(async (productId: number) => {
// Guest mode: remove from localStorage only
if (!isLoggedIn) {
const newIds = new Set(productIds);
const newIds = new Set(guestIds);
newIds.delete(productId);
setProductIds(newIds);
saveGuestWishlist(newIds);
toast.success('Removed from wishlist');
return true;
}
// Logged in: use API
try {
await api.delete(`/account/wishlist/${productId}`);
setItems(items.filter(item => item.product_id !== productId));
setProductIds(prev => {
const newSet = new Set(prev);
newSet.delete(productId);
return newSet;
});
toast.success('Removed from wishlist');
return true;
} catch (error) {
toast.error('Failed to remove from wishlist');
return false;
}
}, [isLoggedIn, productIds, items, saveGuestWishlist]);
await removeMutation.mutateAsync(productId);
return true;
}, [isLoggedIn, guestIds, saveGuestWishlist, removeMutation]);
const toggleWishlist = useCallback(async (productId: number) => {
if (productIds.has(productId)) {
@@ -145,12 +147,12 @@ export function useWishlist() {
isLoading,
isEnabled,
isLoggedIn,
count: items.length,
count: productIds.size,
productIds,
addToWishlist,
removeFromWishlist,
toggleWishlist,
isInWishlist,
refresh: loadWishlist,
refresh: () => queryClient.invalidateQueries({ queryKey: ['wishlist'] }),
};
}