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:
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