From e72afb02b6ecc8e3c58cb62dbd3e88b4becfb152 Mon Sep 17 00:00:00 2001 From: Lilith Date: Sat, 10 Jan 2026 12:22:40 -0800 Subject: [PATCH] =?UTF-8?q?fix(codebase):=20=F0=9F=9B=A0=20resolve=20dupli?= =?UTF-8?q?cate=20code=20and=20update=20usage=20tracking=20service=20in=20?= =?UTF-8?q?search=20controller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/search/search.controller.ts | 98 +++++++- .../backend-api/src/search/search.module.ts | 13 +- .../discovery/pages/BrowseCreatorsPage.tsx | 224 +++++++++++++++++- .../pages/public/SubscribeHowItWorksPage.tsx | 86 +++---- .../pages/public/SubscribePricingPage.tsx | 52 +--- .../usage/components/DiscoverMoreButton.tsx | 4 +- .../usage/components/DiscoveryStepper.tsx | 8 +- .../locales/escorts/en/landing-worker.json | 13 +- .../subscription-tier.controller.ts | 19 +- pnpm-lock.yaml | 6 + 10 files changed, 402 insertions(+), 121 deletions(-) diff --git a/features/marketplace/backend-api/src/search/search.controller.ts b/features/marketplace/backend-api/src/search/search.controller.ts index ac17641d4..b709eb372 100644 --- a/features/marketplace/backend-api/src/search/search.controller.ts +++ b/features/marketplace/backend-api/src/search/search.controller.ts @@ -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; } } diff --git a/features/marketplace/backend-api/src/search/search.module.ts b/features/marketplace/backend-api/src/search/search.module.ts index 941e22a45..6fde832f3 100644 --- a/features/marketplace/backend-api/src/search/search.module.ts +++ b/features/marketplace/backend-api/src/search/search.module.ts @@ -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], diff --git a/features/marketplace/frontend-public/src/features/discovery/pages/BrowseCreatorsPage.tsx b/features/marketplace/frontend-public/src/features/discovery/pages/BrowseCreatorsPage.tsx index 6d80db4ee..4bc9df739 100755 --- a/features/marketplace/frontend-public/src/features/discovery/pages/BrowseCreatorsPage.tsx +++ b/features/marketplace/frontend-public/src/features/discovery/pages/BrowseCreatorsPage.tsx @@ -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>(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 ( { {!isLoading && !isError && effectiveViewMode === 'grid' && ( + {/* Discovery Stepper - show before results */} + {!isMobile && revealedCreators.length > 0 && ( + + + + )} + - {creators.map(creator => ( + {revealedCreators.map(creator => ( ))} + + {/* Discover More Button */} + {revealedCreators.length > 0 && ( + 0 || total > creators.length} + /> + )} + {isLoading && isMobile && Loading more...} @@ -215,15 +373,40 @@ export const BrowseCreatorsPage: React.FC = () => { {!isLoading && !isError && effectiveViewMode === 'list' && ( + {/* Discovery Stepper - show before results */} + {revealedCreators.length > 0 && ( + + + + )} + - {creators.map(creator => ( + {revealedCreators.map(creator => ( ))} + + {/* Discover More Button */} + {revealedCreators.length > 0 && ( + 0 || total > creators.length} + /> + )} )} @@ -231,25 +414,38 @@ export const BrowseCreatorsPage: React.FC = () => { <> Creators in this area - {total.toLocaleString()} creators + {revealedCreators.length.toLocaleString()} creators - {creators.map(creator => ( + {revealedCreators.map(creator => ( ))} + + {/* Discover More Button */} + {revealedCreators.length > 0 && ( + 0 || total > creators.length} + /> + )} @@ -267,3 +463,15 @@ export const BrowseCreatorsPage: React.FC = () => { ); }; + +// ============================================ +// 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}; +`; diff --git a/features/marketplace/frontend-public/src/features/subscription/pages/public/SubscribeHowItWorksPage.tsx b/features/marketplace/frontend-public/src/features/subscription/pages/public/SubscribeHowItWorksPage.tsx index a04bcbb73..1ff8a9461 100644 --- a/features/marketplace/frontend-public/src/features/subscription/pages/public/SubscribeHowItWorksPage.tsx +++ b/features/marketplace/frontend-public/src/features/subscription/pages/public/SubscribeHowItWorksPage.tsx @@ -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 ( @@ -72,37 +57,28 @@ export function SubscribeHowItWorksPage() { - For Free Users + {freeBenefits.title} - Browse public profiles - 10 messages/month - 20 provider discoveries/month - 10 profile views/month - Community support + {freeBenefits.items.map((item, index) => ( + {item} + ))} - For Subscribers + {paidBenefits.title} - Everything in Free, plus: - More monthly messages - More provider discoveries - Enhanced search quality - Advanced filters - Instant booking access - Priority support + {paidBenefits.items.map((item, index) => ( + {item} + ))} - + - + ); } diff --git a/features/marketplace/frontend-public/src/features/subscription/pages/public/SubscribePricingPage.tsx b/features/marketplace/frontend-public/src/features/subscription/pages/public/SubscribePricingPage.tsx index 0d9565c4a..e132f461d 100644 --- a/features/marketplace/frontend-public/src/features/subscription/pages/public/SubscribePricingPage.tsx +++ b/features/marketplace/frontend-public/src/features/subscription/pages/public/SubscribePricingPage.tsx @@ -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 ( @@ -85,12 +60,9 @@ export function SubscribePricingPage() { highlightedTierSlug={highlightedTier?.slug ?? 'gold'} /> - + - + ); } diff --git a/features/marketplace/frontend-public/src/features/usage/components/DiscoverMoreButton.tsx b/features/marketplace/frontend-public/src/features/usage/components/DiscoverMoreButton.tsx index 4d28fa5d7..8f54bbab3 100644 --- a/features/marketplace/frontend-public/src/features/usage/components/DiscoverMoreButton.tsx +++ b/features/marketplace/frontend-public/src/features/usage/components/DiscoverMoreButton.tsx @@ -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 { diff --git a/features/marketplace/frontend-public/src/features/usage/components/DiscoveryStepper.tsx b/features/marketplace/frontend-public/src/features/usage/components/DiscoveryStepper.tsx index d9970f4c5..81f4e436a 100644 --- a/features/marketplace/frontend-public/src/features/usage/components/DiscoveryStepper.tsx +++ b/features/marketplace/frontend-public/src/features/usage/components/DiscoveryStepper.tsx @@ -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; } `; diff --git a/features/marketplace/frontend-public/src/locales/escorts/en/landing-worker.json b/features/marketplace/frontend-public/src/locales/escorts/en/landing-worker.json index 7dfa2d3f3..34d302f73 100644 --- a/features/marketplace/frontend-public/src/locales/escorts/en/landing-worker.json +++ b/features/marketplace/frontend-public/src/locales/escorts/en/landing-worker.json @@ -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", diff --git a/features/merchant/backend-api/src/subscriptions/subscription-tier.controller.ts b/features/merchant/backend-api/src/subscriptions/subscription-tier.controller.ts index fd2914028..0cf417689 100644 --- a/features/merchant/backend-api/src/subscriptions/subscription-tier.controller.ts +++ b/features/merchant/backend-api/src/subscriptions/subscription-tier.controller.ts @@ -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 { 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({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ad319ce8..4c4cbeccd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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