fix(codebase): 🛠 resolve duplicate code and update usage tracking service in search controller
This commit is contained in:
parent
d065a8f204
commit
e72afb02b6
10 changed files with 402 additions and 121 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
6
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue