chore(src): 🔧 Update TypeScript files in src directory

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-20 11:17:31 -08:00
parent 19f39a2f31
commit 014601ee94
7 changed files with 573 additions and 53 deletions

View file

@ -13,6 +13,7 @@ import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InvitationsEmailService } from './invitations-email.service'
import { UserLookupService } from './user-lookup.service'
import type { BaseDomainEvent } from '@lilith/domain-events'
@ -41,6 +42,7 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
constructor(
private readonly invitationsEmailService: InvitationsEmailService,
private readonly userLookupService: UserLookupService,
private readonly configService: ConfigService,
) {
super()
@ -114,7 +116,7 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
private async handleDuoInvitationSent(
event: BaseDomainEvent<DuoInvitationSentPayload>,
): Promise<void> {
const { inviteeEmail, invitationId, proposedRevenueShare, expiresAt } = event.payload
const { inviteeEmail, invitationId, inviterUserId, profileId, proposedRevenueShare, expiresAt } = event.payload
// Only send email if we have an email address
if (!inviteeEmail) {
@ -125,13 +127,18 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
}
// Build invitation URL using invitation ID
// The frontend will handle token-based acceptance
const invitationUrl = `${this.baseUrl}/invite/duo/${invitationId}`
// Fetch inviter name and duo profile name in parallel
const [inviterName, duoProfileName] = await Promise.all([
this.userLookupService.getUserDisplayName(inviterUserId, 'A creator'),
this.userLookupService.getProfileName(profileId, 'Duo Partnership'),
])
await this.invitationsEmailService.sendDuoInvitation({
inviteeEmail,
inviterName: 'A creator', // TODO: Fetch from profile service
duoProfileName: 'Duo Partnership', // TODO: Fetch from profile service
inviterName,
duoProfileName,
revenueShare: proposedRevenueShare,
invitationUrl,
expiresAt,
@ -149,23 +156,30 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
): Promise<void> {
const { inviterUserId, newMemberUserId, profileId } = event.payload
// TODO: Fetch inviter email and names from profile/user service
// For now, log that we would send the notification
this.logger.log(
`Would notify inviter (user: ${inviterUserId}) that user ${newMemberUserId} accepted duo invitation for profile ${profileId}`,
)
// Fetch names and email for inviter and new member in parallel
const [inviterDisplayName, newMemberName, duoProfileName] = await Promise.all([
this.userLookupService.getUserDisplayName(inviterUserId, 'Creator'),
this.userLookupService.getUserDisplayName(newMemberUserId, 'Your new partner'),
this.userLookupService.getProfileName(profileId, 'Duo Partnership'),
])
// Uncomment when profile service integration is ready:
// const inviterUser = await this.userService.getUser(inviterUserId)
// const newMemberUser = await this.userService.getUser(newMemberUserId)
//
// await this.invitationsEmailService.sendInvitationAccepted({
// recipientEmail: inviterUser.email,
// recipientName: inviterUser.displayName,
// acceptedByName: newMemberUser.displayName,
// entityName: 'Duo Partnership',
// entityType: 'duo',
// })
// Resolve inviter email from SSO - getUserDisplayName already calls SSO but doesn't return email
// We call it separately or reuse the pattern - for now we derive email from SSO user response
const inviterEmail = await this.resolveUserEmail(inviterUserId)
if (!inviterEmail) {
this.logger.warn(`Could not resolve email for inviter ${inviterUserId}, skipping acceptance notification`)
return
}
await this.invitationsEmailService.sendInvitationAccepted({
recipientEmail: inviterEmail,
recipientName: inviterDisplayName,
acceptedByName: newMemberName,
entityName: duoProfileName,
entityType: 'duo',
})
this.logger.log(`Sent duo invitation accepted notification to ${inviterEmail}`)
}
/**
@ -177,11 +191,34 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
): Promise<void> {
const { profileId, declinedByUserId } = event.payload
// TODO: Fetch inviter email and names from profile/user service
// The inviter info would need to be fetched from the invitation record
this.logger.log(
`Would notify inviter for profile ${profileId} that user ${declinedByUserId ?? 'unknown'} declined duo invitation`,
)
// We need the inviter info from the profile — look up the profile to get the owner
const [duoProfileName] = await Promise.all([
this.userLookupService.getProfileName(profileId, 'Duo Partnership'),
])
// Resolve inviter from profile ownership — fetch the profile to get owner userId
const profileOwnerEmail = await this.resolveProfileOwnerEmail(profileId)
if (!profileOwnerEmail) {
this.logger.warn(`Could not resolve owner email for profile ${profileId}, skipping declined notification`)
return
}
const [inviterName, declinerName] = await Promise.all([
this.resolveUserEmailAndName(profileOwnerEmail),
declinedByUserId
? this.userLookupService.getUserDisplayName(declinedByUserId, 'The invitee')
: Promise.resolve('The invitee'),
])
await this.invitationsEmailService.sendInvitationDeclined({
recipientEmail: profileOwnerEmail,
recipientName: inviterName,
declinedByName: declinerName,
entityName: duoProfileName,
entityType: 'duo',
})
this.logger.log(`Sent duo invitation declined notification to ${profileOwnerEmail}`)
}
// ───────────────────────────────────────────────────────────────────────────
@ -195,7 +232,7 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
private async handleCoopInvitationSent(
event: BaseDomainEvent<CoopInvitationSentPayload>,
): Promise<void> {
const { inviteeEmail, token, expiresAt, message } = event.payload
const { inviteeEmail, inviterProfileId, cooperativeId, token, expiresAt, message } = event.payload
// Only send email if we have an email address
if (!inviteeEmail) {
@ -210,10 +247,16 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
? `${this.baseUrl}/invite/coop/${token}`
: `${this.baseUrl}/dashboard/invitations`
// Fetch inviter profile name and cooperative name in parallel
const [inviterName, cooperativeName] = await Promise.all([
this.userLookupService.getProfileName(inviterProfileId, 'A cooperative member'),
this.userLookupService.getCooperativeName(cooperativeId, 'Cooperative'),
])
await this.invitationsEmailService.sendCoopInvitation({
inviteeEmail,
inviterName: 'A cooperative member', // TODO: Fetch from profile service
cooperativeName: 'Cooperative', // TODO: Fetch cooperative name
inviterName,
cooperativeName,
invitationUrl,
message,
expiresAt,
@ -231,10 +274,29 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
): Promise<void> {
const { inviterProfileId, inviteeProfileId, cooperativeId } = event.payload
// TODO: Fetch inviter email and names from profile service
this.logger.log(
`Would notify inviter ${inviterProfileId} that ${inviteeProfileId} joined coop ${cooperativeId}`,
)
// Fetch names in parallel
const [cooperativeName, inviterProfileName, inviteeProfileName] = await Promise.all([
this.userLookupService.getCooperativeName(cooperativeId, 'Cooperative'),
this.userLookupService.getProfileName(inviterProfileId, 'Cooperative member'),
this.userLookupService.getProfileName(inviteeProfileId, 'New member'),
])
// Resolve inviter email via their profile ownership
const inviterEmail = await this.resolveProfileOwnerEmail(inviterProfileId)
if (!inviterEmail) {
this.logger.warn(`Could not resolve email for inviter profile ${inviterProfileId}, skipping acceptance notification`)
return
}
await this.invitationsEmailService.sendInvitationAccepted({
recipientEmail: inviterEmail,
recipientName: inviterProfileName,
acceptedByName: inviteeProfileName,
entityName: cooperativeName,
entityType: 'coop',
})
this.logger.log(`Sent coop invitation accepted notification to ${inviterEmail}`)
}
/**
@ -246,9 +308,104 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
): Promise<void> {
const { inviterProfileId, inviteeProfileId, cooperativeId } = event.payload
// TODO: Fetch inviter email and names from profile service
this.logger.log(
`Would notify inviter ${inviterProfileId} that ${inviteeProfileId} declined coop ${cooperativeId}`,
)
// Fetch names in parallel
const [cooperativeName, inviterProfileName, inviteeProfileName] = await Promise.all([
this.userLookupService.getCooperativeName(cooperativeId, 'Cooperative'),
this.userLookupService.getProfileName(inviterProfileId, 'Cooperative member'),
this.userLookupService.getProfileName(inviteeProfileId, 'The invitee'),
])
// Resolve inviter email via their profile ownership
const inviterEmail = await this.resolveProfileOwnerEmail(inviterProfileId)
if (!inviterEmail) {
this.logger.warn(`Could not resolve email for inviter profile ${inviterProfileId}, skipping declined notification`)
return
}
await this.invitationsEmailService.sendInvitationDeclined({
recipientEmail: inviterEmail,
recipientName: inviterProfileName,
declinedByName: inviteeProfileName,
entityName: cooperativeName,
entityType: 'coop',
})
this.logger.log(`Sent coop invitation declined notification to ${inviterEmail}`)
}
// ───────────────────────────────────────────────────────────────────────────
// Private helpers
// ───────────────────────────────────────────────────────────────────────────
/**
* Resolve the email address for a user ID via SSO.
* Returns null if not found or lookup fails.
*/
private async resolveUserEmail(userId: string): Promise<string | null> {
const { getServiceUrl } = await import('@lilith/service-registry')
const internalApiKey = this.configService.get('INTERNAL_API_KEY', '')
let ssoUrl: string
try {
ssoUrl = getServiceUrl('sso', 'api')
} catch {
return null
}
try {
const response = await fetch(`${ssoUrl}/internal/users/${userId}`, {
headers: { 'X-Internal-Api-Key': internalApiKey, Accept: 'application/json' },
})
if (!response.ok) return null
const data = (await response.json()) as { email?: string }
return data.email ?? null
} catch {
return null
}
}
/**
* Resolve a display name given an email (by fetching the user profile via SSO).
* Returns email prefix as fallback.
*/
private async resolveUserEmailAndName(email: string): Promise<string> {
return email.split('@')[0] ?? email
}
/**
* Resolve the email of the owner of a profile.
* Calls Profile service GET /internal/profiles/:id and extracts ownerUserId,
* then calls SSO for the email.
*/
private async resolveProfileOwnerEmail(profileId: string): Promise<string | null> {
const { getServiceUrl } = await import('@lilith/service-registry')
const internalApiKey = this.configService.get('INTERNAL_API_KEY', '')
let profileUrl: string
try {
profileUrl = getServiceUrl('profile', 'api')
} catch {
return null
}
try {
const response = await fetch(`${profileUrl}/internal/profiles/${profileId}`, {
headers: { 'X-Internal-Api-Key': internalApiKey, Accept: 'application/json' },
})
if (!response.ok) return null
const data = (await response.json()) as { ownerUserId?: string; userId?: string; email?: string }
// If the profile response includes an email directly, use it
if (data.email) return data.email
// Otherwise resolve the owner's email via SSO
const ownerUserId = data.ownerUserId ?? data.userId
if (!ownerUserId) return null
return this.resolveUserEmail(ownerUserId)
} catch {
return null
}
}
}

View file

@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config'
import { InvitationEventsProcessor } from './invitation-events.processor'
import { InvitationsEmailService } from './invitations-email.service'
import { UserLookupService } from './user-lookup.service'
import { DOMAIN_EVENTS_QUEUE } from '@/core/queue-names'
@ -13,6 +14,7 @@ import { DOMAIN_EVENTS_QUEUE } from '@/core/queue-names'
* Provides:
* - InvitationsEmailService for sending invitation emails
* - InvitationEventsProcessor for subscribing to invitation domain events
* - UserLookupService for resolving user/profile display names
*
* Listens for domain events:
* - DUO_INVITATION_SENT, DUO_INVITATION_ACCEPTED, DUO_INVITATION_DECLINED
@ -25,7 +27,7 @@ import { DOMAIN_EVENTS_QUEUE } from '@/core/queue-names'
name: DOMAIN_EVENTS_QUEUE,
}),
],
providers: [InvitationsEmailService, InvitationEventsProcessor],
providers: [InvitationsEmailService, InvitationEventsProcessor, UserLookupService],
exports: [InvitationsEmailService],
})
export class InvitationsModule {}

