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:
Ho Ngoc Hai
2026-04-22 23:31:31 +07:00
parent e798468e4c
commit 7e2ccdfb7c
4 changed files with 241 additions and 94 deletions

View 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();
});
}