refactor: unify api client usage around openapi client
This commit is contained in:
parent
a22b84f128
commit
fcc5872fda
@ -1,13 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* Core API Client
|
* Core API client configuration
|
||||||
* Minimal API client setup using the generated OpenAPI client
|
* Wraps the shared generated client to inject portal-specific behavior like auth headers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createClient } from "@customer-portal/api-client";
|
import {
|
||||||
|
createClient as createOpenApiClient,
|
||||||
|
type ApiClient as GeneratedApiClient,
|
||||||
|
type AuthHeaderResolver,
|
||||||
|
} from "@customer-portal/api-client";
|
||||||
import { env } from "../config/env";
|
import { env } from "../config/env";
|
||||||
|
|
||||||
// Create the type-safe API client
|
const baseUrl = env.NEXT_PUBLIC_API_BASE;
|
||||||
export const apiClient = createClient(env.NEXT_PUBLIC_API_BASE);
|
|
||||||
|
|
||||||
// Export the client type for use in hooks
|
let authHeaderResolver: AuthHeaderResolver | undefined;
|
||||||
export type { ApiClient } from "@customer-portal/api-client";
|
|
||||||
|
const resolveAuthHeader: AuthHeaderResolver = () => authHeaderResolver?.();
|
||||||
|
|
||||||
|
export const apiClient: GeneratedApiClient = createOpenApiClient(baseUrl, {
|
||||||
|
getAuthHeader: resolveAuthHeader,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const configureApiClientAuth = (resolver?: AuthHeaderResolver) => {
|
||||||
|
authHeaderResolver = resolver;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiClient = GeneratedApiClient;
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export { apiClient } from "./client";
|
export { apiClient, configureApiClientAuth } from "./client";
|
||||||
export { queryKeys } from "./query-keys";
|
export { queryKeys } from "./query-keys";
|
||||||
export type { ApiClient } from "./client";
|
export type { ApiClient } from "./client";
|
||||||
|
export type { AuthHeaderResolver } from "@customer-portal/api-client";
|
||||||
|
|||||||
@ -85,22 +85,11 @@ export function useProfileData() {
|
|||||||
const saveProfile = async (next: ProfileFormData) => {
|
const saveProfile = async (next: ProfileFormData) => {
|
||||||
setIsSavingProfile(true);
|
setIsSavingProfile(true);
|
||||||
try {
|
try {
|
||||||
const { tokens } = useAuthStore.getState();
|
const updatedUser = await accountService.updateProfile({
|
||||||
if (!tokens?.accessToken) throw new Error("Authentication required");
|
firstName: next.firstName,
|
||||||
const response = await fetch("/api/me", {
|
lastName: next.lastName,
|
||||||
method: "PATCH",
|
phone: next.phone,
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${tokens.accessToken}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
firstName: next.firstName,
|
|
||||||
lastName: next.lastName,
|
|
||||||
phone: next.phone,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error("Failed to update profile");
|
|
||||||
const updatedUser = (await response.json()) as Partial<typeof user>;
|
|
||||||
useAuthStore.setState(state => ({
|
useAuthStore.setState(state => ({
|
||||||
...state,
|
...state,
|
||||||
user: state.user ? { ...state.user, ...updatedUser } : state.user,
|
user: state.user ? { ...state.user, ...updatedUser } : state.user,
|
||||||
|
|||||||
37
apps/portal/src/features/account/services/account.service.ts
Normal file
37
apps/portal/src/features/account/services/account.service.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { apiClient } from "@/core/api";
|
||||||
|
import type { Address, AuthUser } from "@customer-portal/domain";
|
||||||
|
|
||||||
|
const ensureData = <T>(data: T | undefined): T => {
|
||||||
|
if (typeof data === "undefined") {
|
||||||
|
throw new Error("No data returned from server");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProfileUpdateInput = {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
phone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const accountService = {
|
||||||
|
async getProfile() {
|
||||||
|
const response = await apiClient.GET('/me');
|
||||||
|
return ensureData<AuthUser | null>(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateProfile(update: ProfileUpdateInput) {
|
||||||
|
const response = await apiClient.PATCH('/me', { body: update });
|
||||||
|
return ensureData<AuthUser>(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAddress() {
|
||||||
|
const response = await apiClient.GET('/me/address');
|
||||||
|
return ensureData<Address | null>(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAddress(address: Address) {
|
||||||
|
const response = await apiClient.PATCH('/me/address', { body: address });
|
||||||
|
return ensureData<Address>(response.data);
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist, createJSONStorage } from "zustand/middleware";
|
import { persist, createJSONStorage } from "zustand/middleware";
|
||||||
import { apiClient } from "@/core/api";
|
import { apiClient, configureApiClientAuth } from "@/core/api";
|
||||||
import type {
|
import type {
|
||||||
AuthTokens,
|
AuthTokens,
|
||||||
SignupRequest,
|
SignupRequest,
|
||||||
@ -17,6 +17,62 @@ import type {
|
|||||||
AuthError,
|
AuthError,
|
||||||
} from "@customer-portal/domain";
|
} from "@customer-portal/domain";
|
||||||
|
|
||||||
|
const DEFAULT_TOKEN_TYPE = "Bearer";
|
||||||
|
|
||||||
|
type RawAuthTokens =
|
||||||
|
| AuthTokens
|
||||||
|
| (Omit<AuthTokens, "expiresAt"> & { expiresAt: string | number | Date });
|
||||||
|
|
||||||
|
const toIsoString = (value: string | number | Date) => {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return new Date(value).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeAuthTokens = (tokens: RawAuthTokens): AuthTokens => ({
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
tokenType: tokens.tokenType ?? DEFAULT_TOKEN_TYPE,
|
||||||
|
expiresAt: toIsoString(tokens.expiresAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getExpiryTime = (tokens?: AuthTokens | null) => {
|
||||||
|
if (!tokens?.expiresAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiry = Date.parse(tokens.expiresAt);
|
||||||
|
return Number.isNaN(expiry) ? null : expiry;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasValidAccessToken = (tokens?: AuthTokens | null) => {
|
||||||
|
if (!tokens?.accessToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiry = getExpiryTime(tokens);
|
||||||
|
if (expiry === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Date.now() < expiry;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildAuthorizationHeader = (tokens?: AuthTokens | null) => {
|
||||||
|
if (!tokens?.accessToken) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenType = tokens.tokenType ?? DEFAULT_TOKEN_TYPE;
|
||||||
|
return `${tokenType} ${tokens.accessToken}`;
|
||||||
|
};
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
@ -54,7 +110,7 @@ interface AuthStoreState extends AuthState {
|
|||||||
setLoading: (loading: boolean) => void;
|
setLoading: (loading: boolean) => void;
|
||||||
setError: (error: string | null) => void;
|
setError: (error: string | null) => void;
|
||||||
setUser: (user: AuthUser | null) => void;
|
setUser: (user: AuthUser | null) => void;
|
||||||
setTokens: (tokens: AuthTokens | null) => void;
|
setTokens: (tokens: RawAuthTokens | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthStoreState>()(
|
export const useAuthStore = create<AuthStoreState>()(
|
||||||
@ -76,9 +132,10 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.POST('/auth/login', { body: credentials });
|
const response = await apiClient.POST('/auth/login', { body: credentials });
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
|
const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens);
|
||||||
set({
|
set({
|
||||||
user: response.data.user,
|
user: response.data.user,
|
||||||
tokens: response.data.tokens,
|
tokens,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@ -104,9 +161,10 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.POST('/auth/signup', { body: data });
|
const response = await apiClient.POST('/auth/signup', { body: data });
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
|
const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens);
|
||||||
set({
|
set({
|
||||||
user: response.data.user,
|
user: response.data.user,
|
||||||
tokens: response.data.tokens,
|
tokens,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@ -164,9 +222,10 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.POST('/auth/reset-password', { body: data });
|
const response = await apiClient.POST('/auth/reset-password', { body: data });
|
||||||
const { user, tokens } = response.data!;
|
const { user, tokens } = response.data!;
|
||||||
|
const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens);
|
||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
tokens,
|
tokens: normalizedTokens,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@ -194,9 +253,10 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.POST('/auth/change-password', { body: data });
|
const response = await apiClient.POST('/auth/change-password', { body: data });
|
||||||
const { user, tokens: newTokens } = response.data!;
|
const { user, tokens: newTokens } = response.data!;
|
||||||
|
const normalizedTokens = normalizeAuthTokens(newTokens as RawAuthTokens);
|
||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
tokens: newTokens,
|
tokens: normalizedTokens,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@ -240,9 +300,10 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.POST('/auth/set-password', { body: { email, password } });
|
const response = await apiClient.POST('/auth/set-password', { body: { email, password } });
|
||||||
const { user, tokens } = response.data!;
|
const { user, tokens } = response.data!;
|
||||||
|
const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens);
|
||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
tokens,
|
tokens: normalizedTokens,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@ -264,22 +325,26 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
checkAuth: async () => {
|
checkAuth: async () => {
|
||||||
const { tokens } = get();
|
const { tokens } = get();
|
||||||
|
|
||||||
if (!tokens?.accessToken) {
|
if (!hasValidAccessToken(tokens)) {
|
||||||
set({ isAuthenticated: false, loading: false, user: null, tokens: null, hasCheckedAuth: true });
|
set({
|
||||||
return;
|
isAuthenticated: false,
|
||||||
}
|
loading: false,
|
||||||
|
user: null,
|
||||||
// Check if token is expired
|
tokens: null,
|
||||||
if (Date.now() >= tokens.expiresAt) {
|
hasCheckedAuth: true,
|
||||||
set({ isAuthenticated: false, loading: false, user: null, tokens: null, hasCheckedAuth: true });
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ loading: true });
|
set({ loading: true });
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.GET('/me');
|
const response = await apiClient.GET('/me');
|
||||||
const user = response.data!;
|
const user = response.data ?? null;
|
||||||
set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true });
|
if (user) {
|
||||||
|
set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true });
|
||||||
|
} else {
|
||||||
|
set({ loading: false, hasCheckedAuth: true });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Token is invalid, clear auth state
|
// Token is invalid, clear auth state
|
||||||
console.info("Token validation failed, clearing auth state");
|
console.info("Token validation failed, clearing auth state");
|
||||||
@ -301,8 +366,14 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expiry = getExpiryTime(tokens);
|
||||||
|
if (expiry === null) {
|
||||||
|
await checkAuth();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if token needs refresh (expires within 5 minutes)
|
// Check if token needs refresh (expires within 5 minutes)
|
||||||
if (Date.now() >= tokens.expiresAt - 5 * 60 * 1000) { // 5 minutes before expiry
|
if (Date.now() >= expiry - 5 * 60 * 1000) {
|
||||||
// For now, just re-validate the token
|
// For now, just re-validate the token
|
||||||
// In a real implementation, you would call a refresh token endpoint
|
// In a real implementation, you would call a refresh token endpoint
|
||||||
await checkAuth();
|
await checkAuth();
|
||||||
@ -318,7 +389,8 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
|
|
||||||
setUser: (user: AuthUser | null) => set({ user }),
|
setUser: (user: AuthUser | null) => set({ user }),
|
||||||
|
|
||||||
setTokens: (tokens: AuthTokens | null) => set({ tokens }),
|
setTokens: (tokens: RawAuthTokens | null) =>
|
||||||
|
set({ tokens: tokens ? normalizeAuthTokens(tokens) : null }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "auth-store",
|
name: "auth-store",
|
||||||
@ -352,18 +424,45 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resolveAuthorizationHeader = () =>
|
||||||
|
buildAuthorizationHeader(useAuthStore.getState().tokens);
|
||||||
|
|
||||||
|
configureApiClientAuth(resolveAuthorizationHeader);
|
||||||
|
|
||||||
|
export const getAuthorizationHeader = resolveAuthorizationHeader;
|
||||||
|
|
||||||
|
export const selectAuthTokens = (state: AuthStoreState) => state.tokens;
|
||||||
|
export const selectIsAuthenticated = (state: AuthStoreState) => state.isAuthenticated;
|
||||||
|
export const selectHasValidAccessToken = (state: AuthStoreState) =>
|
||||||
|
hasValidAccessToken(state.tokens);
|
||||||
|
|
||||||
|
export const useAuthSession = () =>
|
||||||
|
useAuthStore(state => ({
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
hasValidToken: hasValidAccessToken(state.tokens),
|
||||||
|
tokens: state.tokens,
|
||||||
|
}));
|
||||||
|
|
||||||
// Session timeout detection
|
// Session timeout detection
|
||||||
let sessionTimeoutId: NodeJS.Timeout | null = null;
|
let sessionTimeoutId: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
export const startSessionTimeout = () => {
|
export const startSessionTimeout = () => {
|
||||||
const checkSession = () => {
|
const checkSession = () => {
|
||||||
const state = useAuthStore.getState();
|
const state = useAuthStore.getState();
|
||||||
if (state.tokens?.accessToken) {
|
if (!state.tokens?.accessToken) {
|
||||||
if (Date.now() >= state.tokens.expiresAt) {
|
return;
|
||||||
void state.logout();
|
}
|
||||||
} else {
|
|
||||||
void state.refreshSession();
|
const expiry = getExpiryTime(state.tokens);
|
||||||
}
|
if (expiry === null) {
|
||||||
|
void state.logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() >= expiry) {
|
||||||
|
void state.logout();
|
||||||
|
} else {
|
||||||
|
void state.refreshSession();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -42,8 +42,11 @@ export function ChangePlanModal({
|
|||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.post(`/subscriptions/${subscriptionId}/sim/change-plan`, {
|
await apiClient.POST("/subscriptions/{id}/sim/change-plan", {
|
||||||
newPlanCode: newPlanCode,
|
params: { path: { id: subscriptionId } },
|
||||||
|
body: {
|
||||||
|
newPlanCode,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|||||||
@ -58,7 +58,9 @@ export function SimActions({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`);
|
await apiClient.POST("/subscriptions/{id}/sim/reissue-esim", {
|
||||||
|
params: { path: { id: subscriptionId } },
|
||||||
|
});
|
||||||
|
|
||||||
setSuccess("eSIM profile reissued successfully");
|
setSuccess("eSIM profile reissued successfully");
|
||||||
setShowReissueConfirm(false);
|
setShowReissueConfirm(false);
|
||||||
@ -75,7 +77,9 @@ export function SimActions({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post(`/subscriptions/${subscriptionId}/sim/cancel`, {});
|
await apiClient.POST("/subscriptions/{id}/sim/cancel", {
|
||||||
|
params: { path: { id: subscriptionId } },
|
||||||
|
});
|
||||||
|
|
||||||
setSuccess("SIM service cancelled successfully");
|
setSuccess("SIM service cancelled successfully");
|
||||||
setShowCancelConfirm(false);
|
setShowCancelConfirm(false);
|
||||||
|
|||||||
@ -75,10 +75,10 @@ export function SimFeatureToggles({
|
|||||||
if (nt !== initial.nt) featurePayload.networkType = nt;
|
if (nt !== initial.nt) featurePayload.networkType = nt;
|
||||||
|
|
||||||
if (Object.keys(featurePayload).length > 0) {
|
if (Object.keys(featurePayload).length > 0) {
|
||||||
await apiClient.post(
|
await apiClient.POST("/subscriptions/{id}/sim/features", {
|
||||||
`/subscriptions/${subscriptionId}/sim/features`,
|
params: { path: { id: subscriptionId } },
|
||||||
featurePayload
|
body: featurePayload,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccess("Changes submitted successfully");
|
setSuccess("Changes submitted successfully");
|
||||||
|
|||||||
@ -30,24 +30,31 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const data = await apiClient.get<{
|
const response = await apiClient.GET("/subscriptions/{id}/sim", {
|
||||||
details: SimDetails;
|
params: { path: { id: subscriptionId } },
|
||||||
usage: SimUsage;
|
});
|
||||||
}>(`/subscriptions/${subscriptionId}/sim`);
|
|
||||||
|
|
||||||
setSimInfo(data);
|
const payload = response.data as SimInfo | undefined;
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error("Failed to load SIM information");
|
||||||
|
}
|
||||||
|
|
||||||
|
setSimInfo(payload);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const hasStatus = (v: unknown): v is { status: number } =>
|
const hasStatus = (value: unknown): value is { status: number } =>
|
||||||
typeof v === "object" &&
|
typeof value === "object" &&
|
||||||
v !== null &&
|
value !== null &&
|
||||||
"status" in v &&
|
"status" in value &&
|
||||||
typeof (v as { status: unknown }).status === "number";
|
typeof (value as { status: unknown }).status === "number";
|
||||||
|
|
||||||
if (hasStatus(err) && err.status === 400) {
|
if (hasStatus(err) && err.status === 400) {
|
||||||
// Not a SIM subscription - this component shouldn't be shown
|
// Not a SIM subscription - this component shouldn't be shown
|
||||||
setError("This subscription is not a SIM service");
|
setError("This subscription is not a SIM service");
|
||||||
} else {
|
return;
|
||||||
setError(err instanceof Error ? err.message : "Failed to load SIM information");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load SIM information");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,7 +45,10 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
quotaMb: getCurrentAmountMb(),
|
quotaMb: getCurrentAmountMb(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await apiClient.post(`/subscriptions/${subscriptionId}/sim/top-up`, requestBody);
|
await apiClient.POST("/subscriptions/{id}/sim/top-up", {
|
||||||
|
params: { path: { id: subscriptionId } },
|
||||||
|
body: requestBody,
|
||||||
|
});
|
||||||
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||||
import { apiClient } from "@/core/api";
|
import { apiClient } from "@/core/api";
|
||||||
import type { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/domain";
|
import type { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/domain";
|
||||||
|
|
||||||
@ -17,26 +17,29 @@ interface UseSubscriptionsOptions {
|
|||||||
*/
|
*/
|
||||||
export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
|
export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
|
||||||
const { status } = options;
|
const { status } = options;
|
||||||
const { tokens, isAuthenticated } = useAuthStore();
|
const { isAuthenticated, hasValidToken } = useAuthSession();
|
||||||
|
|
||||||
return useQuery<SubscriptionList | Subscription[]>({
|
return useQuery<SubscriptionList | Subscription[]>({
|
||||||
queryKey: ["subscriptions", status],
|
queryKey: ["subscriptions", status],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!tokens?.accessToken) {
|
if (!hasValidToken) {
|
||||||
throw new Error("Authentication required");
|
throw new Error("Authentication required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const response = await apiClient.GET(
|
||||||
...(status && { status }),
|
"/subscriptions",
|
||||||
});
|
status ? { params: { query: { status } } } : undefined
|
||||||
const res = await apiClient.get<SubscriptionList | Subscription[]>(
|
|
||||||
`/subscriptions?${params}`
|
|
||||||
);
|
);
|
||||||
return res.data as SubscriptionList | Subscription[];
|
|
||||||
|
if (!response.data) {
|
||||||
|
return [] as Subscription[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data as SubscriptionList | Subscription[];
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||||
enabled: isAuthenticated && !!tokens?.accessToken,
|
enabled: isAuthenticated && hasValidToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,21 +47,21 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
|
|||||||
* Hook to fetch active subscriptions only
|
* Hook to fetch active subscriptions only
|
||||||
*/
|
*/
|
||||||
export function useActiveSubscriptions() {
|
export function useActiveSubscriptions() {
|
||||||
const { tokens, isAuthenticated } = useAuthStore();
|
const { isAuthenticated, hasValidToken } = useAuthSession();
|
||||||
|
|
||||||
return useQuery<Subscription[]>({
|
return useQuery<Subscription[]>({
|
||||||
queryKey: ["subscriptions", "active"],
|
queryKey: ["subscriptions", "active"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!tokens?.accessToken) {
|
if (!hasValidToken) {
|
||||||
throw new Error("Authentication required");
|
throw new Error("Authentication required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await apiClient.get<Subscription[]>(`/subscriptions/active`);
|
const response = await apiClient.GET("/subscriptions/active");
|
||||||
return res.data as Subscription[];
|
return (response.data ?? []) as Subscription[];
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||||
enabled: isAuthenticated && !!tokens?.accessToken,
|
enabled: isAuthenticated && hasValidToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,7 +69,7 @@ export function useActiveSubscriptions() {
|
|||||||
* Hook to fetch subscription statistics
|
* Hook to fetch subscription statistics
|
||||||
*/
|
*/
|
||||||
export function useSubscriptionStats() {
|
export function useSubscriptionStats() {
|
||||||
const { tokens, isAuthenticated } = useAuthStore();
|
const { isAuthenticated, hasValidToken } = useAuthSession();
|
||||||
|
|
||||||
return useQuery<{
|
return useQuery<{
|
||||||
total: number;
|
total: number;
|
||||||
@ -77,18 +80,18 @@ export function useSubscriptionStats() {
|
|||||||
}>({
|
}>({
|
||||||
queryKey: ["subscriptions", "stats"],
|
queryKey: ["subscriptions", "stats"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!tokens?.accessToken) {
|
if (!hasValidToken) {
|
||||||
throw new Error("Authentication required");
|
throw new Error("Authentication required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await apiClient.get<{
|
const response = await apiClient.GET("/subscriptions/stats");
|
||||||
total: number;
|
return (response.data ?? {
|
||||||
active: number;
|
total: 0,
|
||||||
suspended: number;
|
active: 0,
|
||||||
cancelled: number;
|
suspended: 0,
|
||||||
pending: number;
|
cancelled: 0,
|
||||||
}>(`/subscriptions/stats`);
|
pending: 0,
|
||||||
return res.data as {
|
}) as {
|
||||||
total: number;
|
total: number;
|
||||||
active: number;
|
active: number;
|
||||||
suspended: number;
|
suspended: number;
|
||||||
@ -98,7 +101,7 @@ export function useSubscriptionStats() {
|
|||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||||
enabled: isAuthenticated && !!tokens?.accessToken,
|
enabled: isAuthenticated && hasValidToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,21 +109,28 @@ export function useSubscriptionStats() {
|
|||||||
* Hook to fetch a specific subscription
|
* Hook to fetch a specific subscription
|
||||||
*/
|
*/
|
||||||
export function useSubscription(subscriptionId: number) {
|
export function useSubscription(subscriptionId: number) {
|
||||||
const { tokens, isAuthenticated } = useAuthStore();
|
const { isAuthenticated, hasValidToken } = useAuthSession();
|
||||||
|
|
||||||
return useQuery<Subscription>({
|
return useQuery<Subscription>({
|
||||||
queryKey: ["subscription", subscriptionId],
|
queryKey: ["subscription", subscriptionId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!tokens?.accessToken) {
|
if (!hasValidToken) {
|
||||||
throw new Error("Authentication required");
|
throw new Error("Authentication required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await apiClient.get<Subscription>(`/subscriptions/${subscriptionId}`);
|
const response = await apiClient.GET("/subscriptions/{id}", {
|
||||||
return res.data as Subscription;
|
params: { path: { id: subscriptionId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.data) {
|
||||||
|
throw new Error("Subscription not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data as Subscription;
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||||
enabled: isAuthenticated && !!tokens?.accessToken,
|
enabled: isAuthenticated && hasValidToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,27 +142,36 @@ export function useSubscriptionInvoices(
|
|||||||
options: { page?: number; limit?: number } = {}
|
options: { page?: number; limit?: number } = {}
|
||||||
) {
|
) {
|
||||||
const { page = 1, limit = 10 } = options;
|
const { page = 1, limit = 10 } = options;
|
||||||
const { tokens, isAuthenticated } = useAuthStore();
|
const { isAuthenticated, hasValidToken } = useAuthSession();
|
||||||
|
|
||||||
return useQuery<InvoiceList>({
|
return useQuery<InvoiceList>({
|
||||||
queryKey: ["subscription-invoices", subscriptionId, page, limit],
|
queryKey: ["subscription-invoices", subscriptionId, page, limit],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!tokens?.accessToken) {
|
if (!hasValidToken) {
|
||||||
throw new Error("Authentication required");
|
throw new Error("Authentication required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const response = await apiClient.GET("/subscriptions/{id}/invoices", {
|
||||||
page: page.toString(),
|
params: {
|
||||||
limit: limit.toString(),
|
path: { id: subscriptionId },
|
||||||
|
query: { page, limit },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const res = await apiClient.get<InvoiceList>(
|
|
||||||
`/subscriptions/${subscriptionId}/invoices?${params}`
|
return (
|
||||||
);
|
response.data ?? {
|
||||||
return unwrap(res) as InvoiceList;
|
invoices: [],
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
totalPages: 0,
|
||||||
|
totalItems: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
) as InvoiceList;
|
||||||
},
|
},
|
||||||
staleTime: 60 * 1000, // 1 minute
|
staleTime: 60 * 1000, // 1 minute
|
||||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
enabled: isAuthenticated && !!tokens?.accessToken && !!subscriptionId,
|
enabled: isAuthenticated && hasValidToken && !!subscriptionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,8 +183,10 @@ export function useSubscriptionAction() {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ id, action }: { id: number; action: string }) => {
|
mutationFn: async ({ id, action }: { id: number; action: string }) => {
|
||||||
const res = await apiClient.post(`/subscriptions/${id}/actions`, { action });
|
await apiClient.POST("/subscriptions/{id}/actions", {
|
||||||
return unwrap(res);
|
params: { path: { id } },
|
||||||
|
body: { action },
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (_, { id }) => {
|
onSuccess: (_, { id }) => {
|
||||||
// Invalidate relevant queries after successful action
|
// Invalidate relevant queries after successful action
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// Re-export generated client and types
|
|
||||||
export * as ApiTypes from "./__generated__/types";
|
export * as ApiTypes from "./__generated__/types";
|
||||||
export { createClient } from "./runtime/client";
|
export { createClient } from "./runtime/client";
|
||||||
export type { ApiClient } from "./runtime/client";
|
export type {
|
||||||
|
ApiClient,
|
||||||
|
AuthHeaderResolver,
|
||||||
|
CreateClientOptions,
|
||||||
|
} from "./runtime/client";
|
||||||
|
|||||||
@ -1,12 +1,45 @@
|
|||||||
// Defer importing openapi-fetch until codegen deps are installed.
|
import createOpenApiClient from "openapi-fetch";
|
||||||
import createFetchClient from "openapi-fetch";
|
|
||||||
import type { paths } from "../__generated__/types";
|
import type { paths } from "../__generated__/types";
|
||||||
|
|
||||||
export type ApiClient = ReturnType<typeof createFetchClient<paths>>;
|
export type ApiClient = ReturnType<typeof createOpenApiClient<paths>>;
|
||||||
|
|
||||||
export function createClient(baseUrl: string, _getAuthHeader?: () => string | undefined): ApiClient {
|
export type AuthHeaderResolver = () => string | undefined;
|
||||||
// Consumers can pass headers per call using the client's request options.
|
|
||||||
return createFetchClient<paths>({ baseUrl });
|
export interface CreateClientOptions {
|
||||||
|
getAuthHeader?: AuthHeaderResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createClient(
|
||||||
|
baseUrl: string,
|
||||||
|
options: CreateClientOptions = {}
|
||||||
|
): ApiClient {
|
||||||
|
const client = createOpenApiClient<paths>({
|
||||||
|
baseUrl,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof client.use === "function" && options.getAuthHeader) {
|
||||||
|
const resolveAuthHeader = options.getAuthHeader;
|
||||||
|
|
||||||
|
client.use({
|
||||||
|
onRequest({ request }: { request: Request }) {
|
||||||
|
if (!request || typeof request.headers?.has !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.headers.has("Authorization")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerValue = resolveAuthHeader();
|
||||||
|
if (!headerValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
request.headers.set("Authorization", headerValue);
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user