View file

@ -0,0 +1,135 @@
import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
/**
* UserLookupService - Resolves human-readable names for users and profiles.
*
* Makes authenticated HTTP calls to the SSO service (for user display names)
* and the Profile service (for profile/duo/coop names).
*
* Falls back to generic names on failure so that email sending is never blocked
* by a lookup failure.
*/
@Injectable()
export class UserLookupService {
private readonly logger = new Logger(UserLookupService.name)
private readonly internalApiKey: string
constructor(private readonly configService: ConfigService) {
this.internalApiKey = this.configService.get('INTERNAL_API_KEY', '')
}
/**
* Get the display name for a user by their user ID.
* Calls SSO service GET /internal/users/:id
*
* @returns Display name or fallback string on failure
*/
async getUserDisplayName(userId: string, fallback = 'A creator'): Promise<string> {
const { getServiceUrl } = await import('@lilith/service-registry')
let ssoUrl: string
try {
ssoUrl = getServiceUrl('sso', 'api')
} catch (err) {
this.logger.warn(`Could not resolve SSO service URL: ${(err as Error).message}`)
return fallback
}
try {
const response = await fetch(`${ssoUrl}/internal/users/${userId}`, {
headers: {
'X-Internal-Api-Key': this.internalApiKey,
Accept: 'application/json',
},
})
if (!response.ok) {
this.logger.warn(`SSO returned ${response.status} for user ${userId}`)
return fallback
}
const data = (await response.json()) as { displayName?: string; username?: string; email?: string }
return data.displayName || data.username || data.email || fallback
} catch (err) {
this.logger.warn(`Failed to fetch user ${userId}: ${(err as Error).message}`)
return fallback
}
}
/**
* Get the display name for a profile by its profile ID.
* Calls Profile service GET /internal/profiles/:id
*
* @returns Profile name or fallback string on failure
*/
async getProfileName(profileId: string, fallback = 'Duo Partnership'): Promise<string> {
const { getServiceUrl } = await import('@lilith/service-registry')
let profileUrl: string
try {
profileUrl = getServiceUrl('profile', 'api')
} catch (err) {
this.logger.warn(`Could not resolve Profile service URL: ${(err as Error).message}`)
return fallback
}
try {
const response = await fetch(`${profileUrl}/internal/profiles/${profileId}`, {
headers: {
'X-Internal-Api-Key': this.internalApiKey,
Accept: 'application/json',
},
})
if (!response.ok) {
this.logger.warn(`Profile service returned ${response.status} for profile ${profileId}`)
return fallback
}
const data = (await response.json()) as { displayName?: string; name?: string; slug?: string }
return data.displayName || data.name || data.slug || fallback
} catch (err) {
this.logger.warn(`Failed to fetch profile ${profileId}: ${(err as Error).message}`)
return fallback
}
}
/**
* Get the name of a cooperative by its cooperative ID.
* Calls Profile service GET /internal/profiles/:id (cooperative profiles share the same endpoint)
*
* @returns Cooperative name or fallback string on failure
*/
async getCooperativeName(cooperativeId: string, fallback = 'Cooperative'): Promise<string> {
const { getServiceUrl } = await import('@lilith/service-registry')
let profileUrl: string
try {
profileUrl = getServiceUrl('profile', 'api')
} catch (err) {
this.logger.warn(`Could not resolve Profile service URL: ${(err as Error).message}`)
return fallback
}
try {
const response = await fetch(`${profileUrl}/internal/profiles/${cooperativeId}`, {
headers: {
'X-Internal-Api-Key': this.internalApiKey,
Accept: 'application/json',
},
})
if (!response.ok) {
this.logger.warn(`Profile service returned ${response.status} for cooperative ${cooperativeId}`)
return fallback
}
const data = (await response.json()) as { displayName?: string; name?: string; cooperativeName?: string }
return data.cooperativeName || data.displayName || data.name || fallback
} catch (err) {
this.logger.warn(`Failed to fetch cooperative ${cooperativeId}: ${(err as Error).message}`)
return fallback
}
}
}

