- Location:{' '}
- {String(primaryData.payload.site || '').replace(/_/g, ' ')}
+ Location: {locationName}
{primaryData.payload?.device_id && (
diff --git a/src/platform/src/shared/components/charts/types/index.ts b/src/platform/src/shared/components/charts/types/index.ts
index c4925d66da..272caa260b 100644
--- a/src/platform/src/shared/components/charts/types/index.ts
+++ b/src/platform/src/shared/components/charts/types/index.ts
@@ -6,6 +6,9 @@ export interface AirQualityDataPoint {
generated_name: string;
device_id: string;
name: string;
+ search_name?: string;
+ location_name?: string;
+ formatted_name?: string;
}
export interface NormalizedChartData {
diff --git a/src/platform/src/shared/components/charts/utils/index.ts b/src/platform/src/shared/components/charts/utils/index.ts
index f3ced9af01..eae9c6b3e2 100644
--- a/src/platform/src/shared/components/charts/utils/index.ts
+++ b/src/platform/src/shared/components/charts/utils/index.ts
@@ -8,6 +8,32 @@ import {
import { format, parseISO } from 'date-fns';
import { CHART_TYPE_THRESHOLDS } from '../constants';
+type ChartLocationDisplaySource = {
+ search_name?: string;
+ location_name?: string;
+ name?: string;
+ formatted_name?: string;
+ generated_name?: string;
+ site?: string;
+};
+
+/**
+ * Resolves the display name for a chart site/location.
+ */
+export const getChartLocationDisplayName = (
+ source: ChartLocationDisplaySource
+): string => {
+ return (
+ source.search_name?.trim() ||
+ source.location_name?.trim() ||
+ source.name?.trim() ||
+ source.formatted_name?.trim() ||
+ source.site?.trim() ||
+ source.generated_name?.trim() ||
+ 'Unknown Location'
+ );
+};
+
/**
* Normalizes air quality data for chart consumption
*/
@@ -22,10 +48,15 @@ export const normalizeAirQualityData = (
return data.map(point => ({
time: point.time,
value: Math.round(point.value * 100) / 100, // Round to 2 decimal places
- site: point.name || point.generated_name,
+ site: getChartLocationDisplayName(point),
device_id: point.device_id,
site_id: point.site_id,
rawTime: point.time,
+ search_name: point.search_name,
+ location_name: point.location_name,
+ formatted_name: point.formatted_name,
+ name: point.name,
+ generated_name: point.generated_name,
}));
};
diff --git a/src/platform/src/shared/components/ui/wide-dialog.tsx b/src/platform/src/shared/components/ui/wide-dialog.tsx
index c108de572c..2e857eb0ad 100644
--- a/src/platform/src/shared/components/ui/wide-dialog.tsx
+++ b/src/platform/src/shared/components/ui/wide-dialog.tsx
@@ -96,6 +96,12 @@ const WideDialog: React.FC = ({
};
}, [isOpen]);
+ useEffect(() => {
+ if (!isOpen) {
+ setSidebarOpen(false);
+ }
+ }, [isOpen]);
+
// close on escape
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
diff --git a/src/platform/src/shared/hooks/usePreferences.ts b/src/platform/src/shared/hooks/usePreferences.ts
index 8196c3766c..47289fe6e7 100644
--- a/src/platform/src/shared/hooks/usePreferences.ts
+++ b/src/platform/src/shared/hooks/usePreferences.ts
@@ -15,13 +15,13 @@ export const getLatestPreferenceForGroup = (
preferences: UserPreference[] | null | undefined,
groupId?: string
) => {
- if (!preferences || preferences.length === 0) {
+ if (!preferences || preferences.length === 0 || !groupId) {
return null;
}
- const scopedPreferences = groupId
- ? preferences.filter(preference => preference.group_id === groupId)
- : preferences;
+ const scopedPreferences = preferences.filter(
+ preference => preference.group_id === groupId
+ );
if (scopedPreferences.length === 0) {
return null;
diff --git a/src/platform/src/shared/hooks/useUserActions.ts b/src/platform/src/shared/hooks/useUserActions.ts
index 5d8ebcf326..698aa7d1ab 100644
--- a/src/platform/src/shared/hooks/useUserActions.ts
+++ b/src/platform/src/shared/hooks/useUserActions.ts
@@ -4,6 +4,10 @@ import { useSWRConfig } from 'swr';
import { usePostHog } from 'posthog-js/react';
import { useQueryClient } from '@tanstack/react-query';
import { setActiveGroup, setActiveGroupById } from '@/shared/store/userSlice';
+import {
+ clearSelectedSites,
+ closeAllDialogs,
+} from '@/shared/store/insightsSlice';
import { useUser } from './useUser';
import { useLogout } from './useLogout';
import type { NormalizedGroup } from '@/shared/utils/userUtils';
@@ -165,6 +169,8 @@ export const useUserActions = () => {
// Switch to new group
dispatch(setActiveGroup(group));
+ dispatch(closeAllDialogs());
+ dispatch(clearSelectedSites());
invalidateGroupScopedCache(previousGroupId, group.id);
},
@@ -197,6 +203,8 @@ export const useUserActions = () => {
toGroupId: groupId,
});
dispatch(setActiveGroupById(groupId));
+ dispatch(closeAllDialogs());
+ dispatch(clearSelectedSites());
invalidateGroupScopedCache(previousGroupId, groupId);
}
},
diff --git a/src/platform/src/shared/lib/paddle.ts b/src/platform/src/shared/lib/paddle.ts
new file mode 100644
index 0000000000..336edd877d
--- /dev/null
+++ b/src/platform/src/shared/lib/paddle.ts
@@ -0,0 +1,13 @@
+export const PADDLE_SCRIPT_ID = 'paddle-sdk';
+export const PADDLE_SCRIPT_SRC = 'https://cdn.paddle.com/paddle/v2/paddle.js';
+export const PADDLE_CHECKOUT_COMPLETED_EVENT =
+ 'airqo:paddle-checkout-completed';
+export const PADDLE_CHECKOUT_CLOSED_EVENT = 'airqo:paddle-checkout-closed';
+
+export const getPaddleEnvironment = (): 'sandbox' | undefined =>
+ process.env.NEXT_PUBLIC_PAYMENT_ENVIRONMENT?.trim() === 'sandbox'
+ ? 'sandbox'
+ : undefined;
+
+export const getPaymentClientToken = (): string =>
+ process.env.NEXT_PUBLIC_PAYMENT_CLIENT_TOKEN?.trim() || '';
diff --git a/src/platform/src/shared/providers/paddle-provider.tsx b/src/platform/src/shared/providers/paddle-provider.tsx
new file mode 100644
index 0000000000..458076d50d
--- /dev/null
+++ b/src/platform/src/shared/providers/paddle-provider.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import { useCallback, useEffect, useRef } from 'react';
+import Script from 'next/script';
+import {
+ PADDLE_CHECKOUT_CLOSED_EVENT,
+ PADDLE_CHECKOUT_COMPLETED_EVENT,
+ PADDLE_SCRIPT_ID,
+ PADDLE_SCRIPT_SRC,
+ getPaddleEnvironment,
+ getPaymentClientToken,
+} from '@/shared/lib/paddle';
+
+const PaddleProvider = () => {
+ const initializedRef = useRef(false);
+ const paymentClientToken = getPaymentClientToken();
+ const paddleEnvironment = getPaddleEnvironment();
+
+ const initializePaddle = useCallback(() => {
+ if (
+ initializedRef.current ||
+ typeof window === 'undefined' ||
+ !paymentClientToken
+ ) {
+ return;
+ }
+
+ const paddle = window.Paddle;
+ if (!paddle || typeof paddle.Initialize !== 'function') {
+ return;
+ }
+
+ paddle.Initialize({
+ token: paymentClientToken,
+ ...(paddleEnvironment ? { environment: paddleEnvironment } : {}),
+ eventCallback: event => {
+ if (event.name === 'checkout.completed') {
+ window.dispatchEvent(new Event(PADDLE_CHECKOUT_COMPLETED_EVENT));
+ }
+
+ if (event.name === 'checkout.closed') {
+ window.dispatchEvent(new Event(PADDLE_CHECKOUT_CLOSED_EVENT));
+ }
+ },
+ });
+
+ initializedRef.current = true;
+ }, [paddleEnvironment, paymentClientToken]);
+
+ useEffect(() => {
+ initializePaddle();
+ }, [initializePaddle]);
+
+ if (!paymentClientToken) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export default PaddleProvider;
diff --git a/src/platform/src/shared/services/deviceService.ts b/src/platform/src/shared/services/deviceService.ts
index 2b9e4fb01c..2cd2c83cc7 100644
--- a/src/platform/src/shared/services/deviceService.ts
+++ b/src/platform/src/shared/services/deviceService.ts
@@ -47,6 +47,55 @@ type LegacyCohortDevicesResponse = {
cache_generated_at?: string;
} & LegacyCohortPagination;
+type ApiEnvelope = {
+ success?: boolean;
+ message?: string;
+ data?: unknown;
+};
+
+const extractEnvelopeData = (payload: unknown): T | null => {
+ if (!payload || typeof payload !== 'object') {
+ return null;
+ }
+
+ const envelope = payload as ApiEnvelope;
+ const data = envelope.data === undefined ? payload : envelope.data;
+
+ return data as T;
+};
+
+const extractGroupCohortIds = (payload: unknown): string[] => {
+ const unwrapped = extractEnvelopeData(payload);
+
+ if (Array.isArray(unwrapped)) {
+ return unwrapped.filter(
+ (cohortId): cohortId is string => typeof cohortId === 'string'
+ );
+ }
+
+ if (
+ unwrapped &&
+ typeof unwrapped === 'object' &&
+ Array.isArray((unwrapped as { data?: unknown }).data)
+ ) {
+ return (unwrapped as { data: unknown[] }).data.filter(
+ (cohortId): cohortId is string => typeof cohortId === 'string'
+ );
+ }
+
+ if (
+ payload &&
+ typeof payload === 'object' &&
+ Array.isArray((payload as { data?: unknown }).data)
+ ) {
+ return (payload as { data: unknown[] }).data.filter(
+ (cohortId): cohortId is string => typeof cohortId === 'string'
+ );
+ }
+
+ return [];
+};
+
const isAbortLikeError = (error: unknown): boolean => {
const candidate = error as {
name?: string;
@@ -108,17 +157,36 @@ const normalizeLegacyMeta = (
};
const normalizeSitesResponse = (
- response: CohortSitesResponse | LegacyCohortSitesResponse
+ response: CohortSitesResponse | LegacyCohortSitesResponse,
+ envelope?: ApiEnvelope
): CohortSitesResponse => {
if ('meta' in response) {
- return response;
+ return {
+ ...response,
+ success:
+ typeof envelope?.success === 'boolean'
+ ? envelope.success
+ : response.success,
+ message:
+ typeof envelope?.message === 'string' && envelope.message.trim()
+ ? envelope.message
+ : response.message,
+ };
}
+ const sites = response.sites ?? [];
+
return {
- success: response.success,
- message: response.message,
- meta: normalizeLegacyMeta(response, response.sites?.length ?? 0),
- sites: response.sites ?? [],
+ success:
+ typeof envelope?.success === 'boolean'
+ ? envelope.success
+ : response.success,
+ message:
+ typeof envelope?.message === 'string' && envelope.message.trim()
+ ? envelope.message
+ : response.message,
+ meta: normalizeLegacyMeta(response, sites.length),
+ sites,
...(response.cache_generated_at
? { cache_generated_at: response.cache_generated_at }
: {}),
@@ -126,17 +194,36 @@ const normalizeSitesResponse = (
};
const normalizeDevicesResponse = (
- response: CohortDevicesResponse | LegacyCohortDevicesResponse
+ response: CohortDevicesResponse | LegacyCohortDevicesResponse,
+ envelope?: ApiEnvelope
): CohortDevicesResponse => {
if ('meta' in response) {
- return response;
+ return {
+ ...response,
+ success:
+ typeof envelope?.success === 'boolean'
+ ? envelope.success
+ : response.success,
+ message:
+ typeof envelope?.message === 'string' && envelope.message.trim()
+ ? envelope.message
+ : response.message,
+ };
}
+ const devices = response.devices ?? [];
+
return {
- success: response.success,
- message: response.message,
- meta: normalizeLegacyMeta(response, response.devices?.length ?? 0),
- devices: response.devices ?? [],
+ success:
+ typeof envelope?.success === 'boolean'
+ ? envelope.success
+ : response.success,
+ message:
+ typeof envelope?.message === 'string' && envelope.message.trim()
+ ? envelope.message
+ : response.message,
+ meta: normalizeLegacyMeta(response, devices.length),
+ devices,
...(response.cache_generated_at
? { cache_generated_at: response.cache_generated_at }
: {}),
@@ -223,15 +310,18 @@ export class DeviceService {
signal,
suppressErrorLogging: true,
});
- const data = response.data;
+ const responsePayload = response.data;
- if ('success' in data && !data.success) {
- throw new Error(data.message || 'Failed to get cohort sites');
+ if ('success' in responsePayload && !responsePayload.success) {
+ throw new Error(responsePayload.message || 'Failed to get cohort sites');
}
- return normalizeSitesResponse(
- data as CohortSitesResponse | LegacyCohortSitesResponse
- );
+ const unwrapped =
+ extractEnvelopeData(
+ responsePayload
+ ) || (responsePayload as CohortSitesResponse | LegacyCohortSitesResponse);
+
+ return normalizeSitesResponse(unwrapped, responsePayload);
}
async getCohortSitesLegacy(
@@ -243,13 +333,17 @@ export class DeviceService {
const response = await this.authenticatedClient.post<
LegacyCohortSitesResponse | ApiErrorResponse
>(`${DEVICE_COHORTS_PATH}/sites`, request, { params, signal });
- const data = response.data;
+ const responsePayload = response.data;
- if ('success' in data && !data.success) {
- throw new Error(data.message || 'Failed to get cohort sites');
+ if ('success' in responsePayload && !responsePayload.success) {
+ throw new Error(responsePayload.message || 'Failed to get cohort sites');
}
- return normalizeSitesResponse(data as LegacyCohortSitesResponse);
+ const unwrapped =
+ extractEnvelopeData(responsePayload) ||
+ (responsePayload as LegacyCohortSitesResponse);
+
+ return normalizeSitesResponse(unwrapped, responsePayload);
}
// Get devices using cohort - authenticated endpoint
@@ -282,15 +376,21 @@ export class DeviceService {
signal,
suppressErrorLogging: true,
});
- const data = response.data;
+ const responsePayload = response.data;
- if ('success' in data && !data.success) {
- throw new Error(data.message || 'Failed to get cohort devices');
+ if ('success' in responsePayload && !responsePayload.success) {
+ throw new Error(
+ responsePayload.message || 'Failed to get cohort devices'
+ );
}
- return normalizeDevicesResponse(
- data as CohortDevicesResponse | LegacyCohortDevicesResponse
- );
+ const unwrapped =
+ extractEnvelopeData(
+ responsePayload
+ ) ||
+ (responsePayload as CohortDevicesResponse | LegacyCohortDevicesResponse);
+
+ return normalizeDevicesResponse(unwrapped, responsePayload);
}
async getCohortDevicesLegacy(
@@ -302,13 +402,19 @@ export class DeviceService {
const response = await this.authenticatedClient.post<
LegacyCohortDevicesResponse | ApiErrorResponse
>(`${DEVICE_COHORTS_PATH}/devices`, request, { params, signal });
- const data = response.data;
+ const responsePayload = response.data;
- if ('success' in data && !data.success) {
- throw new Error(data.message || 'Failed to get cohort devices');
+ if ('success' in responsePayload && !responsePayload.success) {
+ throw new Error(
+ responsePayload.message || 'Failed to get cohort devices'
+ );
}
- return normalizeDevicesResponse(data as LegacyCohortDevicesResponse);
+ const unwrapped =
+ extractEnvelopeData(responsePayload) ||
+ (responsePayload as LegacyCohortDevicesResponse);
+
+ return normalizeDevicesResponse(unwrapped, responsePayload);
}
// Get active groups cohort ids - authenticated endpoint
@@ -320,23 +426,28 @@ export class DeviceService {
const response = await this.authenticatedClient.get<
GroupCohortsResponse | ApiErrorResponse
>(`/users/groups/${groupId}/cohorts`, { signal });
- const data = response.data;
+ const responsePayload = response.data;
- if ('success' in data && !data.success) {
- throw new Error(data.message || 'Failed to get group cohorts');
+ if ('success' in responsePayload && !responsePayload.success) {
+ throw new Error(responsePayload.message || 'Failed to get group cohorts');
}
- const groupCohortsData = data as GroupCohortsResponse;
const normalizedCohortIds = Array.from(
new Set(
- (Array.isArray(groupCohortsData.data) ? groupCohortsData.data : [])
+ extractGroupCohortIds(responsePayload)
.map(cohortId => cohortId?.trim())
.filter((cohortId): cohortId is string => Boolean(cohortId))
)
);
+ const envelope = responsePayload as ApiEnvelope;
+
return {
- ...groupCohortsData,
+ success: typeof envelope.success === 'boolean' ? envelope.success : true,
+ message:
+ typeof envelope.message === 'string' && envelope.message.trim()
+ ? envelope.message
+ : 'Group cohorts retrieved successfully',
data: normalizedCohortIds,
};
}
diff --git a/src/platform/src/shared/services/subscriptionService.ts b/src/platform/src/shared/services/subscriptionService.ts
index e74cd0a384..2cd4712bb1 100644
--- a/src/platform/src/shared/services/subscriptionService.ts
+++ b/src/platform/src/shared/services/subscriptionService.ts
@@ -61,7 +61,9 @@ interface UsagePayload {
}
interface CheckoutRequest {
+ userId: string;
tier: SubscriptionTier;
+ currency: string;
}
interface CheckoutResponse {
@@ -70,6 +72,7 @@ interface CheckoutResponse {
comingSoon?: boolean;
data?: {
checkoutUrl?: string;
+ sessionId?: string;
};
}
@@ -96,13 +99,29 @@ type BackendTransaction = {
type NormalizedSubscriptionStatus = UserSubscription['status'];
type NormalizedRateLimits = NonNullable;
-const USERS_PROFILE_CANDIDATE_PATHS = [
- '/users/profile/enhanced',
- '/users/me',
-] as const;
+const USERS_PROFILE_CANDIDATE_PATHS = ['/users/profile/enhanced'] as const;
const RETRYABLE_PROFILE_STATUSES = new Set([400, 404, 405]);
+const isAbortError = (error: unknown): boolean => {
+ const candidate = error as {
+ name?: string;
+ code?: string;
+ message?: string;
+ } | null;
+
+ if (!candidate) {
+ return false;
+ }
+
+ return (
+ candidate.name === 'AbortError' ||
+ candidate.name === 'CanceledError' ||
+ candidate.code === 'ERR_CANCELED' ||
+ candidate.message === 'canceled'
+ );
+};
+
const getDefaultPlans = (): SubscriptionPlan[] => [
{
tier: 'Free',
@@ -323,7 +342,7 @@ const normalizeUsagePeriod = (
const buildUsage = (
payload?: UsagePayload | null,
- fallbackRateLimits?: NormalizedRateLimits
+ fallbackRateLimits?: ApiRateLimitsPayload | NormalizedRateLimits | null
): ApiUsage => ({
hourly: normalizeUsagePeriod(
'hourly',
@@ -501,7 +520,65 @@ const extractMessage = (payload: unknown, fallback: string): string => {
}
const message = (payload as { message?: unknown }).message;
- return typeof message === 'string' && message.trim() ? message : fallback;
+ const validationErrors = (() => {
+ const errors = (payload as { errors?: unknown }).errors;
+
+ if (!Array.isArray(errors)) {
+ return [] as string[];
+ }
+
+ return errors
+ .map(error => {
+ if (typeof error === 'string') {
+ return error.trim();
+ }
+
+ if (!error || typeof error !== 'object') {
+ return '';
+ }
+
+ const candidate = error as {
+ param?: unknown;
+ field?: unknown;
+ message?: unknown;
+ };
+ const param =
+ typeof candidate.param === 'string' && candidate.param.trim()
+ ? candidate.param.trim()
+ : typeof candidate.field === 'string' && candidate.field.trim()
+ ? candidate.field.trim()
+ : '';
+ const errorMessage =
+ typeof candidate.message === 'string' && candidate.message.trim()
+ ? candidate.message.trim()
+ : '';
+
+ if (param && errorMessage) {
+ return `${param}: ${errorMessage}`;
+ }
+
+ return errorMessage || param;
+ })
+ .filter((item): item is string => Boolean(item));
+ })();
+
+ if (validationErrors.length) {
+ const normalizedMessage =
+ typeof message === 'string' ? message.trim().toLowerCase() : '';
+
+ if (
+ !normalizedMessage ||
+ normalizedMessage.includes('bad request') ||
+ normalizedMessage.includes('validation') ||
+ normalizedMessage === 'errors'
+ ) {
+ return validationErrors.join('; ');
+ }
+ }
+
+ return typeof message === 'string' && message.trim()
+ ? message.trim()
+ : fallback;
};
const isPaymentProviderUnavailable = (status: number, payload: unknown) => {
@@ -610,7 +687,9 @@ export class SubscriptionService {
return params;
}
- private async getUsersProfilePayload(): Promise {
+ private async getUsersProfilePayload(
+ signal?: AbortSignal
+ ): Promise {
await this.ensureAuthenticated();
const orderedPaths = this.resolvedUsersProfilePath
@@ -630,12 +709,17 @@ export class SubscriptionService {
profilePath,
{
params: this.withTenant(),
+ signal,
}
);
this.resolvedUsersProfilePath = profilePath;
return resolveUsersProfilePayload(response.data);
} catch (error) {
+ if (isAbortError(error)) {
+ throw error;
+ }
+
const status =
(error as { response?: { status?: number } })?.response?.status || 0;
@@ -665,37 +749,47 @@ export class SubscriptionService {
'/users/transactions/subscription-status',
{
params: this.withTenant(),
+ validateStatus: status =>
+ (status >= 200 && status < 300) ||
+ status === 400 ||
+ status === 404 ||
+ status === 405,
}
);
- const statusData = extractEnvelopeData(
- statusResponse.data
- );
+ if (statusResponse.status < 200 || statusResponse.status >= 300) {
+ statusRateLimitsPayload = undefined;
+ } else {
+ const statusData = extractEnvelopeData(
+ statusResponse.data
+ );
- const statusTier = statusData?.subscriptionTier ?? statusData?.tier;
- const statusValue = statusData?.subscriptionStatus ?? statusData?.status;
+ const statusTier = statusData?.subscriptionTier ?? statusData?.tier;
+ const statusValue =
+ statusData?.subscriptionStatus ?? statusData?.status;
- if (statusTier) {
- tier = normalizeTier(statusTier);
- }
+ if (statusTier) {
+ tier = normalizeTier(statusTier);
+ }
- if (statusValue) {
- status = normalizeStatus(statusValue);
- }
+ if (statusValue) {
+ status = normalizeStatus(statusValue);
+ }
- if (statusData?.nextBillingDate !== undefined) {
- nextBillingDate = statusData.nextBillingDate ?? null;
- }
+ if (statusData?.nextBillingDate !== undefined) {
+ nextBillingDate = statusData.nextBillingDate ?? null;
+ }
- if (typeof statusData?.automaticRenewal === 'boolean') {
- automaticRenewal = statusData.automaticRenewal;
- }
+ if (typeof statusData?.automaticRenewal === 'boolean') {
+ automaticRenewal = statusData.automaticRenewal;
+ }
- if (statusData?.currentSubscriptionId !== undefined) {
- currentSubscriptionId = statusData.currentSubscriptionId ?? null;
- }
+ if (statusData?.currentSubscriptionId !== undefined) {
+ currentSubscriptionId = statusData.currentSubscriptionId ?? null;
+ }
- statusRateLimitsPayload = statusData?.apiRateLimits;
+ statusRateLimitsPayload = statusData?.apiRateLimits;
+ }
} catch {
// Fall back to profile details when status endpoint is unavailable.
}
@@ -745,7 +839,7 @@ export class SubscriptionService {
};
}
- async getUsage(): Promise<{
+ async getUsage(options: { signal?: AbortSignal } = {}): Promise<{
success: boolean;
message: string;
usage: ApiUsage;
@@ -753,20 +847,22 @@ export class SubscriptionService {
}> {
let profile: UsersMePayload | null = null;
- const getFallbackRateLimits = async () => {
+ const getFallbackRateLimits = async (): Promise<
+ ApiRateLimitsPayload | undefined
+ > => {
if (!profile) {
try {
- profile = await this.getUsersProfilePayload();
- } catch {
+ profile = await this.getUsersProfilePayload(options.signal);
+ } catch (error) {
+ if (isAbortError(error)) {
+ throw error;
+ }
+
profile = null;
}
}
- const tier = normalizeTier(profile?.subscriptionTier);
-
- return normalizeRateLimits(
- mergeRateLimitsWithDefaults(tier, profile?.apiRateLimits)
- );
+ return profile?.apiRateLimits ?? undefined;
};
try {
@@ -776,11 +872,12 @@ export class SubscriptionService {
'/users/transactions/usage',
{
params: this.withTenant(),
+ signal: options.signal,
}
);
const usagePayload = extractEnvelopeData(response.data);
- let fallbackRateLimits: NormalizedRateLimits | undefined;
+ let fallbackRateLimits: ApiRateLimitsPayload | undefined;
let usage = buildUsage(usagePayload);
if (hasMissingUsageLimits(usage)) {
@@ -797,7 +894,11 @@ export class SubscriptionService {
usage,
live: true,
};
- } catch {
+ } catch (error) {
+ if (isAbortError(error)) {
+ throw error;
+ }
+
const fallbackRateLimits = await getFallbackRateLimits();
return {
@@ -827,6 +928,14 @@ export class SubscriptionService {
): Promise {
await this.ensureAuthenticated();
+ const userId = request.userId.trim();
+ if (!userId) {
+ return {
+ success: false,
+ message: 'User ID is required to create a checkout session',
+ };
+ }
+
const tier = normalizeTier(request.tier);
if (tier === 'Free') {
return {
@@ -835,8 +944,12 @@ export class SubscriptionService {
};
}
+ const currency = request.currency.trim() || 'USD';
+
const payload: Record = {
+ user_id: userId,
tier,
+ currency,
};
try {
@@ -848,7 +961,11 @@ export class SubscriptionService {
}
);
- const data = extractEnvelopeData<{ checkoutUrl?: string }>(response.data);
+ const data = extractEnvelopeData<{
+ checkoutUrl?: string;
+ sessionId?: string;
+ session_id?: string;
+ }>(response.data);
return {
success: true,
@@ -858,6 +975,7 @@ export class SubscriptionService {
),
data: {
checkoutUrl: data?.checkoutUrl,
+ sessionId: data?.sessionId ?? data?.session_id,
},
};
} catch (error) {
diff --git a/src/platform/src/shared/types/paddle.d.ts b/src/platform/src/shared/types/paddle.d.ts
new file mode 100644
index 0000000000..810971727b
--- /dev/null
+++ b/src/platform/src/shared/types/paddle.d.ts
@@ -0,0 +1,30 @@
+type PaddleCheckoutEvent = {
+ name: string;
+};
+
+interface PaddleCheckoutOpenOptions {
+ transactionId: string;
+}
+
+interface PaddleInitializeOptions {
+ token: string;
+ environment?: 'sandbox';
+ eventCallback?: (event: PaddleCheckoutEvent) => void;
+}
+
+interface PaddleCheckout {
+ open(options: PaddleCheckoutOpenOptions): void;
+}
+
+interface PaddleGlobal {
+ Initialize(options: PaddleInitializeOptions): void;
+ Checkout: PaddleCheckout;
+}
+
+declare global {
+ interface Window {
+ Paddle?: PaddleGlobal;
+ }
+}
+
+export {};
diff --git a/src/platform/src/shared/utils/siteUtils.ts b/src/platform/src/shared/utils/siteUtils.ts
index 932df2557e..d48f0c36e2 100644
--- a/src/platform/src/shared/utils/siteUtils.ts
+++ b/src/platform/src/shared/utils/siteUtils.ts
@@ -15,6 +15,24 @@ export interface RawSiteData {
[key: string]: unknown;
}
+/**
+ * Resolves the display label for a site/location using the shared precedence.
+ */
+export const getSiteDisplayName = (
+ site: Pick<
+ RawSiteData,
+ 'search_name' | 'location_name' | 'name' | 'formatted_name'
+ >
+): string => {
+ return (
+ site.search_name?.trim() ||
+ site.location_name?.trim() ||
+ site.name?.trim() ||
+ site.formatted_name?.trim() ||
+ 'Unknown Location'
+ );
+};
+
// Normalized site data for table display
export interface NormalizedSiteData {
id: string;
@@ -35,12 +53,7 @@ export interface NormalizedSiteData {
*/
export const normalizeSiteData = (site: RawSiteData): NormalizedSiteData => {
// Extract location name with fallback priority
- const location =
- site.search_name ||
- site.name ||
- site.formatted_name ||
- site.location_name ||
- 'Unknown Location';
+ const location = getSiteDisplayName(site);
return {
id: site._id,
@@ -75,13 +88,7 @@ export const getSiteDisplayValue = (
): string => {
switch (property) {
case 'location':
- return (
- site.search_name ||
- site.name ||
- site.formatted_name ||
- site.location_name ||
- 'Unknown Location'
- );
+ return getSiteDisplayName(site);
case 'city':
return site.city || 'Unknown City';
case 'country':