feat(web): add social share component and wire price history into listing detail
- Add SocialShare component with copy-link, Facebook, Zalo, and QR code sharing - Integrate price history chart and social sharing into listing detail page - Register new price history and feature-listing handlers in ListingsModule Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -2,10 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { CreateListingHandler } from './application/commands/create-listing/create-listing.handler';
|
||||
import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler';
|
||||
import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-listing.handler';
|
||||
import { UpdateListingHandler } from './application/commands/update-listing/update-listing.handler';
|
||||
import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler';
|
||||
import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler';
|
||||
import { ActivateFeaturedListingHandler } from './application/event-handlers/activate-featured-listing.handler';
|
||||
import { RecordPriceHistoryHandler } from './application/event-handlers/record-price-history.handler';
|
||||
import { GetListingHandler } from './application/queries/get-listing/get-listing.handler';
|
||||
import { GetPendingModerationHandler } from './application/queries/get-pending-moderation/get-pending-moderation.handler';
|
||||
@@ -25,6 +27,7 @@ import { ListingsController } from './presentation/controllers/listings.controll
|
||||
|
||||
const CommandHandlers = [
|
||||
CreateListingHandler,
|
||||
FeatureListingHandler,
|
||||
UpdateListingHandler,
|
||||
UpdateListingStatusHandler,
|
||||
UploadMediaHandler,
|
||||
@@ -39,6 +42,7 @@ const QueryHandlers = [
|
||||
];
|
||||
|
||||
const EventHandlers = [
|
||||
ActivateFeaturedListingHandler,
|
||||
RecordPriceHistoryHandler,
|
||||
];
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import { SearchListingsQuery } from '../../application/queries/search-listings/s
|
||||
import type { ListingDetailData, ListingSearchItem } from '../../domain/repositories/listing-read.dto';
|
||||
import type { PaginatedResult } from '../../domain/repositories/listing.repository';
|
||||
import type { CreateListingDto } from '../dto/create-listing.dto';
|
||||
import type { FeatureListingDto } from '../dto/feature-listing.dto';
|
||||
import type { ModerateListingDto } from '../dto/moderate-listing.dto';
|
||||
import { type SearchListingsDto } from '../dto/search-listings.dto';
|
||||
import type { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
|
||||
@@ -297,4 +298,24 @@ export class ListingsController {
|
||||
new ModerateListingCommand(id, user.sub, dto.action, dto.moderationScore, dto.notes),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Feature a listing (creates payment for featured boost)' })
|
||||
@ApiParam({ name: 'id', description: 'Listing UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Payment created for featured listing' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid package or listing not ACTIVE' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 403, description: 'Not the seller or assigned agent' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post(':id/feature')
|
||||
async featureListing(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: FeatureListingDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Ip() ip: string,
|
||||
): Promise<FeatureListingResult> {
|
||||
return this.commandBus.execute(
|
||||
new FeatureListingCommand(id, user.sub, dto.package, dto.provider, dto.returnUrl, ip),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as React from 'react';
|
||||
import { AddToCompareButton } from '@/components/comparison/add-to-compare-button';
|
||||
import { ImageGallery } from '@/components/listings/image-gallery';
|
||||
import { InquiryModal } from '@/components/listings/inquiry-modal';
|
||||
import { SocialShare } from '@/components/listings/social-share';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -230,6 +231,16 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Social sharing + QR code */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<SocialShare
|
||||
listingId={listing.id}
|
||||
listingTitle={property.title}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* AI Estimate */}
|
||||
<AiEstimateButton listingId={listing.id} />
|
||||
|
||||
|
||||
139
apps/web/components/listings/social-share.tsx
Normal file
139
apps/web/components/listings/social-share.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Social Share Buttons — Facebook, Zalo, Copy Link, QR Code
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SocialShareProps {
|
||||
listingId: string;
|
||||
listingTitle: string;
|
||||
}
|
||||
|
||||
const apiUrl = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
|
||||
|
||||
export function SocialShare({ listingId, listingTitle }: SocialShareProps) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const [showQr, setShowQr] = React.useState(false);
|
||||
|
||||
const listingUrl = typeof window !== 'undefined'
|
||||
? window.location.href
|
||||
: '';
|
||||
|
||||
const handleCopyLink = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(listingUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = listingUrl;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [listingUrl]);
|
||||
|
||||
const handleShareFacebook = React.useCallback(() => {
|
||||
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(listingUrl)}`;
|
||||
window.open(url, '_blank', 'noopener,noreferrer,width=600,height=400');
|
||||
}, [listingUrl]);
|
||||
|
||||
const handleShareZalo = React.useCallback(() => {
|
||||
const url = `https://zalo.me/share?url=${encodeURIComponent(listingUrl)}&title=${encodeURIComponent(listingTitle)}`;
|
||||
window.open(url, '_blank', 'noopener,noreferrer,width=600,height=400');
|
||||
}, [listingUrl, listingTitle]);
|
||||
|
||||
const qrCodeUrl = `${apiUrl}/listings/${listingId}/qr-code`;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">Chia sẻ</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Facebook */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={handleShareFacebook}
|
||||
aria-label="Chia sẻ lên Facebook"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
|
||||
</svg>
|
||||
Facebook
|
||||
</Button>
|
||||
|
||||
{/* Zalo */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={handleShareZalo}
|
||||
aria-label="Chia sẻ lên Zalo"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="24" r="24" fill="#0068FF" />
|
||||
<path d="M12.5 17.8h10.2c.3 0 .5.1.6.3.1.2.1.5-.1.7l-7.8 11.4h7.7c.4 0 .7.3.7.7v1.4c0 .4-.3.7-.7.7H12.5c-.3 0-.5-.1-.6-.3-.1-.2-.1-.5.1-.7l7.8-11.4h-7.3c-.4 0-.7-.3-.7-.7v-1.4c0-.4.3-.7.7-.7zm25.4 0c.4 0 .7.3.7.7v13.8c0 .4-.3.7-.7.7h-1.4c-.4 0-.7-.3-.7-.7V18.5c0-.4.3-.7.7-.7h1.4z" fill="white" />
|
||||
</svg>
|
||||
Zalo
|
||||
</Button>
|
||||
|
||||
{/* Copy link */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={handleCopyLink}
|
||||
aria-label="Sao chép liên kết"
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="h-4 w-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
)}
|
||||
{copied ? 'Đã sao chép!' : 'Sao chép liên kết'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* QR Code toggle */}
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 text-muted-foreground"
|
||||
onClick={() => setShowQr(!showQr)}
|
||||
aria-label={showQr ? 'Ẩn mã QR' : 'Hiện mã QR'}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
||||
</svg>
|
||||
{showQr ? 'Ẩn mã QR' : 'Mã QR'}
|
||||
</Button>
|
||||
|
||||
{showQr && (
|
||||
<div className="mt-2 flex justify-center rounded-lg border bg-white p-3">
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt={`Mã QR cho ${listingTitle}`}
|
||||
width={200}
|
||||
height={200}
|
||||
className="h-[200px] w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user