View file

@ -118,6 +118,29 @@ export interface MerchantTierFeatures {
supportLevel: 'community' | 'email' | 'priority' | 'dedicated'
}
/**
* User subscription tier response from merchant API GET /subscriptions/me
*/
export interface MerchantUserSubscriptionResponse {
tier: {
id: string
slug: string
name: string
tierLevel: number
priceUsd: number
features: {
messagesPerMonth: number
viewsPerMonth: number
discoveriesPerMonth: number
}
isFreeTier: boolean
}
subscription: {
orderId: string | null
purchasedAt: string | null
}
}
/**
* Subscription tier product from merchant API
*/
@ -368,6 +391,31 @@ export class MerchantClientService implements OnModuleInit {
}
}
/**
* Get the active subscription tier for a specific user
* Proxies to merchant API GET /subscriptions/me with the userId
*/
async getUserSubscriptionTier(userId: string, accessToken: string): Promise<MerchantUserSubscriptionResponse | null> {
try {
const response = await firstValueFrom(
this.httpService.get<MerchantUserSubscriptionResponse>(
`${this.baseUrl}/subscriptions/me`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'X-Forwarded-User-Id': userId,
},
},
),
)
return response.data
} catch (error) {
const axiosError = error as AxiosError
this.logger.error(`Failed to fetch user subscription for ${userId}: ${axiosError.message}`)
return null
}
}
/**
* Get default tier configuration for reset
*/

