From 7e2ccdfb7cc77143961c20a8787e6c6cddbf20c7 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 22 Apr 2026 23:31:31 +0700 Subject: [PATCH] feat(web): add mobile swipe gestures to image gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/[locale]/(public)/pricing/page.tsx | 101 +------- .../web/components/listings/image-gallery.tsx | 10 +- pnpm-lock.yaml | 9 + prisma/scripts/seed-plans.ts | 215 ++++++++++++++++++ 4 files changed, 241 insertions(+), 94 deletions(-) create mode 100644 prisma/scripts/seed-plans.ts diff --git a/apps/web/app/[locale]/(public)/pricing/page.tsx b/apps/web/app/[locale]/(public)/pricing/page.tsx index 841e70a..186b934 100644 --- a/apps/web/app/[locale]/(public)/pricing/page.tsx +++ b/apps/web/app/[locale]/(public)/pricing/page.tsx @@ -40,98 +40,6 @@ const TIER_COLORS: Record = { 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 @@ -209,7 +117,7 @@ export default function PricingPage() { const [checkoutPlan, setCheckoutPlan] = useState(null); const [checkoutOpen, setCheckoutOpen] = useState(false); - const plans = (plansData ?? (error ? FALLBACK_PLANS : [])) + const plans = (plansData ?? []) .slice() .sort( (a, b) => @@ -316,6 +224,13 @@ export default function PricingPage() {
{t('loading')}
+ ) : error ? ( +
+

+ {t('errorLoadingPlans')} +

+

{t('errorLoadingPlansHint')}

+
) : (
{plans.map((plan) => { diff --git a/apps/web/components/listings/image-gallery.tsx b/apps/web/components/listings/image-gallery.tsx index fb1627b..343f788 100644 --- a/apps/web/components/listings/image-gallery.tsx +++ b/apps/web/components/listings/image-gallery.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import * as React from 'react'; +import { useSwipeable } from 'react-swipeable'; import { ImageLightbox } from '@/components/listings/image-lightbox'; import { shimmerBlurDataURL } from '@/lib/image-blur'; import type { PropertyMedia } from '@/lib/listings-api'; @@ -22,6 +23,13 @@ export function ImageGallery({ media, className }: ImageGalleryProps) { 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) { return (
{/* Main image */} -
+
1 ? swipeHandlers : {})}>