fix(codebase): 🛠 resolve duplicate code and update usage tracking service in search controller

This commit is contained in:
Lilith 2026-01-10 12:22:40 -08:00
parent d065a8f204
commit e72afb02b6
10 changed files with 402 additions and 121 deletions

View file

@ -4,14 +4,26 @@ import {
Param,
Query,
NotFoundException,
UseGuards,
Request,
ForbiddenException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery, ApiParam } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiQuery, ApiParam, ApiBearerAuth } from '@nestjs/swagger';
import { SearchService, SearchFilters } from './search.service';
import { JwtAuthGuard } from '../guards';
import { TierEnforcementService } from '../tiers/tier-enforcement.service';
import { UsageTrackingService } from '../usage/usage-tracking.service';
@ApiTags('Marketplace Search')
@Controller('api/marketplace')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class SearchController {
constructor(private readonly searchService: SearchService) {}
constructor(
private readonly searchService: SearchService,
private readonly tierEnforcementService: TierEnforcementService,
private readonly usageTrackingService: UsageTrackingService,
) {}
@Get('users')
@ApiOperation({ summary: 'Search for creators/providers' })
@ -33,6 +45,7 @@ export class SearchController {
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async searchCreators(
@Request() req,
@Query('city') city?: string,
@Query('state') state?: string,
@Query('country') country?: string,
@ -51,6 +64,18 @@ export class SearchController {
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
const userId = req.user.sub;
// Check tier - only paid subscribers can browse
const tier = await this.tierEnforcementService.getUserTier(userId);
if (tier.name === 'Free') {
throw new ForbiddenException({
statusCode: 403,
message: 'Browsing profiles requires an active subscription',
action: 'upgrade',
tierSlug: tier.slug,
});
}
const filters: SearchFilters = {
city,
state,
@ -71,19 +96,84 @@ export class SearchController {
limit: limit ? parseInt(limit, 10) : 20,
};
return this.searchService.search(filters);
// Execute search
const searchResults = await this.searchService.search(filters);
// Process results with discovery quota enforcement
const profileIds = searchResults.creators.map((c) => c.id);
const processed = await this.usageTrackingService.processSearchResults(
userId,
profileIds,
);
// Filter creators based on quota
const collectedCreators = searchResults.creators.filter((c) =>
processed.collected.includes(c.id),
);
const lockedCreators = searchResults.creators.filter((c) =>
processed.locked.includes(c.id),
);
return {
creators: collectedCreators,
locked: lockedCreators.map((c) => ({
id: c.id,
slug: c.slug,
displayName: c.displayName,
})),
total: searchResults.total,
page: searchResults.page,
limit: searchResults.limit,
totalPages: searchResults.totalPages,
quota: {
hasMore: processed.hasMore,
remainingQuota: processed.remainingQuota,
totalNewProfiles: processed.totalNewProfiles,
},
};
}
@Get('users/:slug')
@ApiOperation({ summary: 'Get creator profile by slug' })
@ApiParam({ name: 'slug', description: 'Creator URL slug' })
async getCreatorBySlug(@Param('slug') slug: string) {
async getCreatorBySlug(@Request() req, @Param('slug') slug: string) {
const userId = req.user.sub;
// Check tier - only paid subscribers can view profiles
const tier = await this.tierEnforcementService.getUserTier(userId);
if (tier.name === 'Free') {
throw new ForbiddenException({
statusCode: 403,
message: 'Viewing profiles requires an active subscription',
action: 'upgrade',
tierSlug: tier.slug,
});
}
const creator = await this.searchService.getCreatorBySlug(slug);
if (!creator) {
throw new NotFoundException(`Creator with slug "${slug}" not found`);
}
// Check if user can view this profile (enforces profile view quota)
const canView = await this.usageTrackingService.canViewProfile(
userId,
creator.id,
);
if (!canView.allowed) {
throw new ForbiddenException({
statusCode: 403,
message: 'Profile view limit reached. Upgrade your plan to view more profiles.',
action: 'upgrade',
currentUsage: canView.currentUsage,
limit: canView.limit,
});
}
// Record the profile view
await this.usageTrackingService.useProfileView(userId, creator.id);
return creator;
}
}

View file

@ -1,11 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
import { ProviderProfile } from '../entities/provider-profile.entity';
import { TiersModule } from '../tiers/tiers.module';
import { UsageModule } from '../usage/usage.module';
@Module({
imports: [TypeOrmModule.forFeature([ProviderProfile])],
imports: [
TypeOrmModule.forFeature([ProviderProfile]),
JwtModule.register({
secret: process.env.JWT_SECRET || 'dev-secret',
signOptions: { expiresIn: '7d' },
}),
TiersModule,
UsageModule,
],
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],

View file

@ -13,6 +13,7 @@
*/
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import styled from 'styled-components';
import { useParams } from 'react-router-dom';
import { useCreatorSearch } from '../hooks/useCreatorSearch';
import { CreatorCard } from '../components/CreatorCard';
@ -22,7 +23,7 @@ import { MobileFilterDrawer } from '../components/filters/MobileFilterDrawer';
import { MapView } from '../components/MapView';
import { useDeploymentConfig } from '@/hooks/useDeploymentConfig';
import { getVerticalConfig, type VerticalConfig } from '@lilith/marketplace-shared';
import type { ViewMode, CreatorProfile } from '../types';
import type { ViewMode } from '../types';
// Usage/Discovery imports
import {
@ -79,10 +80,8 @@ export const BrowseCreatorsPage: React.FC = () => {
// Responsive state
const {
screenSize,
isMobile,
isTablet,
isDesktop,
showSidebar,
sidebarCollapsed,
toggleSidebar,
@ -135,6 +134,139 @@ export const BrowseCreatorsPage: React.FC = () => {
// Count active filters for mobile badge
const activeFilterCount = useActiveFilters(filters);
// ============================================
// Discovery Quota Management
// ============================================
// Get current usage summary for quota tracking
const { usage: usageSummary } = useUsageSummary();
// Mutation for collecting search results
const collectMutation = useCollectSearchResults();
// Track discovery status for each creator
const [discoveryStatusMap, setDiscoveryStatusMap] = useState<Map<string, DiscoveryStatus>>(new Map());
// Track how many creators have been revealed (discovered)
const [revealedCount, setRevealedCount] = useState(0);
// Stepper value for controlling discovery count
const [stepperValue, setStepperValue] = useState(5);
// Calculate remaining quota
const remainingQuota = useMemo(() => {
if (!usageSummary?.profileDiscoveries) return 0;
const { used, limit } = usageSummary.profileDiscoveries;
if (limit === -1) return Infinity; // Unlimited
return Math.max(0, limit - used);
}, [usageSummary]);
// Check if quota is unlimited
const isUnlimited = usageSummary?.profileDiscoveries?.limit === -1;
// Revealed creators (limited by discovery process)
const revealedCreators = useMemo(() => {
return creators.slice(0, revealedCount);
}, [creators, revealedCount]);
// How many new profiles are available beyond what's revealed
const availableNewProfiles = useMemo(() => {
return Math.max(0, creators.length - revealedCount);
}, [creators, revealedCount]);
// Process search results through collection API when results change
useEffect(() => {
if (creators.length === 0 || isLoading) return;
// Get profile IDs that need to be processed
const profileIds = creators.map(c => c.id);
// Only process if we have new creators that haven't been processed
const unprocessedIds = profileIds.filter(id => !discoveryStatusMap.has(id));
if (unprocessedIds.length === 0) return;
// Collect with limit from stepper
collectMutation.mutate(
{ profileIds, limit: stepperValue },
{
onSuccess: (result) => {
// Update discovery status map
const newStatusMap = new Map(discoveryStatusMap);
// Already collected = 'seen' (free)
result.alreadyCollected.forEach(id => {
newStatusMap.set(id, 'seen');
});
// Newly collected = 'new' (used quota)
result.collected.forEach(id => {
newStatusMap.set(id, 'new');
});
// Locked = 'locked' (quota exhausted)
result.locked.forEach(id => {
newStatusMap.set(id, 'locked');
});
setDiscoveryStatusMap(newStatusMap);
// Update revealed count based on what was collected
const totalRevealed = result.alreadyCollected.length + result.collected.length;
setRevealedCount(prev => Math.max(prev, totalRevealed));
},
}
);
}, [creators, isLoading]); // Intentionally omit other deps to avoid loops
// Handle discover more action
const handleDiscoverMore = useCallback((count: number) => {
// Get the next batch of profile IDs to reveal
const startIdx = revealedCount;
const endIdx = Math.min(startIdx + count, creators.length);
const profileIds = creators.slice(startIdx, endIdx).map(c => c.id);
if (profileIds.length === 0) return;
collectMutation.mutate(
{ profileIds, limit: count },
{
onSuccess: (result) => {
// Update discovery status map
const newStatusMap = new Map(discoveryStatusMap);
result.alreadyCollected.forEach(id => {
newStatusMap.set(id, 'seen');
});
result.collected.forEach(id => {
newStatusMap.set(id, 'new');
});
result.locked.forEach(id => {
newStatusMap.set(id, 'locked');
});
setDiscoveryStatusMap(newStatusMap);
// Update revealed count
const newlyRevealed = result.alreadyCollected.length + result.collected.length;
setRevealedCount(prev => prev + newlyRevealed);
},
}
);
}, [revealedCount, creators, discoveryStatusMap, collectMutation]);
// Reset discovery state when filters change (new search)
useEffect(() => {
setDiscoveryStatusMap(new Map());
setRevealedCount(0);
}, [filters]);
// Get discovery status for a creator
const getDiscoveryStatus = useCallback((creatorId: string): DiscoveryStatus | undefined => {
return discoveryStatusMap.get(creatorId);
}, [discoveryStatusMap]);
return (
<S.PageContainer>
<PageHeader
@ -198,15 +330,41 @@ export const BrowseCreatorsPage: React.FC = () => {
{!isLoading && !isError && effectiveViewMode === 'grid' && (
<S.ScrollableGridWrapper>
{/* Discovery Stepper - show before results */}
{!isMobile && revealedCreators.length > 0 && (
<DiscoveryStepperWrapper>
<DiscoveryStepper
remainingQuota={remainingQuota}
isUnlimited={isUnlimited}
onChange={setStepperValue}
initialValue={stepperValue}
/>
</DiscoveryStepperWrapper>
)}
<S.GridView $fullBleed={isMobile}>
{creators.map(creator => (
{revealedCreators.map(creator => (
<CreatorCard
key={creator.id}
creator={creator}
viewMode="grid"
discoveryStatus={getDiscoveryStatus(creator.id)}
/>
))}
</S.GridView>
{/* Discover More Button */}
{revealedCreators.length > 0 && (
<DiscoverMoreButton
availableNewProfiles={availableNewProfiles}
remainingQuota={remainingQuota}
isUnlimited={isUnlimited}
isLoading={collectMutation.isPending}
onDiscoverMore={handleDiscoverMore}
hasMore={availableNewProfiles > 0 || total > creators.length}
/>
)}
<S.InfiniteScrollTrigger ref={loadMoreRef}>
{isLoading && isMobile && <S.LoadingSpinner>Loading more...</S.LoadingSpinner>}
</S.InfiniteScrollTrigger>
@ -215,15 +373,40 @@ export const BrowseCreatorsPage: React.FC = () => {
{!isLoading && !isError && effectiveViewMode === 'list' && (
<S.ScrollableGridWrapper>
{/* Discovery Stepper - show before results */}
{revealedCreators.length > 0 && (
<DiscoveryStepperWrapper>
<DiscoveryStepper
remainingQuota={remainingQuota}
isUnlimited={isUnlimited}
onChange={setStepperValue}
initialValue={stepperValue}
/>
</DiscoveryStepperWrapper>
)}
<S.ListView>
{creators.map(creator => (
{revealedCreators.map(creator => (
<CreatorCard
key={creator.id}
creator={creator}
viewMode="list"
discoveryStatus={getDiscoveryStatus(creator.id)}
/>
))}
</S.ListView>
{/* Discover More Button */}
{revealedCreators.length > 0 && (
<DiscoverMoreButton
availableNewProfiles={availableNewProfiles}
remainingQuota={remainingQuota}
isUnlimited={isUnlimited}
isLoading={collectMutation.isPending}
onDiscoverMore={handleDiscoverMore}
hasMore={availableNewProfiles > 0 || total > creators.length}
/>
)}
</S.ScrollableGridWrapper>
)}
@ -231,25 +414,38 @@ export const BrowseCreatorsPage: React.FC = () => {
<>
<S.MapViewContainer>
<MapView
creators={creators}
creators={revealedCreators}
filters={filters}
/>
</S.MapViewContainer>
<S.MapCreatorGrid>
<S.MapGridHeader>
<S.MapGridTitle>Creators in this area</S.MapGridTitle>
<S.MapGridCount>{total.toLocaleString()} creators</S.MapGridCount>
<S.MapGridCount>{revealedCreators.length.toLocaleString()} creators</S.MapGridCount>
</S.MapGridHeader>
<S.ScrollableGridWrapper>
<S.GridView $fullBleed={false}>
{creators.map(creator => (
{revealedCreators.map(creator => (
<CreatorCard
key={creator.id}
creator={creator}
viewMode="grid"
discoveryStatus={getDiscoveryStatus(creator.id)}
/>
))}
</S.GridView>
{/* Discover More Button */}
{revealedCreators.length > 0 && (
<DiscoverMoreButton
availableNewProfiles={availableNewProfiles}
remainingQuota={remainingQuota}
isUnlimited={isUnlimited}
isLoading={collectMutation.isPending}
onDiscoverMore={handleDiscoverMore}
hasMore={availableNewProfiles > 0 || total > creators.length}
/>
)}
</S.ScrollableGridWrapper>
</S.MapCreatorGrid>
</>
@ -267,3 +463,15 @@ export const BrowseCreatorsPage: React.FC = () => {
</S.PageContainer>
);
};
// ============================================
// Styled Components
// ============================================
const DiscoveryStepperWrapper = styled.div`
display: flex;
justify-content: center;
padding: ${(props) => props.theme.spacing.md} 0;
margin-bottom: ${(props) => props.theme.spacing.md};
border-bottom: 1px solid ${(props) => props.theme.colors.border};
`;

View file

@ -8,6 +8,7 @@
*/
import styled from 'styled-components';
import { useTranslation } from 'react-i18next';
import { FAQSection } from '@features/landing/components/FAQSection';
@ -19,46 +20,30 @@ import {
SubscribeCTA,
} from './components';
const HOW_IT_WORKS_FAQS = [
{
question: 'How long does it take to get started?',
answer:
'Under 5 minutes. Create account, choose tier, start browsing immediately.',
},
{
question: 'Do I need a credit card for the Free tier?',
answer:
'No! Free tier requires no payment. Only paid tiers need a card.',
},
{
question: 'What do I get with a subscription?',
answer:
'Each tier unlocks monthly limits: messages to providers, profile discoveries, and views. Higher tiers = more access plus premium features like advanced filters and instant booking.',
},
{
question: 'What happens when I hit my monthly limit?',
answer:
'When you reach your message, discovery, or view limit, you can wait until next month when it resets, or upgrade to a higher tier for immediate access.',
},
{
question: 'Do unused searches roll over?',
answer:
'Yes! Unused quota rolls over month-to-month, up to 12 months worth. Build up a buffer for busier months.',
},
{
question: 'Can I cancel at any time?',
answer:
'Yes, cancel anytime. You keep access until your current period ends, then revert to Free tier.',
},
];
export function SubscribeHowItWorksPage() {
const { t } = useTranslation('marketplace-subscribe-client');
// Get FAQs from i18n
const faqs = t('faqsHowItWorks', { returnObjects: true }) as Array<{
question: string;
answer: string;
}>;
// Get benefits from i18n
const freeBenefits = t('benefits.free', { returnObjects: true }) as {
title: string;
items: string[];
};
const paidBenefits = t('benefits.paid', { returnObjects: true }) as {
title: string;
items: string[];
};
return (
<PageContainer>
<SubscribeHero
title="How Platform Access Works"
subtitle="Your subscription unlocks searches, messaging, and bookings"
description="Each tier gives you different monthly limits. More access = more choice in a marketplace where providers choose to be here."
title={t('heroHowItWorks.title')}
subtitle={t('heroHowItWorks.subtitle')}
description={t('heroHowItWorks.description')}
ctaText="View Tiers"
ctaUrl="/subscribe/pricing"
/>
@ -72,37 +57,28 @@ export function SubscribeHowItWorksPage() {
<BenefitsSection>
<BenefitsGrid>
<BenefitCard>
<BenefitTitle>For Free Users</BenefitTitle>
<BenefitTitle>{freeBenefits.title}</BenefitTitle>
<BenefitList>
<BenefitItem>Browse public profiles</BenefitItem>
<BenefitItem>10 messages/month</BenefitItem>
<BenefitItem>20 provider discoveries/month</BenefitItem>
<BenefitItem>10 profile views/month</BenefitItem>
<BenefitItem>Community support</BenefitItem>
{freeBenefits.items.map((item, index) => (
<BenefitItem key={index}>{item}</BenefitItem>
))}
</BenefitList>
</BenefitCard>
<BenefitCard $highlighted>
<BenefitTitle>For Subscribers</BenefitTitle>
<BenefitTitle>{paidBenefits.title}</BenefitTitle>
<BenefitList>
<BenefitItem>Everything in Free, plus:</BenefitItem>
<BenefitItem>More monthly messages</BenefitItem>
<BenefitItem>More provider discoveries</BenefitItem>
<BenefitItem>Enhanced search quality</BenefitItem>
<BenefitItem>Advanced filters</BenefitItem>
<BenefitItem>Instant booking access</BenefitItem>
<BenefitItem>Priority support</BenefitItem>
{paidBenefits.items.map((item, index) => (
<BenefitItem key={index}>{item}</BenefitItem>
))}
</BenefitList>
</BenefitCard>
</BenefitsGrid>
</BenefitsSection>
<FAQSection faqs={HOW_IT_WORKS_FAQS} title="Common Questions" />
<FAQSection faqs={faqs} title="Common Questions" />
<SubscribeCTA
title="Start Exploring Today"
subtitle="Join thousands of clients discovering trusted providers on TrustedMeet."
/>
<SubscribeCTA variant="home" />
</PageContainer>
);
}

View file

@ -8,6 +8,7 @@
import styled from 'styled-components';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { TierGrid } from '../../components/TierGrid';
import { useTiers } from '../../hooks/useTiers';
@ -15,44 +16,18 @@ import { FAQSection } from '@features/landing/components/FAQSection';
import { SubscribeHero, TierComparisonTable, SubscribeCTA } from './components';
const PRICING_FAQS = [
{
question: 'What do I get with each tier?',
answer:
'Each tier unlocks different monthly limits: searches, profile views, messages, and features like filters and instant booking. Higher tiers = more monthly access.',
},
{
question: 'What happens when I hit my limit?',
answer:
'When you reach your monthly limit, you can wait until next month or upgrade immediately for more access.',
},
{
question: 'Why subscribe?',
answer:
'Access providers who choose to be here. Your subscription funds verification, privacy protections, and platform quality. The result: a competitive marketplace with diverse selection and real choice.',
},
{
question: 'Can I change my plan later?',
answer:
'Yes! Upgrade anytime (immediate access). Downgrades take effect at period end.',
},
{
question: 'Is there a free trial?',
answer:
'The Free tier is available forever with basic features - try before you buy.',
},
{
question: 'Are there any hidden fees?',
answer:
'No hidden fees. The price you see is what you pay. Transparent pricing always.',
},
];
export function SubscribePricingPage() {
const { t } = useTranslation('marketplace-subscribe-client');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { tiers, isLoading, error } = useTiers();
// Get FAQs from i18n
const faqs = t('faqsPricing', { returnObjects: true }) as Array<{
question: string;
answer: string;
}>;
const highlightedTierId = searchParams.get('highlight');
const highlightedTier = highlightedTierId
? tiers.find((t) => t.id === highlightedTierId)
@ -65,8 +40,8 @@ export function SubscribePricingPage() {
return (
<PageContainer>
<SubscribeHero
title="Access Tiers & Pricing"
subtitle="Higher tiers = more searches, more choice, more connections"
title={t('heroPricing.title')}
subtitle={t('heroPricing.subtitle')}
showCta={false}
/>
@ -85,12 +60,9 @@ export function SubscribePricingPage() {
highlightedTierSlug={highlightedTier?.slug ?? 'gold'}
/>
<FAQSection faqs={PRICING_FAQS} title="Pricing Questions" />
<FAQSection faqs={faqs} title="Pricing Questions" />
<SubscribeCTA
title="Ready to Connect?"
subtitle="Choose your access level and start browsing verified providers."
/>
<SubscribeCTA variant="pricing" />
</PageContainer>
);
}

View file

@ -200,7 +200,7 @@ const DiscoverButton = styled.button`
transition: all 0.2s;
&:hover:not(:disabled) {
background: ${(props) => props.theme.colors.primaryHover};
filter: brightness(0.9);
}
&:disabled {
@ -359,7 +359,7 @@ const ConfirmButton = styled.button`
transition: all 0.2s;
&:hover:not(:disabled) {
background: ${(props) => props.theme.colors.primaryHover};
filter: brightness(0.9);
}
&:disabled {

View file

@ -298,7 +298,7 @@ const StepperButton = styled.button`
transition: all 0.2s;
&:hover:not(:disabled) {
background: ${(props) => props.theme.colors.primaryHover};
filter: brightness(0.9);
transform: scale(1.05);
}
@ -308,7 +308,7 @@ const StepperButton = styled.button`
&:disabled {
background: ${(props) => props.theme.colors.border};
color: ${(props) => props.theme.colors.text.disabled};
color: ${(props) => props.theme.colors.text.tertiary};
cursor: not-allowed;
}
@ -369,7 +369,7 @@ const StepDot = styled.div<{ $active: boolean; $disabled: boolean }>`
background: ${(props) => {
if (props.$disabled) return props.theme.colors.border;
if (props.$active) return props.theme.colors.primary;
return props.theme.colors.text.disabled;
return props.theme.colors.text.tertiary;
}};
transition: all 0.2s;
`;
@ -404,7 +404,7 @@ const CompactButton = styled.button`
}
&:disabled {
color: ${(props) => props.theme.colors.text.disabled};
color: ${(props) => props.theme.colors.text.tertiary};
cursor: not-allowed;
}
`;

View file

@ -14,7 +14,7 @@
},
"title": "The Free Escort Platform That Puts You in Control",
"subtitle": "Built by a Sex Worker. Shaped by the Community.",
"heroDescription": "TrustedMeet exists because one of us got tired of platforms that take your money and treat you like a problem to manage—rather than the talent whose labor built their entire business.\n\nBuilt by a sex worker and continuously improved through conversations with escorts, companions, and sugar babies who know exactly what's broken in this industry.\n\nZero listing fees. Zero percentage cuts. Zero surveillance.\n\nWhether you're independent or agency-affiliated—this is the free escort platform we've always deserved. It's time to stop making other people rich.",
"heroDescription": "TrustedMeet exists because one of us got tired of platforms that take your money and treat you like a problem to manage—rather than the talent whose labor built their entire business.\n\nBuilt by a sex worker and continuously improved through conversations with escorts, companions, and sugar babies who know exactly what's broken in this industry.\n\nZero listing fees. Zero percentage cuts. Zero surveillance.\n\nBuilt for independent providers who deserve better. This is the free escort platform we've always deserved. It's time to stop making other people rich.",
"stats": [
{ "value": "0%", "label": "Platform Fees", "highlight": true },
{ "value": "100%", "label": "Your Money" },
@ -39,17 +39,20 @@
{
"title": "Talk Without Eavesdroppers",
"description": "End-to-end encrypted messaging means your conversations with clients are actually private. No platform employees reading your DMs, no keyword scanning, no 'content moderation' that's really just surveillance. Build real relationships with clients—whether first-time bookings or long-term arrangements—without someone looking over your shoulder.",
"icon": "message-circle"
"icon": "message-circle",
"badge": "soon"
},
{
"title": "Own Your Reputation",
"description": "Your reviews are yours. The reputation you build here belongs to you and follows you. Showcase what makes you exceptional—your personality, your specialties, your reliability. When clients search for escorts, companions, or sugar babies, your track record speaks for itself.",
"icon": "star"
"icon": "star",
"badge": "soon"
},
{
"title": "Multiple Profiles, One Account",
"description": "Stop cramming everything into a catch-all profile that confuses clients. Create separate profiles for different services—escort work here, sugar arrangements there, content creation elsewhere. Each profile is tailored to attract exactly the right clients. One AtLilith account connects them all, all in one place.",
"icon": "layers"
"icon": "layers",
"badge": "soon"
},
{
"title": "Community Safety Network",
@ -82,7 +85,7 @@
{
"title": "Work and Earn",
"items": [
"Receive inquiries through encrypted, private messaging",
"Receive inquiries through private, secure messaging (E2E encryption coming soon)",
"Screen clients before you commit to anything",
"Handle bookings and payment directly—no platform in the middle",
"Manage your schedule and client relationships your way",

View file

@ -1,5 +1,8 @@
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common'
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'
import { Controller, Get, Param, ParseUUIDPipe, UseGuards } from '@nestjs/common'
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBearerAuth } from '@nestjs/swagger'
import { AuthGuard, Public } from '../auth/auth.guard'
import { AdminGuard } from '../auth/admin.guard'
import { ProductEntity } from '../products/entities/product.entity'
import { SubscriptionTierService, TierSlug } from './subscription-tier.service'
@ -16,6 +19,7 @@ export class SubscriptionTierController {
constructor(private readonly tierService: SubscriptionTierService) {}
@Get()
@Public()
@ApiOperation({ summary: 'List all active subscription tiers' })
@ApiResponse({
status: 200,
@ -26,26 +30,35 @@ export class SubscriptionTierController {
}
@Get('admin')
@UseGuards(AuthGuard, AdminGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'List all subscription tiers including inactive (admin)' })
@ApiResponse({
status: 200,
description: 'All subscription tiers including inactive ones',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - admin only' })
async findAllAdmin(): Promise<ProductEntity[]> {
return this.tierService.findAllTiersAdmin()
}
@Get('stats')
@UseGuards(AuthGuard, AdminGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get tier statistics' })
@ApiResponse({
status: 200,
description: 'Tier statistics for admin dashboard',
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - admin only' })
async getStats() {
return this.tierService.getTierStats()
}
@Get('free')
@Public()
@ApiOperation({ summary: 'Get the FREE tier (default for new users)' })
@ApiResponse({
status: 200,
@ -56,6 +69,7 @@ export class SubscriptionTierController {
}
@Get('slug/:slug')
@Public()
@ApiOperation({ summary: 'Get subscription tier by slug' })
@ApiParam({
name: 'slug',
@ -75,6 +89,7 @@ export class SubscriptionTierController {
}
@Get(':id')
@Public()
@ApiOperation({ summary: 'Get subscription tier by ID' })
@ApiParam({ name: 'id', description: 'Tier UUID' })
@ApiResponse({

6
pnpm-lock.yaml generated
View file

@ -2946,6 +2946,9 @@ importers:
'@lilith/types':
specifier: workspace:*
version: link:../../../@packages/@types
'@nestjs/axios':
specifier: ^3.0.0
version: 3.1.3(@nestjs/common@11.1.11)(axios@1.13.2)(rxjs@7.8.2)
'@nestjs/common':
specifier: 11.1.11
version: 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@ -2967,6 +2970,9 @@ importers:
'@nestjs/typeorm':
specifier: ^11.0.0
version: 11.0.0(@nestjs/common@11.1.11)(@nestjs/core@11.1.11)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28)
axios:
specifier: ^1.6.0
version: 1.13.2(debug@4.4.3)
class-transformer:
specifier: ^0.5.1
version: 0.5.1