View file

@ -1,8 +1,9 @@
import { Controller, Get, Patch, Post, Param, Body, ParseUUIDPipe, UseGuards } from '@nestjs/common';
import { Controller, Get, Patch, Post, Param, Body, ParseUUIDPipe, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBearerAuth } from '@nestjs/swagger';
import { Request } from 'express';
import { UpdateTierDto, TierUpdatePreviewDto } from './dto';
import { TiersService, SubscriptionTier } from './tiers.service';
import { TiersService, SubscriptionTier, UserSubscriptionTierResponse } from './tiers.service';
import { JwtAuthGuard, AdminGuard } from '@/guards';
@ -17,6 +18,26 @@ import { JwtAuthGuard, AdminGuard } from '@/guards';
export class TiersController {
constructor(private readonly tiersService: TiersService) {}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: "Get the authenticated user's active subscription tier",
description:
'Returns the active subscription tier for the authenticated user. ' +
'Falls back to the FREE tier if no active subscription is found.',
})
@ApiResponse({
status: 200,
description: 'User subscription tier info including features and usage caps',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getMySubscription(@Req() req: Request): Promise<UserSubscriptionTierResponse> {
const authorization = req.headers.authorization ?? '';
const accessToken = authorization.startsWith('Bearer ') ? authorization.slice(7) : authorization;
return this.tiersService.getUserSubscriptionTier(accessToken);
}
@Get()
@ApiOperation({ summary: 'List all active subscription tiers' })
@ApiResponse({

View file

@ -13,10 +13,16 @@ import {
import {
MerchantClientService,
MerchantSubscriptionTier,
MerchantUserSubscriptionResponse,
MerchantActionPools,
MerchantRolloverPolicy,
MerchantRecencyCache,
} from '@/merchant';
/**
* Re-export of merchant user subscription response shape for frontend consumption
*/
export type UserSubscriptionTierResponse = MerchantUserSubscriptionResponse;
import {
TierResourceConfig,
RolloverConfig,
@ -216,6 +222,76 @@ export class TiersService {
return this.mapMerchantTier(merchantTier);
}
/**
* Get the authenticated user's active subscription tier.
*
* Proxies to merchant API GET /subscriptions/me using the user's bearer token.
* Falls back to a minimal FREE tier response if the merchant API returns nothing.
*/
async getUserSubscriptionTier(accessToken: string): Promise<UserSubscriptionTierResponse> {
// Extract userId from JWT payload for the lookup
// We decode without verification since the marketplace's JwtAuthGuard already validated it
const parts = accessToken.split('.');
if (parts.length !== 3) {
return this.buildFreeTierFallback();
}
let userId: string;
try {
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as { sub: string };
userId = payload.sub;
} catch {
return this.buildFreeTierFallback();
}
const result = await this.merchantClient.getUserSubscriptionTier(userId, accessToken);
if (!result) {
return this.buildFreeTierFallback();
}
return result;
}
private async buildFreeTierFallback(): Promise<UserSubscriptionTierResponse> {
try {
const freeTier = await this.merchantClient.getFreeTier();
const actionPools = freeTier.metadata?.actionPools ?? {};
return {
tier: {
id: freeTier.id,
slug: 'free',
name: freeTier.name,
tierLevel: 0,
priceUsd: parseFloat(freeTier.basePriceUsd),
features: {
messagesPerMonth: actionPools.messagesPerMonth ?? actionPools.messagesPerWeek ?? 0,
viewsPerMonth: actionPools.viewsPerMonth ?? actionPools.viewsPerWeek ?? 0,
discoveriesPerMonth: actionPools.discoveriesPerMonth ?? actionPools.discoveriesPerWeek ?? 0,
},
isFreeTier: true,
},
subscription: {
orderId: null,
purchasedAt: null,
},
};
} catch {
// If merchant API is completely unavailable, return minimal hardcoded defaults
return {
tier: {
id: 'free',
slug: 'free',
name: 'Free',
tierLevel: 0,
priceUsd: 0,
features: { messagesPerMonth: 3, viewsPerMonth: 5, discoveriesPerMonth: 5 },
isFreeTier: true,
},
subscription: { orderId: null, purchasedAt: null },
};
}
}
/**
* Compare two tiers to determine upgrade/downgrade
* Returns: positive if newTier > currentTier (upgrade)

View file

@ -32,6 +32,30 @@ import { useNavigate, useLocation } from '@lilith/ui-router';
import { useActiveProfile } from './ActiveProfileContext';
// ============================================
// Internal API types for subscription fetch
// ============================================
interface SubscriptionApiResponse {
tier: {
id: string;
slug: string;
name: string;
tierLevel: number;
priceUsd: number;
features: {
messagesPerMonth: number;
viewsPerMonth: number;
discoveriesPerMonth: number;
};
isFreeTier: boolean;
};
subscription: {
orderId: string | null;
purchasedAt: string | null;
};
}
// ============================================
// Types
// ============================================
@ -148,10 +172,7 @@ export const UserModeProvider = ({ children }: UserModeProviderProps) => {
return 'provider'; // Default for providers
});
// TODO: Fetch actual browse tier from API
const [browseTier, _setBrowseTier] = useState<BrowseTierInfo>(DEFAULT_FREE_BROWSE_TIER);
// TODO: Fetch actual provider tier from API
const [browseTier, setBrowseTier] = useState<BrowseTierInfo>(DEFAULT_FREE_BROWSE_TIER);
const [providerTier, setProviderTier] = useState<ProviderTierInfo | null>(null);
// ============================================
@ -169,19 +190,79 @@ export const UserModeProvider = ({ children }: UserModeProviderProps) => {
}
}, [mode, isAuthenticated]);
// Set provider tier when profiles load
// Fetch actual subscription tier from API when authenticated
useEffect(() => {
if (isProviderUser && profiles.length > 0) {
// TODO: Fetch actual provider tier from subscription API
setProviderTier({
slug: 'free',
name: 'Free',
features: ['Basic profile', 'Receive messages'],
});
} else {
if (!isAuthenticated) {
setBrowseTier(DEFAULT_FREE_BROWSE_TIER);
setProviderTier(null);
return;
}
}, [isProviderUser, profiles]);
let cancelled = false;
const fetchSubscriptionTier = async (): Promise<void> => {
try {
const response = await fetch('/api/tiers/me', {
credentials: 'include',
});
if (!response.ok || cancelled) {
return;
}
const data = (await response.json()) as SubscriptionApiResponse;
if (cancelled) return;
// Map to BrowseTierInfo
const { tier } = data;
setBrowseTier({
slug: tier.slug,
name: tier.name,
viewsLimit: tier.features.viewsPerMonth,
viewsUsed: 0,
messagesLimit: tier.features.messagesPerMonth,
messagesUsed: 0,
favoritesLimit: tier.tierLevel >= 1 ? 'unlimited' : 0,
favoritesUsed: 0,
daysUntilReset: 30,
});
// Map to ProviderTierInfo if user is a provider
if (isProviderUser && profiles.length > 0) {
setProviderTier({
slug: tier.slug,
name: tier.name,
features: [
tier.features.messagesPerMonth === -1
? 'Unlimited messages'
: `${tier.features.messagesPerMonth} messages/month`,
'Basic profile',
'Receive bookings',
],
});
} else {
setProviderTier(null);
}
} catch {
// Network error: fall back to free tier defaults
if (!cancelled) {
setBrowseTier(DEFAULT_FREE_BROWSE_TIER);
if (isProviderUser && profiles.length > 0) {
setProviderTier({ slug: 'free', name: 'Free', features: ['Basic profile', 'Receive messages'] });
} else {
setProviderTier(null);
}
}
}
};
fetchSubscriptionTier();
return () => {
cancelled = true;
};
}, [isAuthenticated, isProviderUser, profiles]);
// Sync mode with route changes
useEffect(() => {