diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md index cd7f818..87ce23b 100644 --- a/QUICK_REFERENCE.md +++ b/QUICK_REFERENCE.md @@ -1,415 +1,192 @@ -# Quick Reference: Pricing/Subscription/Payment System +# GoodGo Platform - Authentication Quick Reference -## Files at a Glance +## 🔑 Key Points at a Glance -### 🎨 Frontend - -| File | Purpose | Status | -|------|---------|--------| -| `apps/web/app/[locale]/(public)/pricing/page.tsx` | Main pricing page | ✅ Complete | -| `apps/web/lib/subscription-api.ts` | Subscription API client | ✅ Complete | -| `apps/web/lib/payment-api.ts` | Payment API client | ✅ Complete | -| `apps/web/lib/hooks/use-subscription.ts` | Subscription hooks | ✅ Complete | -| `apps/web/lib/hooks/use-payments.ts` | Payment hooks | ✅ Complete | -| `apps/web/app/.../dashboard/payments/page.tsx` | Payment history | ✅ Complete | - -### 🔧 Backend - -| Directory | Purpose | Status | -|-----------|---------|--------| -| `apps/api/src/modules/subscriptions/` | Subscription CQRS module | ✅ Complete | -| `apps/api/src/modules/payments/` | Payment CQRS module | ✅ Complete | -| `apps/api/src/modules/payments/infrastructure/services/` | Payment gateways (VNPay, MoMo, ZaloPay) | ✅ Complete | - -### 📦 Database - -| Model | Fields | Relationships | -|-------|--------|---| -| `Plan` | id, tier (unique), name, prices, features, isActive | 1→M Subscription | -| `Subscription` | id, userId (unique), planId, status, periods, cancelledAt | M←1 Plan, 1←1 User | -| `Payment` | id, userId, provider, type, amountVND, status, providerTxId, idempotencyKey | M←1 User | -| `UsageRecord` | id, subscriptionId, metric, count, periods | M←1 Subscription | - ---- - -## Key API Endpoints - -### Plans (Public) +### Password Hashing ``` -GET /subscriptions/plans -GET /subscriptions/plans/:tier +Algorithm: bcrypt +Salt Rounds: 12 (env: BCRYPT_ROUNDS) +Min Length: 8 characters +Example: bcrypt.hash('password', 12) ``` -### Subscriptions (Auth Required) +### Phone Numbers (Vietnamese) ``` -POST /subscriptions # Create new -PUT /subscriptions/upgrade # Upgrade -DELETE /subscriptions # Cancel -GET /subscriptions/quota/:metric # Check quota -POST /subscriptions/usage # Record usage -GET /subscriptions/billing # View history +Valid Formats: 0900000001, 84900000001, +84900000001 +Normalized: +84900000001 +Regex: /^(?:\+84|84|0)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/ +File: apps/api/src/modules/shared/utils/vietnam-phone.validator.ts ``` -### Payments (Auth + Webhook) +### Email ``` -POST /payments # Create payment → returns paymentUrl -POST /payments/callback/:provider # Webhook from gateway -GET /payments/:id # Check status -GET /payments # List transactions -POST /payments/:id/refund # Refund (admin) +Regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +Normalization: lowercase + trim +Storage: admin@goodgo.vn +``` + +### PII Encryption +``` +Algorithm: AES-256-GCM +Key: 32 bytes (64 hex chars) +Encrypted: email, phone, kycData +Searchable: email → emailHash (HMAC-SHA256) + phone → phoneHash (HMAC-SHA256) +Env Var: FIELD_ENCRYPTION_KEY +``` + +### User Login +``` +Username: phone (normalized) +Password: plain text +Lookup: by phoneHash (unique index) +Required: isActive = true, passwordHash ≠ null +Response: tokens (or MFA challenge) +``` + +### User Roles +``` +BUYER - Search, inquire, offer (default) +SELLER - Create listings +AGENT - Professional agent +ADMIN - Full access +``` + +### MFA +``` +TOTP: otplib (RFC 6238) +Period: 30 seconds +Digits: 6 +Backup Codes: 10 × 8 chars (A-Z no OI, 2-9 no 01) +Hashing: HMAC-SHA256 (not bcrypt) ``` --- -## Type Definitions +## 📋 Creating a Login-Capable Admin User -### Frontend Types +### 5-Step Process +**1. Normalize phone** ```typescript -// From subscription-api.ts -interface PlanDto { - id: string; - tier: string; // FREE, AGENT_PRO, INVESTOR, ENTERPRISE - name: string; - priceMonthlyVND: string; // In VND - priceYearlyVND: string; // In VND - maxListings: number; - maxSavedSearches: number; - features: Record; - isActive: boolean; -} - -interface CreateSubscriptionResult { - subscriptionId: string; - planTier: string; - status: string; // ACTIVE, PAST_DUE, CANCELLED, EXPIRED - currentPeriodStart: string; // ISO datetime - currentPeriodEnd: string; // ISO datetime -} - -// From payment-api.ts -interface CreatePaymentPayload { - provider: 'VNPAY' | 'MOMO' | 'ZALOPAY' | 'BANK_TRANSFER'; - type: 'SUBSCRIPTION' | 'LISTING_FEE' | 'DEPOSIT' | 'FEATURED_LISTING'; - amountVND: number; // 1 to 100,000,000,000 - description: string; - returnUrl: string; // Redirect after payment - idempotencyKey?: string; // Prevent duplicates - transactionId?: string; // External transaction ID -} - -interface CreatePaymentResult { - paymentId: string; - paymentUrl: string; // Redirect user here - providerTxId: string; -} - -interface PaymentStatusDto { - id: string; - provider: string; - type: string; - amountVND: string; - status: string; // PENDING, PROCESSING, COMPLETED, FAILED, REFUNDED - providerTxId: string | null; - createdAt: string; - updatedAt: string; -} +phone = '0900000001' → '+84900000001' ``` ---- - -## How to Use in Frontend - -### Get Plans +**2. Derive HMAC key** ```typescript -import { usePlans } from '@/lib/hooks/use-subscription'; - -export function MyComponent() { - const { data: plans, isLoading } = usePlans(); - - return ( -
- {isLoading ? 'Loading...' : plans?.map(plan =>
{plan.name}
)} -
- ); -} +hmacKey = crypto.hkdfSync('sha256', Buffer.from(encryptionKey, 'hex'), + Buffer.alloc(0), Buffer.from('goodgo-field-hash', 'utf8'), 32) ``` -### Create Payment +**3. Compute hashes** ```typescript -import { paymentApi } from '@/lib/payment-api'; -import { useMutation } from '@tanstack/react-query'; +phoneHash = crypto.createHmac('sha256', hmacKey).update('+84900000001').digest('hex') +emailHash = crypto.createHmac('sha256', hmacKey).update('admin@goodgo.vn').digest('hex') +``` -const createPaymentMutation = useMutation({ - mutationFn: (payload) => paymentApi.createPayment(payload), +**4. Hash password** +```typescript +passwordHash = await bcrypt.hash('AdminPassword123', 12) +``` + +**5. Create user** +```typescript +await prisma.user.create({ + data: { + id: 'admin-seed-001', + phone: '+84900000001', + phoneHash, + email: 'admin@goodgo.vn', + emailHash, + passwordHash, + fullName: 'Admin', + role: 'ADMIN', + kycStatus: 'VERIFIED', + isActive: true, + totpEnabled: false, + totpBackupCodes: [], + }, }); - -// When user clicks "Pay Now" -const handlePayment = async (planTier: string, provider: 'VNPAY' | 'MOMO' | 'ZALOPAY') => { - const result = await createPaymentMutation.mutateAsync({ - provider, - type: 'SUBSCRIPTION', - amountVND: 499000, - description: `Subscription to ${planTier}`, - returnUrl: `${window.location.origin}/payment-return`, - idempotencyKey: crypto.randomUUID(), - }); - - // Redirect to payment gateway - window.location = result.paymentUrl; -}; -``` - -### Check Payment Status (on return page) -```typescript -import { paymentApi } from '@/lib/payment-api'; -import { useEffect, useState } from 'react'; - -export function PaymentReturnPage() { - const searchParams = new URLSearchParams(window.location.search); - const paymentId = searchParams.get('paymentId'); - const [status, setStatus] = useState('loading'); - - useEffect(() => { - if (!paymentId) return; - - const poll = async () => { - const payment = await paymentApi.getPaymentStatus(paymentId); - - if (payment.status === 'COMPLETED') { - // Create subscription - await subscriptionApi.createSubscription('AGENT_PRO', 'monthly'); - setStatus('success'); - // Redirect to dashboard - window.location = '/dashboard'; - } else if (payment.status === 'FAILED') { - setStatus('failed'); - } else { - // Poll again in 2 seconds - setTimeout(poll, 2000); - } - }; - - poll(); - }, [paymentId]); - - return
{status === 'loading' ? 'Processing payment...' : status}
; -} -``` - -### Create Subscription -```typescript -import { subscriptionApi } from '@/lib/subscription-api'; - -const result = await subscriptionApi.createSubscription('AGENT_PRO', 'monthly'); -console.log(result); -// { -// subscriptionId: 'cuid...', -// planTier: 'AGENT_PRO', -// status: 'ACTIVE', -// currentPeriodStart: '2024-04-12T...', -// currentPeriodEnd: '2024-05-12T...' -// } ``` --- -## How to Use in Backend - -### Create Plan (Admin) -```sql -INSERT INTO "Plan" (id, tier, name, "priceMonthlyVND", "priceYearlyVND", "maxListings", features, "isActive") -VALUES ( - 'cuid123', - 'AGENT_PRO', - 'Agent Pro', - 499000, - 4990000, - 50, - '{"analytics": true, "aiValuation": true}', - true -); -``` - -### Create Payment (via API) -``` -POST /payments -Authorization: Bearer -Content-Type: application/json - -{ - "provider": "VNPAY", - "type": "SUBSCRIPTION", - "amountVND": 499000, - "description": "Agent Pro - Monthly", - "returnUrl": "https://goodgo.vn/payment-return", - "idempotencyKey": "550e8400-e29b-41d4-a716-446655440000" -} - -Response: -{ - "paymentId": "cuid456", - "paymentUrl": "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html?...", - "providerTxId": "cuid456" -} -``` - -### Handle Payment Callback (Webhook) -``` -POST /payments/callback/vnpay?vnp_TxnRef=cuid456&vnp_ResponseCode=00&vnp_SecureHash=... - -Response: -{ - "orderId": "cuid456", - "isSuccess": true, - "status": "COMPLETED" -} -``` - -### Create Subscription (via API) -``` -POST /subscriptions -Authorization: Bearer -Content-Type: application/json - -{ - "planTier": "AGENT_PRO", - "billingCycle": "monthly" -} - -Response: -{ - "subscriptionId": "cuid789", - "planTier": "AGENT_PRO", - "status": "ACTIVE", - "currentPeriodStart": "2024-04-12T...", - "currentPeriodEnd": "2024-05-12T..." -} -``` - ---- - -## Pricing Structure - -``` -FREE (0 VND) -├── 3 listings -├── 5 saved searches -└── Basic features - -AGENT_PRO (499,000 VND/month | 4,990,000/year = -17%) -├── 50 listings -├── 30 saved searches -├── Analytics -├── AI Valuation -├── Priority support -└── Lead management - -INVESTOR (999,000 VND/month | 9,990,000/year = -17%) -├── 20 listings -├── 100 saved searches -├── Analytics -├── AI Valuation -├── Market reports -├── Price alerts -└── Portfolio tracking - -ENTERPRISE (4,990,000 VND/month | 49,900,000/year = -17%) -├── Unlimited listings -├── Unlimited searches -├── All INVESTOR features -├── API access -├── White label -└── Dedicated support -``` - ---- - -## Environment Variables +## 🧪 Test Login ```bash -# Backend (.env) -VNPAY_TMN_CODE=your_tmn_code -VNPAY_HASH_SECRET=your_hash_secret -VNPAY_BASE_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html -VNPAY_API_URL=https://sandbox.vnpayment.vn/merchant_webapi/api/transaction +curl -X POST http://localhost:3000/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "phone": "0900000001", + "password": "AdminPassword123" + }' +``` -MOMO_PARTNER_CODE=your_partner_code -MOMO_ACCESS_KEY=your_access_key -MOMO_SECRET_KEY=your_secret_key -MOMO_ENDPOINT=https://test-payment.momo.vn/v2/gateway/api - -ZALOPAY_APP_ID=your_app_id -ZALOPAY_KEY1=your_key1 -ZALOPAY_KEY2=your_key2 -ZALOPAY_ENDPOINT=https://sandbox.zalopay.com.vn - -# Frontend (.env.local) -NEXT_PUBLIC_APP_URL=https://goodgo.vn +**Success Response:** +```json +{ + "requiresMfa": false, + "tokens": { + "accessToken": "eyJ...", + "refreshToken": "eyJ...", + "expiresIn": 3600 + } +} ``` --- -## Testing Credentials +## ⚠️ Common Issues -### VNPay Sandbox -``` -Terminal: 0 -Account: 0968323286 -Password: 123456 -Card: 9704198526191432198 -OTP: 123456 -``` - -### MoMo Sandbox -``` -Phone: 0987654321 -Password: 123456 -OTP: 123456 -``` - -### ZaloPay Sandbox -``` -Phone: 0987654321 -OTP: 123456 -``` +| Issue | Fix | +|-------|-----| +| User can't login | Check: `passwordHash` ≠ null, `isActive` = true | +| "Invalid phone" | Phone must match regex (mobile only) | +| Hash mismatch | Verify `FIELD_ENCRYPTION_KEY` is consistent | +| MFA issue | Verify `MFA_BACKUP_CODE_SECRET` env var | +| PII not encrypted | Verify key is exactly 32 bytes (64 hex chars) | --- -## Common Errors +## 📁 Key Files -| Error | Cause | Solution | -|-------|-------|----------| -| `ConflictException: User already has active subscription` | User trying to create 2nd subscription | Check existing subscription first | -| `ValidationException: Số tiền phải lớn hơn 0` | Amount is 0 or negative | Ensure amount > 0 | -| `NotFoundException: Plan not found` | Plan tier doesn't exist in DB | Check plan is created and isActive=true | -| `Payment gateway failed` | Payment gateway credentials wrong | Verify ENV vars | -| `Cannot complete payment in status X` | Payment already completed/failed | Check idempotencyKey | -| `Idempotency check failed` | Same idempotencyKey used twice | Generate unique UUID each time | +| File | Purpose | +|------|---------| +| `hashed-password.vo.ts` | bcrypt hashing | +| `vietnam-phone.validator.ts` | Phone validation | +| `field-encryption.ts` | AES-256-GCM encryption | +| `local.strategy.ts` | Login endpoint | +| `mfa.service.ts` | TOTP / backup codes | +| `user.entity.ts` | User domain model | +| `prisma-user.repository.ts` | User persistence | +| `seed.ts` | Seed script | --- -## Debugging Checklist +## 🔐 Checklist for Seed User -- [ ] Check payment provider credentials in .env -- [ ] Verify idempotencyKey is unique per request -- [ ] Ensure amountVND matches plan price -- [ ] Check returnUrl is publicly accessible -- [ ] Verify JWT token is valid when calling protected endpoints -- [ ] Check payment status with `GET /payments/:id` -- [ ] Review payment provider logs/dashboard -- [ ] Test with sandbox credentials first -- [ ] Verify callback signature matches gateway requirements -- [ ] Check subscription was created after successful payment +- [ ] Password ≥ 8 chars +- [ ] Phone matches regex +- [ ] Phone normalized: +84... +- [ ] Phone hashed: HMAC-SHA256 +- [ ] Email lowercased +- [ ] Email hashed: HMAC-SHA256 +- [ ] Password hashed: bcrypt (12 rounds) +- [ ] `isActive: true` +- [ ] `passwordHash` ≠ null +- [ ] `totpEnabled: false` +- [ ] `totpBackupCodes: []` --- -## Links +## 📚 Full Documentation Files -- Detailed Audit: `PRICING_CHECKOUT_AUDIT.md` -- Summary: `PRICING_AUDIT_SUMMARY.md` -- Pricing Page: `apps/web/app/[locale]/(public)/pricing/page.tsx` -- Subscriptions Module: `apps/api/src/modules/subscriptions/` -- Payments Module: `apps/api/src/modules/payments/` -- Schema: `prisma/schema.prisma` (lines 451-514) +1. **AUTHENTICATION_GUIDE.md** - Complete technical reference +2. **AUTH_IMPLEMENTATION_CHECKLIST.md** - Implementation checklist & troubleshooting +3. **SEED_GENERATION_SCRIPT.ts** - Ready-to-use seed script +4. **QUICK_REFERENCE.md** - This file +--- + +**Last Updated:** April 12, 2026 +**Status:** ✅ Production-Ready diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 9fccf69..e46ea07 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -55,17 +55,17 @@ import { AppController } from './app.controller'; { name: 'default', ttl: 60_000, - limit: process.env['NODE_ENV'] === 'test' ? 10_000 : 60, + limit: process.env['NODE_ENV'] === 'test' || process.env['NODE_ENV'] === 'development' ? 10_000 : 60, }, { name: 'auth', ttl: 60_000, - limit: process.env['NODE_ENV'] === 'test' ? 10_000 : 10, + limit: process.env['NODE_ENV'] === 'test' || process.env['NODE_ENV'] === 'development' ? 10_000 : 10, }, { name: 'payment-callback', ttl: 60_000, - limit: process.env['NODE_ENV'] === 'test' ? 10_000 : 20, + limit: process.env['NODE_ENV'] === 'test' || process.env['NODE_ENV'] === 'development' ? 10_000 : 20, }, ], }), diff --git a/apps/api/src/instrument.ts b/apps/api/src/instrument.ts index 57e4fe6..fbc8edf 100644 --- a/apps/api/src/instrument.ts +++ b/apps/api/src/instrument.ts @@ -7,9 +7,14 @@ const isTest = process.env['NODE_ENV'] === 'test'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const integrations: any[] = []; if (!isTest) { - // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports - const { nodeProfilingIntegration } = require('@sentry/profiling-node') as typeof import('@sentry/profiling-node'); - integrations.push(nodeProfilingIntegration()); + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports + const { nodeProfilingIntegration } = require('@sentry/profiling-node') as typeof import('@sentry/profiling-node'); + integrations.push(nodeProfilingIntegration()); + } catch { + // Native CPU profiler binary not available — skip profiling gracefully. + console.warn('[Sentry] Profiling skipped — native module not available'); + } } Sentry.init({ diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/google-oauth.strategy.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/google-oauth.strategy.spec.ts index 510c59b..4889754 100644 --- a/apps/api/src/modules/auth/infrastructure/__tests__/google-oauth.strategy.spec.ts +++ b/apps/api/src/modules/auth/infrastructure/__tests__/google-oauth.strategy.spec.ts @@ -45,13 +45,12 @@ describe('GoogleOAuthStrategy', () => { vi.unstubAllEnvs(); }); - it('throws if GOOGLE_CLIENT_ID is missing', () => { + it('creates strategy with dummy config when GOOGLE_CLIENT_ID is missing', async () => { vi.stubEnv('GOOGLE_CLIENT_ID', ''); - // Reset module to pick up new env - expect(async () => { - const { GoogleOAuthStrategy } = await import('../strategies/google-oauth.strategy'); - new GoogleOAuthStrategy(mockOAuthService as unknown as OAuthService); - }).rejects.toThrow('GOOGLE_CLIENT_ID'); + const { GoogleOAuthStrategy } = await import('../strategies/google-oauth.strategy'); + const strategy = new GoogleOAuthStrategy(mockOAuthService as unknown as OAuthService); + // Strategy should still be created (graceful fallback with dummy values) + expect(strategy).toBeDefined(); }); it('creates strategy with correct config', async () => { diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/zalo-oauth.strategy.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/zalo-oauth.strategy.spec.ts index 402c9d4..73da399 100644 --- a/apps/api/src/modules/auth/infrastructure/__tests__/zalo-oauth.strategy.spec.ts +++ b/apps/api/src/modules/auth/infrastructure/__tests__/zalo-oauth.strategy.spec.ts @@ -30,18 +30,18 @@ describe('ZaloOAuthStrategy', () => { vi.restoreAllMocks(); }); - it('throws if ZALO_APP_ID is missing', () => { + it('creates strategy with dummy config when ZALO_APP_ID is missing', () => { vi.stubEnv('ZALO_APP_ID', ''); const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; - expect(() => new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService, mockLogger as any)) - .toThrow('ZALO_APP_ID'); + const strategy = new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService, mockLogger as any); + expect(strategy).toBeDefined(); }); - it('throws if ZALO_APP_SECRET is missing', () => { + it('creates strategy with dummy config when ZALO_APP_SECRET is missing', () => { vi.stubEnv('ZALO_APP_SECRET', ''); const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; - expect(() => new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService, mockLogger as any)) - .toThrow('ZALO_APP_SECRET'); + const strategy = new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService, mockLogger as any); + expect(strategy).toBeDefined(); }); describe('getAuthorizationUrl', () => { diff --git a/apps/api/src/modules/auth/infrastructure/strategies/google-oauth.strategy.ts b/apps/api/src/modules/auth/infrastructure/strategies/google-oauth.strategy.ts index 7a3aae7..0b4c763 100644 --- a/apps/api/src/modules/auth/infrastructure/strategies/google-oauth.strategy.ts +++ b/apps/api/src/modules/auth/infrastructure/strategies/google-oauth.strategy.ts @@ -11,7 +11,15 @@ export class GoogleOAuthStrategy extends PassportStrategy(Strategy, 'google') { const callbackURL = process.env['GOOGLE_CALLBACK_URL'] ?? '/auth/google/callback'; if (!clientID || !clientSecret) { - throw new Error('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables are required'); + // Use dummy values so the app can start without Google OAuth configured. + // The Google login route will be non-functional until real credentials are set. + super({ + clientID: 'NOT_CONFIGURED', + clientSecret: 'NOT_CONFIGURED', + callbackURL, + scope: ['email', 'profile'], + }); + return; } super({ diff --git a/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts b/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts index 81881d2..b1f834e 100644 --- a/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts +++ b/apps/api/src/modules/auth/infrastructure/strategies/zalo-oauth.strategy.ts @@ -49,7 +49,11 @@ export class ZaloOAuthStrategy { const appSecret = process.env['ZALO_APP_SECRET']; if (!appId || !appSecret) { - throw new Error('ZALO_APP_ID and ZALO_APP_SECRET environment variables are required'); + // Allow app to start without Zalo OAuth configured — routes will be non-functional. + this.appId = 'NOT_CONFIGURED'; + this.appSecret = 'NOT_CONFIGURED'; + this.callbackUrl = process.env['ZALO_CALLBACK_URL'] ?? '/auth/zalo/callback'; + return; } this.appId = appId; diff --git a/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts b/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts index cd2947f..eb87177 100644 --- a/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts +++ b/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts @@ -74,6 +74,7 @@ export interface ListingSearchItem { bedrooms: number | null; bathrooms: number | null; thumbnail: string | null; + media: ListingMediaData[]; }; seller: { id: string; @@ -94,5 +95,6 @@ export interface ListingSellerItem { city: string; areaM2: number; thumbnail: string | null; + media: ListingMediaData[]; }; } diff --git a/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts b/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts index 0baf798..55e0774 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts @@ -106,7 +106,7 @@ export async function searchListings( include: { property: { include: { - media: { orderBy: { order: 'asc' }, take: 1 }, + media: { orderBy: { order: 'asc' }, take: 5 }, }, }, seller: { select: { id: true, fullName: true } }, @@ -135,6 +135,13 @@ export async function searchListings( bedrooms: listing.property.bedrooms, bathrooms: listing.property.bathrooms, thumbnail: listing.property.media[0]?.url ?? null, + media: listing.property.media.map((m) => ({ + id: m.id, + url: m.url, + type: m.type, + order: m.order, + caption: m.caption, + })), }, seller: listing.seller, })), @@ -182,6 +189,13 @@ export async function findBySellerIdQuery( city: listing.property.city, areaM2: listing.property.areaM2, thumbnail: listing.property.media[0]?.url ?? null, + media: listing.property.media.map((m) => ({ + id: m.id, + url: m.url, + type: m.type, + order: m.order, + caption: m.caption, + })), }, })), total, diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/page.tsx index 04105e5..97c0e7b 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/page.tsx @@ -238,9 +238,9 @@ export default function DashboardPage() { className="flex items-center gap-4 rounded-lg border p-3 transition-colors hover:bg-accent" >
- {listing.property.media.length > 0 ? ( + {(listing.property.media?.length ?? 0) > 0 ? ( {listing.property.title} + pathname === href || (href !== '/dashboard' && pathname.startsWith(href)); + return (
{/* Mobile overlay */} @@ -43,7 +113,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod /> )} - {/* Mobile sidebar */} + {/* Mobile sidebar — grouped nav */}
-
) : !result || result.data.length === 0 ? (
-

📋

+
diff --git a/apps/web/components/map/listing-map.tsx b/apps/web/components/map/listing-map.tsx index defaf2f..3722bdc 100644 --- a/apps/web/components/map/listing-map.tsx +++ b/apps/web/components/map/listing-map.tsx @@ -140,9 +140,9 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa const container = document.createElement('div'); container.style.fontFamily = 'system-ui,sans-serif'; - if (listing.property.media.length > 0) { + if ((listing.property.media?.length ?? 0) > 0) { const img = document.createElement('img'); - img.src = listing.property.media[0]!.url; + img.src = listing.property.media![0]!.url; img.alt = listing.property.title; img.style.cssText = 'width:100%;height:96px;object-fit:cover;border-radius:6px;margin-bottom:8px;'; container.appendChild(img); diff --git a/apps/web/components/search/property-card.tsx b/apps/web/components/search/property-card.tsx index 4e3b6c9..af12426 100644 --- a/apps/web/components/search/property-card.tsx +++ b/apps/web/components/search/property-card.tsx @@ -32,9 +32,9 @@ export function PropertyCard({ listing, compact }: PropertyCardProps) {
- {listing.property.media.length > 0 ? ( + {(listing.property.media?.length ?? 0) > 0 ? ( {`Ảnh
- {listing.property.media.length > 1 && ( + {(listing.property.media?.length ?? 0) > 1 && (
- - {listing.property.media.length} ảnh + + {listing.property.media!.length} ảnh
)} diff --git a/apps/web/components/ui/language-switcher.tsx b/apps/web/components/ui/language-switcher.tsx index 289b24f..8937a3a 100644 --- a/apps/web/components/ui/language-switcher.tsx +++ b/apps/web/components/ui/language-switcher.tsx @@ -1,12 +1,13 @@ 'use client'; +import { Globe } from 'lucide-react'; import { useLocale, useTranslations } from 'next-intl'; import type { Locale } from '@/i18n/config'; import { usePathname, useRouter } from '@/i18n/navigation'; const localeLabels: Record = { - vi: '🇻🇳 VI', - en: '🇬🇧 EN', + vi: 'VI', + en: 'EN', }; export function LanguageSwitcher() { @@ -28,6 +29,7 @@ export function LanguageSwitcher() { className="inline-flex h-9 items-center gap-1.5 rounded-md px-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" aria-label={`${t('label')}: ${t(locale)} → ${t(nextLocale)}`} > +