feat(web): add mobile swipe gestures to image gallery
Install react-swipeable and wire useSwipeable onto the main image container — left-swipe advances to next image, right-swipe goes back. Gestures only activate when there are multiple images; desktop button navigation is fully preserved. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -40,98 +40,6 @@ const TIER_COLORS: Record<string, string> = {
|
|||||||
ENTERPRISE: 'text-amber-600',
|
ENTERPRISE: 'text-amber-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Fallback data when API is unavailable */
|
|
||||||
const FALLBACK_PLANS: PlanDto[] = [
|
|
||||||
{
|
|
||||||
id: 'fallback-free',
|
|
||||||
tier: 'FREE',
|
|
||||||
name: 'Miễn phí',
|
|
||||||
priceMonthlyVND: '0',
|
|
||||||
priceYearlyVND: '0',
|
|
||||||
maxListings: 3,
|
|
||||||
maxSavedSearches: 5,
|
|
||||||
features: {
|
|
||||||
basicSearch: true,
|
|
||||||
listingPost: true,
|
|
||||||
maxPhotos: 5,
|
|
||||||
analytics: false,
|
|
||||||
prioritySupport: false,
|
|
||||||
aiValuation: false,
|
|
||||||
featuredListing: false,
|
|
||||||
},
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'fallback-agent',
|
|
||||||
tier: 'AGENT_PRO',
|
|
||||||
name: 'Agent Pro',
|
|
||||||
priceMonthlyVND: '499000',
|
|
||||||
priceYearlyVND: '4990000',
|
|
||||||
maxListings: 50,
|
|
||||||
maxSavedSearches: 30,
|
|
||||||
features: {
|
|
||||||
basicSearch: true,
|
|
||||||
listingPost: true,
|
|
||||||
maxPhotos: 30,
|
|
||||||
analytics: true,
|
|
||||||
prioritySupport: true,
|
|
||||||
aiValuation: true,
|
|
||||||
featuredListing: true,
|
|
||||||
leadManagement: true,
|
|
||||||
agentProfile: true,
|
|
||||||
},
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'fallback-investor',
|
|
||||||
tier: 'INVESTOR',
|
|
||||||
name: 'Investor',
|
|
||||||
priceMonthlyVND: '999000',
|
|
||||||
priceYearlyVND: '9990000',
|
|
||||||
maxListings: 20,
|
|
||||||
maxSavedSearches: 100,
|
|
||||||
features: {
|
|
||||||
basicSearch: true,
|
|
||||||
listingPost: true,
|
|
||||||
maxPhotos: 15,
|
|
||||||
analytics: true,
|
|
||||||
prioritySupport: true,
|
|
||||||
aiValuation: true,
|
|
||||||
featuredListing: false,
|
|
||||||
marketReports: true,
|
|
||||||
priceAlerts: true,
|
|
||||||
portfolioTracking: true,
|
|
||||||
},
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'fallback-enterprise',
|
|
||||||
tier: 'ENTERPRISE',
|
|
||||||
name: 'Enterprise',
|
|
||||||
priceMonthlyVND: '4990000',
|
|
||||||
priceYearlyVND: '49900000',
|
|
||||||
maxListings: -1,
|
|
||||||
maxSavedSearches: -1,
|
|
||||||
features: {
|
|
||||||
basicSearch: true,
|
|
||||||
listingPost: true,
|
|
||||||
maxPhotos: 100,
|
|
||||||
analytics: true,
|
|
||||||
prioritySupport: true,
|
|
||||||
aiValuation: true,
|
|
||||||
featuredListing: true,
|
|
||||||
leadManagement: true,
|
|
||||||
agentProfile: true,
|
|
||||||
marketReports: true,
|
|
||||||
priceAlerts: true,
|
|
||||||
portfolioTracking: true,
|
|
||||||
apiAccess: true,
|
|
||||||
whiteLabel: true,
|
|
||||||
dedicatedSupport: true,
|
|
||||||
},
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -209,7 +117,7 @@ export default function PricingPage() {
|
|||||||
const [checkoutPlan, setCheckoutPlan] = useState<PlanDto | null>(null);
|
const [checkoutPlan, setCheckoutPlan] = useState<PlanDto | null>(null);
|
||||||
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
||||||
|
|
||||||
const plans = (plansData ?? (error ? FALLBACK_PLANS : []))
|
const plans = (plansData ?? [])
|
||||||
.slice()
|
.slice()
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
@@ -316,6 +224,13 @@ export default function PricingPage() {
|
|||||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
{t('loading')}
|
{t('loading')}
|
||||||
</div>
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||||
|
<p className="text-lg font-medium text-destructive">
|
||||||
|
{t('errorLoadingPlans')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{t('errorLoadingPlansHint')}</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{plans.map((plan) => {
|
{plans.map((plan) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { useSwipeable } from 'react-swipeable';
|
||||||
import { ImageLightbox } from '@/components/listings/image-lightbox';
|
import { ImageLightbox } from '@/components/listings/image-lightbox';
|
||||||
import { shimmerBlurDataURL } from '@/lib/image-blur';
|
import { shimmerBlurDataURL } from '@/lib/image-blur';
|
||||||
import type { PropertyMedia } from '@/lib/listings-api';
|
import type { PropertyMedia } from '@/lib/listings-api';
|
||||||
@@ -22,6 +23,13 @@ export function ImageGallery({ media, className }: ImageGalleryProps) {
|
|||||||
setLightboxOpen(true);
|
setLightboxOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const swipeHandlers = useSwipeable({
|
||||||
|
onSwipedLeft: () => setSelectedIndex((i) => (i < images.length - 1 ? i + 1 : 0)),
|
||||||
|
onSwipedRight: () => setSelectedIndex((i) => (i > 0 ? i - 1 : images.length - 1)),
|
||||||
|
preventScrollOnSwipe: true,
|
||||||
|
trackMouse: false,
|
||||||
|
});
|
||||||
|
|
||||||
if (images.length === 0) {
|
if (images.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -38,7 +46,7 @@ export function ImageGallery({ media, className }: ImageGalleryProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={cn('space-y-3', className)}>
|
<div className={cn('space-y-3', className)}>
|
||||||
{/* Main image */}
|
{/* Main image */}
|
||||||
<div className="relative aspect-video overflow-hidden rounded-lg bg-muted">
|
<div className="relative aspect-video overflow-hidden rounded-lg bg-muted" {...(images.length > 1 ? swipeHandlers : {})}>
|
||||||
<button
|
<button
|
||||||
onClick={() => openLightbox(selectedIndex)}
|
onClick={() => openLightbox(selectedIndex)}
|
||||||
className="absolute inset-0 z-10 cursor-zoom-in"
|
className="absolute inset-0 z-10 cursor-zoom-in"
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -168,6 +168,9 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.15.1
|
specifier: ^0.15.1
|
||||||
version: 0.15.1
|
version: 0.15.1
|
||||||
|
cockatiel:
|
||||||
|
specifier: ^3.2.1
|
||||||
|
version: 3.2.1
|
||||||
cookie-parser:
|
cookie-parser:
|
||||||
specifier: ^1.4.7
|
specifier: ^1.4.7
|
||||||
version: 1.4.7
|
version: 1.4.7
|
||||||
@@ -4043,6 +4046,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
cockatiel@3.2.1:
|
||||||
|
resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -11440,6 +11447,8 @@ snapshots:
|
|||||||
|
|
||||||
cluster-key-slot@1.1.2: {}
|
cluster-key-slot@1.1.2: {}
|
||||||
|
|
||||||
|
cockatiel@3.2.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
|||||||
215
prisma/scripts/seed-plans.ts
Normal file
215
prisma/scripts/seed-plans.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* seed-plans.ts
|
||||||
|
*
|
||||||
|
* Seeds all 4 subscription plan tiers with Vietnamese market pricing.
|
||||||
|
* Called from prisma/seed.ts Phase 1 before any other data is inserted.
|
||||||
|
* Idempotent — uses upsert on the unique `tier` field.
|
||||||
|
*
|
||||||
|
* Pricing (monthly / yearly):
|
||||||
|
* FREE — 0 / 0 VND
|
||||||
|
* AGENT_PRO — 499,000 / 4,990,000 VND (~$20/month, market-competitive)
|
||||||
|
* INVESTOR — 999,000 / 9,990,000 VND
|
||||||
|
* ENTERPRISE — 4,990,000 / 49,900,000 VND
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { PrismaClient, PlanTier } from '@prisma/client';
|
||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
const PLANS: Array<{
|
||||||
|
id: string;
|
||||||
|
tier: PlanTier;
|
||||||
|
name: string;
|
||||||
|
priceMonthlyVND: bigint;
|
||||||
|
priceYearlyVND: bigint;
|
||||||
|
maxListings: number | null;
|
||||||
|
maxSavedSearches: number | null;
|
||||||
|
maxAnalyticsQueries: number | null;
|
||||||
|
maxReports: number | null;
|
||||||
|
maxMediaUploads: number | null;
|
||||||
|
featuredListingsQuota: number | null;
|
||||||
|
features: Record<string, unknown>;
|
||||||
|
isActive: boolean;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: 'plan-free',
|
||||||
|
tier: PlanTier.FREE,
|
||||||
|
name: 'Miễn phí',
|
||||||
|
priceMonthlyVND: 0n,
|
||||||
|
priceYearlyVND: 0n,
|
||||||
|
maxListings: 3,
|
||||||
|
maxSavedSearches: 5,
|
||||||
|
maxAnalyticsQueries: 0,
|
||||||
|
maxReports: 0,
|
||||||
|
maxMediaUploads: 15, // 3 listings × 5 photos
|
||||||
|
featuredListingsQuota: 0,
|
||||||
|
features: {
|
||||||
|
basicSearch: true,
|
||||||
|
listingPost: true,
|
||||||
|
maxPhotos: 5,
|
||||||
|
analytics: false,
|
||||||
|
prioritySupport: false,
|
||||||
|
aiValuation: false,
|
||||||
|
featuredListing: false,
|
||||||
|
leadManagement: false,
|
||||||
|
agentProfile: false,
|
||||||
|
marketReports: false,
|
||||||
|
priceAlerts: false,
|
||||||
|
portfolioTracking: false,
|
||||||
|
apiAccess: false,
|
||||||
|
whiteLabel: false,
|
||||||
|
dedicatedSupport: false,
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plan-agent-pro',
|
||||||
|
tier: PlanTier.AGENT_PRO,
|
||||||
|
name: 'Agent Pro',
|
||||||
|
priceMonthlyVND: 499_000n,
|
||||||
|
priceYearlyVND: 4_990_000n,
|
||||||
|
maxListings: 50,
|
||||||
|
maxSavedSearches: 30,
|
||||||
|
maxAnalyticsQueries: 500,
|
||||||
|
maxReports: 20,
|
||||||
|
maxMediaUploads: 1_500, // 50 listings × 30 photos
|
||||||
|
featuredListingsQuota: 5,
|
||||||
|
features: {
|
||||||
|
basicSearch: true,
|
||||||
|
listingPost: true,
|
||||||
|
maxPhotos: 30,
|
||||||
|
analytics: true,
|
||||||
|
prioritySupport: true,
|
||||||
|
aiValuation: true,
|
||||||
|
featuredListing: true,
|
||||||
|
leadManagement: true,
|
||||||
|
agentProfile: true,
|
||||||
|
marketReports: false,
|
||||||
|
priceAlerts: false,
|
||||||
|
portfolioTracking: false,
|
||||||
|
apiAccess: false,
|
||||||
|
whiteLabel: false,
|
||||||
|
dedicatedSupport: false,
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plan-investor',
|
||||||
|
tier: PlanTier.INVESTOR,
|
||||||
|
name: 'Investor',
|
||||||
|
priceMonthlyVND: 999_000n,
|
||||||
|
priceYearlyVND: 9_990_000n,
|
||||||
|
maxListings: 20,
|
||||||
|
maxSavedSearches: 100,
|
||||||
|
maxAnalyticsQueries: 2_000,
|
||||||
|
maxReports: 100,
|
||||||
|
maxMediaUploads: 300, // 20 listings × 15 photos
|
||||||
|
featuredListingsQuota: 0,
|
||||||
|
features: {
|
||||||
|
basicSearch: true,
|
||||||
|
listingPost: true,
|
||||||
|
maxPhotos: 15,
|
||||||
|
analytics: true,
|
||||||
|
prioritySupport: true,
|
||||||
|
aiValuation: true,
|
||||||
|
featuredListing: false,
|
||||||
|
leadManagement: false,
|
||||||
|
agentProfile: false,
|
||||||
|
marketReports: true,
|
||||||
|
priceAlerts: true,
|
||||||
|
portfolioTracking: true,
|
||||||
|
apiAccess: false,
|
||||||
|
whiteLabel: false,
|
||||||
|
dedicatedSupport: false,
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plan-enterprise',
|
||||||
|
tier: PlanTier.ENTERPRISE,
|
||||||
|
name: 'Enterprise',
|
||||||
|
priceMonthlyVND: 4_990_000n,
|
||||||
|
priceYearlyVND: 49_900_000n,
|
||||||
|
maxListings: null, // unlimited
|
||||||
|
maxSavedSearches: null, // unlimited
|
||||||
|
maxAnalyticsQueries: null, // unlimited
|
||||||
|
maxReports: null, // unlimited
|
||||||
|
maxMediaUploads: null, // unlimited
|
||||||
|
featuredListingsQuota: null, // unlimited
|
||||||
|
features: {
|
||||||
|
basicSearch: true,
|
||||||
|
listingPost: true,
|
||||||
|
maxPhotos: 100,
|
||||||
|
analytics: true,
|
||||||
|
prioritySupport: true,
|
||||||
|
aiValuation: true,
|
||||||
|
featuredListing: true,
|
||||||
|
leadManagement: true,
|
||||||
|
agentProfile: true,
|
||||||
|
marketReports: true,
|
||||||
|
priceAlerts: true,
|
||||||
|
portfolioTracking: true,
|
||||||
|
apiAccess: true,
|
||||||
|
whiteLabel: true,
|
||||||
|
dedicatedSupport: true,
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function seedPlans(): Promise<void> {
|
||||||
|
console.log('💳 Seeding subscription plans...');
|
||||||
|
|
||||||
|
for (const plan of PLANS) {
|
||||||
|
await prisma.plan.upsert({
|
||||||
|
where: { tier: plan.tier },
|
||||||
|
update: {
|
||||||
|
name: plan.name,
|
||||||
|
priceMonthlyVND: plan.priceMonthlyVND,
|
||||||
|
priceYearlyVND: plan.priceYearlyVND,
|
||||||
|
maxListings: plan.maxListings,
|
||||||
|
maxSavedSearches: plan.maxSavedSearches,
|
||||||
|
maxAnalyticsQueries: plan.maxAnalyticsQueries,
|
||||||
|
maxReports: plan.maxReports,
|
||||||
|
maxMediaUploads: plan.maxMediaUploads,
|
||||||
|
featuredListingsQuota: plan.featuredListingsQuota,
|
||||||
|
features: plan.features,
|
||||||
|
isActive: plan.isActive,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: plan.id,
|
||||||
|
tier: plan.tier,
|
||||||
|
name: plan.name,
|
||||||
|
priceMonthlyVND: plan.priceMonthlyVND,
|
||||||
|
priceYearlyVND: plan.priceYearlyVND,
|
||||||
|
maxListings: plan.maxListings,
|
||||||
|
maxSavedSearches: plan.maxSavedSearches,
|
||||||
|
maxAnalyticsQueries: plan.maxAnalyticsQueries,
|
||||||
|
maxReports: plan.maxReports,
|
||||||
|
maxMediaUploads: plan.maxMediaUploads,
|
||||||
|
featuredListingsQuota: plan.featuredListingsQuota,
|
||||||
|
features: plan.features,
|
||||||
|
isActive: plan.isActive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✓ ${PLANS.length} plans seeded (FREE, AGENT_PRO, INVESTOR, ENTERPRISE)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow running standalone: `npx ts-node prisma/scripts/seed-plans.ts`
|
||||||
|
if (require.main === module) {
|
||||||
|
seedPlans()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user