Stripe Integration
This commit is contained in:
parent
056db7b199
commit
48bff89526
|
|
@ -34,6 +34,8 @@
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@nestjs/serve-static": "^4.0.1",
|
"@nestjs/serve-static": "^4.0.1",
|
||||||
|
"@types/stripe": "^8.0.417",
|
||||||
|
"body-parser": "^1.20.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-orm": "^0.32.0",
|
"drizzle-orm": "^0.32.0",
|
||||||
|
|
@ -55,6 +57,7 @@
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
|
"stripe": "^16.8.0",
|
||||||
"tsx": "^4.16.2",
|
"tsx": "^4.16.2",
|
||||||
"urlcat": "^3.1.0",
|
"urlcat": "^3.1.0",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
|
|
@ -114,4 +117,4 @@
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ export class AiService {
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `Please create unformatted JSON Object from a user input.
|
content: `Please create unformatted JSON Object from a user input.
|
||||||
The type must be: ${JSON.stringify(businessListingCriteriaStructure)}.
|
The type must be: ${JSON.stringify(businessListingCriteriaStructure)}.
|
||||||
If location details available please fill city, county and state as State Code`,
|
If location details available please fill city and state as State Code and only county if explicitly mentioned`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
|
|
@ -77,6 +77,8 @@ export class AiService {
|
||||||
],
|
],
|
||||||
model: 'llama-3.1-70b-versatile',
|
model: 'llama-3.1-70b-versatile',
|
||||||
//model: 'llama-3.1-8b-instant',
|
//model: 'llama-3.1-8b-instant',
|
||||||
|
// model: 'mixtral-8x7b-32768',
|
||||||
|
//model: 'gemma2-9b-it',
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
max_tokens: 300,
|
max_tokens: 300,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { LogController } from './log/log.controller.js';
|
||||||
import { LogModule } from './log/log.module.js';
|
import { LogModule } from './log/log.module.js';
|
||||||
|
|
||||||
import { MailModule } from './mail/mail.module.js';
|
import { MailModule } from './mail/mail.module.js';
|
||||||
|
import { PaymentModule } from './payment/payment.module.js';
|
||||||
import { RequestDurationMiddleware } from './request-duration/request-duration.middleware.js';
|
import { RequestDurationMiddleware } from './request-duration/request-duration.middleware.js';
|
||||||
import { SelectOptionsModule } from './select-options/select-options.module.js';
|
import { SelectOptionsModule } from './select-options/select-options.module.js';
|
||||||
import { UserModule } from './user/user.module.js';
|
import { UserModule } from './user/user.module.js';
|
||||||
|
|
@ -79,6 +80,7 @@ loadEnvFiles();
|
||||||
PassportModule,
|
PassportModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
LogModule,
|
LogModule,
|
||||||
|
PaymentModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, LogController],
|
controllers: [AppController, LogController],
|
||||||
providers: [AppService, FileService],
|
providers: [AppService, FileService],
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ fs.ensureDirSync(`./pictures/property`);
|
||||||
//User
|
//User
|
||||||
for (let index = 0; index < usersData.length; index++) {
|
for (let index = 0; index < usersData.length; index++) {
|
||||||
const userData = usersData[index];
|
const userData = usersData[index];
|
||||||
const user: User = createDefaultUser('', '', '');
|
const user: User = createDefaultUser('', '', '', null);
|
||||||
user.licensedIn = [];
|
user.licensedIn = [];
|
||||||
userData.licensedIn.forEach(l => {
|
userData.licensedIn.forEach(l => {
|
||||||
console.log(l['value'], l['name']);
|
console.log(l['value'], l['name']);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export const genderEnum = pgEnum('gender', ['male', 'female']);
|
||||||
export const customerTypeEnum = pgEnum('customerType', ['buyer', 'professional']);
|
export const customerTypeEnum = pgEnum('customerType', ['buyer', 'professional']);
|
||||||
export const customerSubTypeEnum = pgEnum('customerSubType', ['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
|
export const customerSubTypeEnum = pgEnum('customerSubType', ['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
|
||||||
export const listingsCategoryEnum = pgEnum('listingsCategory', ['commercialProperty', 'business']);
|
export const listingsCategoryEnum = pgEnum('listingsCategory', ['commercialProperty', 'business']);
|
||||||
|
export const subscriptionTypeEnum = pgEnum('subscriptionType', ['free', 'professional', 'broker']);
|
||||||
|
|
||||||
export const users = pgTable('users', {
|
export const users = pgTable('users', {
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||||
|
|
@ -30,6 +31,11 @@ export const users = pgTable('users', {
|
||||||
updated: timestamp('updated'),
|
updated: timestamp('updated'),
|
||||||
latitude: doublePrecision('latitude'),
|
latitude: doublePrecision('latitude'),
|
||||||
longitude: doublePrecision('longitude'),
|
longitude: doublePrecision('longitude'),
|
||||||
|
stripeCustomerId: text('stripeCustomerId'),
|
||||||
|
subscriptionId: text('subscriptionId'),
|
||||||
|
planActive: boolean('planActive').default(false),
|
||||||
|
planExpires: timestamp('planExpires'),
|
||||||
|
subscriptionPlan: subscriptionTypeEnum('subscriptionType'),
|
||||||
// embedding: vector('embedding', { dimensions: 1536 }),
|
// embedding: vector('embedding', { dimensions: 1536 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import path, { join } from 'path';
|
import path, { join } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
import { SenderSchema } from '../models/db.model.js';
|
import { SenderSchema, User } from '../models/db.model.js';
|
||||||
import { ErrorResponse, MailInfo, isEmpty } from '../models/main.model.js';
|
import { ErrorResponse, MailInfo, isEmpty } from '../models/main.model.js';
|
||||||
import { UserService } from '../user/user.service.js';
|
import { UserService } from '../user/user.service.js';
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
|
@ -81,4 +81,19 @@ export class MailService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async sendSubscriptionConfirmation(user: User): Promise<void> {
|
||||||
|
await this.mailerService.sendMail({
|
||||||
|
to: 'support@bizmatch.net',
|
||||||
|
from: `"Bizmatch Support Team" <info@bizmatch.net>`,
|
||||||
|
subject: `Subscription Confirmation`,
|
||||||
|
//template: './inquiry', // `.hbs` extension is appended automatically
|
||||||
|
template: join(__dirname, '../..', 'mail/templates/subscriptionConfirmation.hbs'),
|
||||||
|
context: {
|
||||||
|
// ✏️ filling curly brackets with content
|
||||||
|
firstname: user.firstname,
|
||||||
|
lastname: user.lastname,
|
||||||
|
subscriptionPlan: user.subscriptionPlan,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Subscription Confirmation</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #333333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #dddddd;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.content p {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.content .plan-info {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #0056b3;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 30px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Subscription Confirmation</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Dear {{firstname}} {{lastname}},</p>
|
||||||
|
|
||||||
|
<p>Thank you for subscribing to our service! We are thrilled to have you on board.</p>
|
||||||
|
|
||||||
|
<p>Your subscription details are as follows:</p>
|
||||||
|
|
||||||
|
<p><span class="plan-info">{{#if (eq subscriptionPlan "professional")}}Professional (CPA, Attorney, Title Company) Plan{{else if (eq subscriptionPlan "broker")}}Business Broker Plan{{/if}}</span></p>
|
||||||
|
|
||||||
|
<p>If you have any questions or need further assistance, please feel free to contact our support team at any time.</p>
|
||||||
|
|
||||||
|
<p>Thank you for choosing Bizmatch!</p>
|
||||||
|
|
||||||
|
<p>Best regards,</p>
|
||||||
|
<p>The Bizmatch Support Team</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© 2024 Bizmatch. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import bodyParser from 'body-parser';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from './app.module.js';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const server = express();
|
const server = express();
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.use('/bizmatch/payment/webhook', bodyParser.raw({ type: 'application/json' }));
|
||||||
app.setGlobalPrefix('bizmatch');
|
app.setGlobalPrefix('bizmatch');
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: '*',
|
origin: '*',
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export type ListingsCategory = 'commercialProperty' | 'business';
|
||||||
|
|
||||||
export const GenderEnum = z.enum(['male', 'female']);
|
export const GenderEnum = z.enum(['male', 'female']);
|
||||||
export const CustomerTypeEnum = z.enum(['buyer', 'professional']);
|
export const CustomerTypeEnum = z.enum(['buyer', 'professional']);
|
||||||
|
export const SubscriptionTypeEnum = z.enum(['free', 'professional', 'broker']);
|
||||||
export const CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
|
export const CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
|
||||||
export const ListingsCategoryEnum = z.enum(['commercialProperty', 'business']);
|
export const ListingsCategoryEnum = z.enum(['commercialProperty', 'business']);
|
||||||
const PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']);
|
const PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']);
|
||||||
|
|
@ -156,6 +157,11 @@ export const UserSchema = z
|
||||||
customerSubType: CustomerSubTypeEnum.optional().nullable(),
|
customerSubType: CustomerSubTypeEnum.optional().nullable(),
|
||||||
created: z.date().optional().nullable(),
|
created: z.date().optional().nullable(),
|
||||||
updated: z.date().optional().nullable(),
|
updated: z.date().optional().nullable(),
|
||||||
|
stripeCustomerId: z.string().optional().nullable(),
|
||||||
|
subscriptionId: z.string().optional().nullable(),
|
||||||
|
planActive: z.boolean().optional().nullable(),
|
||||||
|
planExpires: z.date().optional().nullable(),
|
||||||
|
subscriptionPlan: SubscriptionTypeEnum.optional().nullable(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.customerType === 'professional') {
|
if (data.customerType === 'professional') {
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,10 @@ export interface ModalResult {
|
||||||
accepted: boolean;
|
accepted: boolean;
|
||||||
criteria?: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
|
criteria?: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
|
||||||
}
|
}
|
||||||
|
export interface Checkout {
|
||||||
|
priceId: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
export function isEmpty(value: any): boolean {
|
export function isEmpty(value: any): boolean {
|
||||||
// Check for undefined or null
|
// Check for undefined or null
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
|
|
@ -294,7 +298,7 @@ export interface ValidationMessage {
|
||||||
field: string;
|
field: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
export function createDefaultUser(email: string, firstname: string, lastname: string): User {
|
export function createDefaultUser(email: string, firstname: string, lastname: string, subscriptionPlan: 'free' | 'professional' | 'broker'): User {
|
||||||
return {
|
return {
|
||||||
id: undefined,
|
id: undefined,
|
||||||
email,
|
email,
|
||||||
|
|
@ -316,6 +320,11 @@ export function createDefaultUser(email: string, firstname: string, lastname: st
|
||||||
customerSubType: null,
|
customerSubType: null,
|
||||||
created: new Date(),
|
created: new Date(),
|
||||||
updated: new Date(),
|
updated: new Date(),
|
||||||
|
stripeCustomerId: null,
|
||||||
|
subscriptionId: null,
|
||||||
|
planActive: false,
|
||||||
|
planExpires: null,
|
||||||
|
subscriptionPlan: subscriptionPlan,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function createDefaultCommercialPropertyListing(): CommercialPropertyListing {
|
export function createDefaultCommercialPropertyListing(): CommercialPropertyListing {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Body, Controller, HttpException, HttpStatus, Post, Req, Res } from '@nestjs/common';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { Checkout } from 'src/models/main.model.js';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import { PaymentService } from './payment.service.js';
|
||||||
|
|
||||||
|
@Controller('payment')
|
||||||
|
export class PaymentController {
|
||||||
|
constructor(private readonly paymentService: PaymentService) {}
|
||||||
|
|
||||||
|
// @Post()
|
||||||
|
// async createSubscription(@Body() subscriptionData: any) {
|
||||||
|
// return this.paymentService.createSubscription(subscriptionData);
|
||||||
|
// }
|
||||||
|
@Post('create-checkout-session')
|
||||||
|
async calculateTax(@Body() checkout: Checkout) {
|
||||||
|
return this.paymentService.checkout(checkout);
|
||||||
|
}
|
||||||
|
@Post('webhook')
|
||||||
|
async handleWebhook(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||||
|
const signature = req.headers['stripe-signature'] as string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = await this.paymentService.constructEvent(req.body, signature);
|
||||||
|
|
||||||
|
if (event.type === 'checkout.session.completed') {
|
||||||
|
await this.paymentService.handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send('Webhook received');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Webhook Error: ${error.message}`);
|
||||||
|
throw new HttpException('Webhook Error', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DrizzleModule } from '../drizzle/drizzle.module.js';
|
||||||
|
import { FileService } from '../file/file.service.js';
|
||||||
|
import { GeoService } from '../geo/geo.service.js';
|
||||||
|
import { MailModule } from '../mail/mail.module.js';
|
||||||
|
import { MailService } from '../mail/mail.service.js';
|
||||||
|
import { UserModule } from '../user/user.module.js';
|
||||||
|
import { UserService } from '../user/user.service.js';
|
||||||
|
import { PaymentController } from './payment.controller.js';
|
||||||
|
import { PaymentService } from './payment.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DrizzleModule, UserModule, MailModule],
|
||||||
|
providers: [PaymentService, UserService, MailService, FileService, GeoService],
|
||||||
|
controllers: [PaymentController],
|
||||||
|
})
|
||||||
|
export class PaymentModule {}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver.js';
|
||||||
|
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import { Logger } from 'winston';
|
||||||
|
import * as schema from '../drizzle/schema.js';
|
||||||
|
import { PG_CONNECTION } from '../drizzle/schema.js';
|
||||||
|
import { MailService } from '../mail/mail.service.js';
|
||||||
|
import { Checkout } from '../models/main.model.js';
|
||||||
|
import { UserService } from '../user/user.service.js';
|
||||||
|
export interface BillingAddress {
|
||||||
|
country: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
@Injectable()
|
||||||
|
export class PaymentService {
|
||||||
|
private stripe: Stripe;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||||
|
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
||||||
|
private readonly userService: UserService,
|
||||||
|
private readonly mailService: MailService,
|
||||||
|
) {
|
||||||
|
this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||||
|
apiVersion: '2024-06-20',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async checkout(checkout: Checkout) {
|
||||||
|
try {
|
||||||
|
const session = await this.stripe.checkout.sessions.create({
|
||||||
|
mode: 'subscription',
|
||||||
|
payment_method_types: ['card'],
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price: checkout.priceId,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
success_url: 'http://localhost:4200/success',
|
||||||
|
cancel_url: 'http://localhost:4200/pricing',
|
||||||
|
customer_email: checkout.email,
|
||||||
|
shipping_address_collection: {
|
||||||
|
allowed_countries: ['US'],
|
||||||
|
},
|
||||||
|
client_reference_id: '1234',
|
||||||
|
locale: 'en',
|
||||||
|
});
|
||||||
|
|
||||||
|
return session;
|
||||||
|
} catch (e) {
|
||||||
|
throw new BadRequestException(`error during checkout: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async constructEvent(body: string | Buffer, signature: string) {
|
||||||
|
return this.stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
|
||||||
|
}
|
||||||
|
async handleCheckoutSessionCompleted(session: Stripe.Checkout.Session): Promise<void> {
|
||||||
|
this.logger.info(JSON.stringify(session));
|
||||||
|
//const jwtUser:JwtUser = {firstname:,lastname:}
|
||||||
|
const user = await this.userService.getUserByMail(session.customer_details.email);
|
||||||
|
user.stripeCustomerId = session.customer as string;
|
||||||
|
user.subscriptionId = session.subscription as string;
|
||||||
|
user.planActive = true;
|
||||||
|
//session.shipping_details ->
|
||||||
|
// "shipping_details": {
|
||||||
|
// "address": {
|
||||||
|
// "city": "Springfield",
|
||||||
|
// "country": "US",
|
||||||
|
// "line1": "West Maple Avenue South",
|
||||||
|
// "line2": null,
|
||||||
|
// "postal_code": "62704",
|
||||||
|
// "state": "IL"
|
||||||
|
// },
|
||||||
|
// "name": "Johnathan Miller"
|
||||||
|
// }
|
||||||
|
user.subscriptionPlan = session.amount_total === 4900 ? 'broker' : 'professional'; //session.metadata['subscriptionPlan'] as 'free' | 'professional' | 'broker';
|
||||||
|
this.userService.saveUser(user);
|
||||||
|
this.mailService.sendSubscriptionConfirmation(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -96,7 +96,7 @@ export class UserService {
|
||||||
.from(schema.users)
|
.from(schema.users)
|
||||||
.where(sql`email = ${email}`)) as User[];
|
.where(sql`email = ${email}`)) as User[];
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
const user: User = { id: undefined, customerType: 'buyer', ...createDefaultUser(email, jwtuser.firstname, jwtuser.lastname) };
|
const user: User = { id: undefined, customerType: 'buyer', ...createDefaultUser(email, jwtuser.firstname, jwtuser.lastname, 'free') };
|
||||||
const u = await this.saveUser(user);
|
const u = await this.saveUser(user);
|
||||||
return convertDrizzleUserToUser(u);
|
return convertDrizzleUserToUser(u);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||||
"@ng-select/ng-select": "^13.4.1",
|
"@ng-select/ng-select": "^13.4.1",
|
||||||
"@ngneat/until-destroy": "^10.0.0",
|
"@ngneat/until-destroy": "^10.0.0",
|
||||||
|
"@stripe/stripe-js": "^4.3.0",
|
||||||
"@types/cropperjs": "^1.3.0",
|
"@types/cropperjs": "^1.3.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"browser-bunyan": "^1.8.0",
|
"browser-bunyan": "^1.8.0",
|
||||||
|
|
@ -45,6 +46,7 @@
|
||||||
"ngx-image-cropper": "^8.0.0",
|
"ngx-image-cropper": "^8.0.0",
|
||||||
"ngx-mask": "^18.0.0",
|
"ngx-mask": "^18.0.0",
|
||||||
"ngx-quill": "^26.0.5",
|
"ngx-quill": "^26.0.5",
|
||||||
|
"ngx-stripe": "^18.1.0",
|
||||||
"on-change": "^5.0.1",
|
"on-change": "^5.0.1",
|
||||||
"rxjs": "~7.8.1",
|
"rxjs": "~7.8.1",
|
||||||
"tslib": "^2.6.3",
|
"tslib": "^2.6.3",
|
||||||
|
|
@ -71,4 +73,4 @@
|
||||||
"tailwindcss": "^3.4.4",
|
"tailwindcss": "^3.4.4",
|
||||||
"typescript": "~5.4.5"
|
"typescript": "~5.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@a
|
||||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||||
import { KeycloakBearerInterceptor, KeycloakService } from 'keycloak-angular';
|
import { KeycloakBearerInterceptor, KeycloakService } from 'keycloak-angular';
|
||||||
import { provideQuillConfig } from 'ngx-quill';
|
import { provideQuillConfig } from 'ngx-quill';
|
||||||
|
import { provideNgxStripe } from 'ngx-stripe';
|
||||||
import { environment } from '../environments/environment';
|
import { environment } from '../environments/environment';
|
||||||
import { customKeycloakAdapter } from '../keycloak';
|
import { customKeycloakAdapter } from '../keycloak';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
|
@ -52,6 +53,7 @@ export const appConfig: ApplicationConfig = {
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
provideAnimations(),
|
provideAnimations(),
|
||||||
|
provideNgxStripe('pk_test_IlpbVQhxAXZypLgnCHOCqlj8'),
|
||||||
provideQuillConfig({
|
provideQuillConfig({
|
||||||
modules: {
|
modules: {
|
||||||
syntax: true,
|
syntax: true,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
|
||||||
import { LogoutComponent } from './components/logout/logout.component';
|
import { LogoutComponent } from './components/logout/logout.component';
|
||||||
import { NotFoundComponent } from './components/not-found/not-found.component';
|
import { NotFoundComponent } from './components/not-found/not-found.component';
|
||||||
|
|
||||||
|
import { PaymentComponent } from './components/payment/payment.component';
|
||||||
import { AuthGuard } from './guards/auth.guard';
|
import { AuthGuard } from './guards/auth.guard';
|
||||||
import { ListingCategoryGuard } from './guards/listing-category.guard';
|
import { ListingCategoryGuard } from './guards/listing-category.guard';
|
||||||
import { DetailsBusinessListingComponent } from './pages/details/details-business-listing/details-business-listing.component';
|
import { DetailsBusinessListingComponent } from './pages/details/details-business-listing/details-business-listing.component';
|
||||||
|
|
@ -18,6 +19,7 @@ import { EditCommercialPropertyListingComponent } from './pages/subscription/edi
|
||||||
import { EmailUsComponent } from './pages/subscription/email-us/email-us.component';
|
import { EmailUsComponent } from './pages/subscription/email-us/email-us.component';
|
||||||
import { FavoritesComponent } from './pages/subscription/favorites/favorites.component';
|
import { FavoritesComponent } from './pages/subscription/favorites/favorites.component';
|
||||||
import { MyListingComponent } from './pages/subscription/my-listing/my-listing.component';
|
import { MyListingComponent } from './pages/subscription/my-listing/my-listing.component';
|
||||||
|
import { SuccessComponent } from './pages/success/success.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
|
|
@ -132,5 +134,17 @@ export const routes: Routes = [
|
||||||
path: 'pricing',
|
path: 'pricing',
|
||||||
component: PricingComponent,
|
component: PricingComponent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'pricing/:id',
|
||||||
|
component: PricingComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'payment',
|
||||||
|
component: PaymentComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'success',
|
||||||
|
component: SuccessComponent,
|
||||||
|
},
|
||||||
{ path: '**', redirectTo: 'home' },
|
{ path: '**', redirectTo: 'home' },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
<section class="bg-white py-8 antialiased dark:bg-gray-900 md:py-16">
|
||||||
|
<div class="mx-auto max-w-screen-xl px-4 2xl:px-0">
|
||||||
|
<div class="mx-auto max-w-5xl">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white sm:text-2xl">Payment</h2>
|
||||||
|
|
||||||
|
<div class="mt-6 sm:mt-8 lg:flex lg:items-start lg:gap-12">
|
||||||
|
<form action="#" class="w-full rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6 lg:max-w-xl lg:p-8">
|
||||||
|
<div ngxStripeCardGroup class="mb-6 grid grid-cols-2 gap-4">
|
||||||
|
<div class="col-span-2 sm:col-span-1">
|
||||||
|
<label for="full_name" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white"> Full name (as displayed on card)* </label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="full_name"
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||||
|
placeholder="Bonnie Green"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <ngx-stripe-card-expiry [options]="cardOptions"></ngx-stripe-card-expiry>
|
||||||
|
<ngx-stripe-card-cvc [options]="cardOptions"></ngx-stripe-card-cvc> -->
|
||||||
|
<div class="col-span-2 sm:col-span-1">
|
||||||
|
<label for="card-number-input" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white"> Card number* </label>
|
||||||
|
<!-- <input
|
||||||
|
type="text"
|
||||||
|
id="card-number-input"
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pe-10 text-sm text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||||
|
placeholder="xxxx-xxxx-xxxx-xxxx"
|
||||||
|
pattern="^4[0-9]{12}(?:[0-9]{3})?$"
|
||||||
|
required
|
||||||
|
/> -->
|
||||||
|
<div
|
||||||
|
class="ngx-input block w-full rounded-lg border border-gray-300 bg-gray-50 text-sm text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<ngx-stripe-card-number [options]="numberOptions"></ngx-stripe-card-number>
|
||||||
|
</div>
|
||||||
|
<!-- <ngx-stripe-card [options]="cardOptions" /> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="card-expiration-input" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Card expiration* </label>
|
||||||
|
<!-- <div class="relative">
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 start-0 flex items-center ps-3.5">
|
||||||
|
<svg class="h-4 w-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M5 5a1 1 0 0 0 1-1 1 1 0 1 1 2 0 1 1 0 0 0 1 1h1a1 1 0 0 0 1-1 1 1 0 1 1 2 0 1 1 0 0 0 1 1h1a1 1 0 0 0 1-1 1 1 0 1 1 2 0 1 1 0 0 0 1 1 2 2 0 0 1 2 2v1a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a2 2 0 0 1 2-2ZM3 19v-7a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Zm6.01-6a1 1 0 1 0-2 0 1 1 0 0 0 2 0Zm2 0a1 1 0 1 1 2 0 1 1 0 0 1-2 0Zm6 0a1 1 0 1 0-2 0 1 1 0 0 0 2 0Zm-10 4a1 1 0 1 1 2 0 1 1 0 0 1-2 0Zm6 0a1 1 0 1 0-2 0 1 1 0 0 0 2 0Zm2 0a1 1 0 1 1 2 0 1 1 0 0 1-2 0Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
datepicker
|
||||||
|
datepicker-format="mm/yy"
|
||||||
|
id="card-expiration-input"
|
||||||
|
type="text"
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 ps-9 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
|
||||||
|
placeholder="12/23"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div> -->
|
||||||
|
<div
|
||||||
|
class="ngx-input block w-full rounded-lg border border-gray-300 bg-gray-50 text-sm text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<ngx-stripe-card-expiry [options]="cvcOptions"></ngx-stripe-card-expiry>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="cvv-input" class="mb-2 flex items-center gap-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
CVV*
|
||||||
|
<button data-tooltip-target="cvv-desc" data-tooltip-trigger="hover" class="text-gray-400 hover:text-gray-900 dark:text-gray-500 dark:hover:text-white">
|
||||||
|
<svg class="h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm9.408-5.5a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2h-.01ZM10 10a1 1 0 1 0 0 2h1v3h-1a1 1 0 1 0 0 2h4a1 1 0 1 0 0-2h-1v-4a1 1 0 0 0-1-1h-2Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
id="cvv-desc"
|
||||||
|
role="tooltip"
|
||||||
|
class="tooltip invisible absolute z-10 inline-block rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white opacity-0 shadow-sm transition-opacity duration-300 dark:bg-gray-700"
|
||||||
|
>
|
||||||
|
The last 3 or 4 digits on back of card
|
||||||
|
<div class="tooltip-arrow" data-popper-arrow></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<!-- <input
|
||||||
|
type="number"
|
||||||
|
id="cvv-input"
|
||||||
|
aria-describedby="helper-text-explanation"
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||||
|
placeholder="•••"
|
||||||
|
required
|
||||||
|
/> -->
|
||||||
|
<div
|
||||||
|
class="ngx-input block w-full rounded-lg border border-gray-300 bg-gray-50 text-sm text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<ngx-stripe-card-cvc [options]="cvcOptions"></ngx-stripe-card-cvc>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-full items-center justify-center rounded-lg bg-primary-700 px-5 py-2.5 text-sm font-medium text-white hover:bg-primary-800 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||||
|
>
|
||||||
|
Pay now
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 grow sm:mt-8 lg:mt-0">
|
||||||
|
<div class="space-y-4 rounded-lg border border-gray-100 bg-gray-50 p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<dl class="flex items-center justify-between gap-4">
|
||||||
|
<dt class="text-base font-normal text-gray-500 dark:text-gray-400">Subscription Price</dt>
|
||||||
|
<dd class="text-base font-medium text-gray-900 dark:text-white">$49.00</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<!-- <dl class="flex items-center justify-between gap-4">
|
||||||
|
<dt class="text-base font-normal text-gray-500 dark:text-gray-400">Savings</dt>
|
||||||
|
<dd class="text-base font-medium text-green-500">-$299.00</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<dl class="flex items-center justify-between gap-4">
|
||||||
|
<dt class="text-base font-normal text-gray-500 dark:text-gray-400">Store Pickup</dt>
|
||||||
|
<dd class="text-base font-medium text-gray-900 dark:text-white">$99</dd>
|
||||||
|
</dl> -->
|
||||||
|
|
||||||
|
<dl class="flex items-center justify-between gap-4">
|
||||||
|
<dt class="text-base font-normal text-gray-500 dark:text-gray-400">Tax</dt>
|
||||||
|
<dd class="text-base font-medium text-gray-900 dark:text-white">$1.43</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="flex items-center justify-between gap-4 border-t border-gray-200 pt-2 dark:border-gray-700">
|
||||||
|
<dt class="text-base font-bold text-gray-900 dark:text-white">Total</dt>
|
||||||
|
<dd class="text-base font-bold text-gray-900 dark:text-white">$50.43</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex items-center justify-center gap-8">
|
||||||
|
<!-- <img class="h-8 w-auto dark:hidden" src="https://flowbite.s3.amazonaws.com/blocks/e-commerce/brand-logos/paypal.svg" alt="" />
|
||||||
|
<img class="hidden h-8 w-auto dark:flex" src="https://flowbite.s3.amazonaws.com/blocks/e-commerce/brand-logos/paypal-dark.svg" alt="" /> -->
|
||||||
|
<img class="h-8 w-auto dark:hidden" src="https://flowbite.s3.amazonaws.com/blocks/e-commerce/brand-logos/visa.svg" alt="" />
|
||||||
|
<img class="hidden h-8 w-auto dark:flex" src="https://flowbite.s3.amazonaws.com/blocks/e-commerce/brand-logos/visa-dark.svg" alt="" />
|
||||||
|
<img class="h-8 w-auto dark:hidden" src="https://flowbite.s3.amazonaws.com/blocks/e-commerce/brand-logos/mastercard.svg" alt="" />
|
||||||
|
<img class="hidden h-8 w-auto dark:flex" src="https://flowbite.s3.amazonaws.com/blocks/e-commerce/brand-logos/mastercard-dark.svg" alt="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-6 text-center text-gray-500 dark:text-gray-400 sm:mt-8 lg:text-left">
|
||||||
|
Payment processed by <a href="#" title="" class="font-medium text-primary-700 underline hover:no-underline dark:text-primary-500">Stripe</a> for
|
||||||
|
<a href="#" title="" class="font-medium text-primary-700 underline hover:no-underline dark:text-primary-500">Bizmatch Inc.</a>
|
||||||
|
- United States Of America
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, inject, ViewChild } from '@angular/core';
|
||||||
|
import { FormsModule, NgForm } from '@angular/forms';
|
||||||
|
import { StripeCardCvcElementOptions, StripeCardNumberElementOptions, StripeElementsOptions } from '@stripe/stripe-js';
|
||||||
|
import { injectStripe, StripeCardComponent, StripeCardCvcComponent, StripeCardExpiryComponent, StripeCardGroupDirective, StripeCardNumberComponent, StripeElementsDirective } from 'ngx-stripe';
|
||||||
|
import { PaymentService } from './payment.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-payment-modal',
|
||||||
|
templateUrl: './payment.component.html',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, StripeCardNumberComponent, StripeCardExpiryComponent, StripeCardCvcComponent, StripeElementsDirective, StripeCardGroupDirective],
|
||||||
|
styles: `
|
||||||
|
.ngx-input{
|
||||||
|
padding:0.85rem
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class PaymentComponent {
|
||||||
|
@ViewChild('cardElement') cardElement: StripeCardComponent;
|
||||||
|
stripe = injectStripe('your-stripe-publishable-key');
|
||||||
|
name: string;
|
||||||
|
constructor() {}
|
||||||
|
cvcOptions: StripeCardCvcElementOptions = {
|
||||||
|
style: {
|
||||||
|
base: {
|
||||||
|
iconColor: '#666EE8',
|
||||||
|
color: '#111827',
|
||||||
|
fontWeight: '300',
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontSize: '14px',
|
||||||
|
'::placeholder': {
|
||||||
|
color: '#CFD7E0',
|
||||||
|
// color: '#6B7280',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
numberOptions: StripeCardNumberElementOptions = {
|
||||||
|
showIcon: true,
|
||||||
|
style: {
|
||||||
|
base: {
|
||||||
|
iconColor: '#666EE8',
|
||||||
|
color: '#111827',
|
||||||
|
fontWeight: '300',
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontSize: '14px',
|
||||||
|
'::placeholder': {
|
||||||
|
color: '#CFD7E0',
|
||||||
|
// color: '#6B7280',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
elementsOptions: StripeElementsOptions = {
|
||||||
|
locale: 'en',
|
||||||
|
};
|
||||||
|
|
||||||
|
paymentService = inject(PaymentService);
|
||||||
|
|
||||||
|
onSubmit(form: NgForm) {
|
||||||
|
if (form.valid) {
|
||||||
|
this.stripe.createToken(this.cardElement.element, { name: this.name }).subscribe(result => {
|
||||||
|
if (result.token) {
|
||||||
|
this.paymentService.processPayment(result.token.id).subscribe(() => {
|
||||||
|
console.log('Payment successful');
|
||||||
|
});
|
||||||
|
} else if (result.error) {
|
||||||
|
console.error(result.error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
// 1. Shared Service (modal.service.ts)
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class PaymentService {
|
||||||
|
private modalVisibleSubject = new BehaviorSubject<boolean>(false);
|
||||||
|
modalVisible$: Observable<boolean> = this.modalVisibleSubject.asObservable();
|
||||||
|
private resolvePromise!: (value: boolean) => void;
|
||||||
|
constructor(private http: HttpClient) {}
|
||||||
|
openPaymentModal() {
|
||||||
|
this.modalVisibleSubject.next(true);
|
||||||
|
return new Promise<boolean>(resolve => {
|
||||||
|
this.resolvePromise = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
accept(): void {
|
||||||
|
this.modalVisibleSubject.next(false);
|
||||||
|
this.resolvePromise(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(): void {
|
||||||
|
this.modalVisibleSubject.next(false);
|
||||||
|
this.resolvePromise(false);
|
||||||
|
}
|
||||||
|
processPayment(token: string): Observable<any> {
|
||||||
|
return this.http.post('/api/subscription', { token });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
} @else {
|
} @else {
|
||||||
<a routerLink="/pricing" class="text-gray-800">Pricing</a>
|
<a routerLink="/pricing" class="text-gray-800">Pricing</a>
|
||||||
<a (click)="login()" class="text-blue-600 border border-blue-600 px-3 py-2 rounded">Log In</a>
|
<a (click)="login()" class="text-blue-600 border border-blue-600 px-3 py-2 rounded">Log In</a>
|
||||||
<a (click)="register()" class="text-white bg-blue-600 px-4 py-2 rounded">Register</a>
|
<a routerLink="/pricing" class="text-white bg-blue-600 px-4 py-2 rounded">Register</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button (click)="toggleMenu()" class="md:hidden text-gray-600">
|
<button (click)="toggleMenu()" class="md:hidden text-gray-600">
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,6 @@
|
||||||
<i class="fas fa-check text-green-500 mr-2"></i>
|
<i class="fas fa-check text-green-500 mr-2"></i>
|
||||||
Get early access to new listings
|
Get early access to new listings
|
||||||
</li>
|
</li>
|
||||||
<li class="mb-4 flex items-center">
|
|
||||||
<i class="fas fa-check text-green-500 mr-2"></i>
|
|
||||||
Professional/Broker trial period (3 month)
|
|
||||||
</li>
|
|
||||||
<li class="mb-4 flex items-center">
|
<li class="mb-4 flex items-center">
|
||||||
<i class="fas fa-check text-green-500 mr-2"></i>
|
<i class="fas fa-check text-green-500 mr-2"></i>
|
||||||
Extended search functionality
|
Extended search functionality
|
||||||
|
|
@ -31,7 +27,7 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4 mt-auto">
|
<div class="px-6 py-4 mt-auto">
|
||||||
<button class="w-full bg-blue-500 text-white rounded-full px-4 py-2 font-semibold hover:bg-blue-600 transition duration-300">Sign Up Now</button>
|
<button (click)="register()" class="w-full bg-blue-500 text-white rounded-full px-4 py-2 font-semibold hover:bg-blue-600 transition duration-300">Sign Up Now</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -51,7 +47,7 @@
|
||||||
</li>
|
</li>
|
||||||
<li class="mb-4 flex items-center">
|
<li class="mb-4 flex items-center">
|
||||||
<i class="fas fa-check text-green-500 mr-2"></i>
|
<i class="fas fa-check text-green-500 mr-2"></i>
|
||||||
Extended public listings (3+ months)
|
3-Month Free Trial
|
||||||
</li>
|
</li>
|
||||||
<li class="mb-4 flex items-center">
|
<li class="mb-4 flex items-center">
|
||||||
<i class="fas fa-check text-green-500 mr-2"></i>
|
<i class="fas fa-check text-green-500 mr-2"></i>
|
||||||
|
|
@ -76,7 +72,8 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4 mt-auto">
|
<div class="px-6 py-4 mt-auto">
|
||||||
<button class="w-full bg-blue-500 text-white rounded-full px-4 py-2 font-semibold hover:bg-blue-600 transition duration-300">Get Started</button>
|
<!-- <button routerLink="/payment" class="w-full bg-blue-500 text-white rounded-full px-4 py-2 font-semibold hover:bg-blue-600 transition duration-300">Get Started</button> -->
|
||||||
|
<button (click)="register('price_1PpSkpDjmFBOcNBs9UDPgBos')" class="w-full bg-blue-500 text-white rounded-full px-4 py-2 font-semibold hover:bg-blue-600 transition duration-300">Get Started</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -100,7 +97,7 @@
|
||||||
</li>
|
</li>
|
||||||
<li class="mb-4 flex items-center">
|
<li class="mb-4 flex items-center">
|
||||||
<i class="fas fa-check text-green-500 mr-2"></i>
|
<i class="fas fa-check text-green-500 mr-2"></i>
|
||||||
Extended public listings (3+ months)
|
3-Month Free Trial
|
||||||
</li>
|
</li>
|
||||||
<li class="mb-4 flex items-center">
|
<li class="mb-4 flex items-center">
|
||||||
<i class="fas fa-check text-green-500 mr-2"></i>
|
<i class="fas fa-check text-green-500 mr-2"></i>
|
||||||
|
|
@ -125,7 +122,8 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4 mt-auto">
|
<div class="px-6 py-4 mt-auto">
|
||||||
<button class="w-full bg-blue-500 text-white rounded-full px-4 py-2 font-semibold hover:bg-blue-600 transition duration-300">Start Listing Now</button>
|
<!-- <button routerLink="/payment" class="w-full bg-blue-500 text-white rounded-full px-4 py-2 font-semibold hover:bg-blue-600 transition duration-300">Start Listing Now</button> -->
|
||||||
|
<button (click)="register('price_1PpSmRDjmFBOcNBsaaSp2nk9')" class="w-full bg-blue-500 text-white rounded-full px-4 py-2 font-semibold hover:bg-blue-600 transition duration-300">Start Listing Now</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { KeycloakService } from 'keycloak-angular';
|
import { KeycloakService } from 'keycloak-angular';
|
||||||
|
import { StripeService } from 'ngx-stripe';
|
||||||
|
import { switchMap } from 'rxjs';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
import { SharedModule } from '../../shared/shared/shared.module';
|
import { SharedModule } from '../../shared/shared/shared.module';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|
@ -10,8 +15,40 @@ import { SharedModule } from '../../shared/shared/shared.module';
|
||||||
styleUrl: './pricing.component.scss',
|
styleUrl: './pricing.component.scss',
|
||||||
})
|
})
|
||||||
export class PricingComponent {
|
export class PricingComponent {
|
||||||
constructor(public keycloakService: KeycloakService) {}
|
private apiBaseUrl = environment.apiBaseUrl;
|
||||||
register() {
|
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
|
||||||
this.keycloakService.register({ redirectUri: `${window.location.origin}/account` });
|
constructor(public keycloakService: KeycloakService, private http: HttpClient, private stripeService: StripeService, private activatedRoute: ActivatedRoute) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (this.id) {
|
||||||
|
this.checkout(atob(this.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register(priceId?: string) {
|
||||||
|
if (priceId) {
|
||||||
|
this.keycloakService.register({ redirectUri: `${window.location.origin}/pricing/${btoa(priceId)}` });
|
||||||
|
} else {
|
||||||
|
this.keycloakService.register({ redirectUri: `${window.location.origin}/account` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkout(priceId) {
|
||||||
|
// Check the server.js tab to see an example implementation
|
||||||
|
this.http
|
||||||
|
.post(`${this.apiBaseUrl}/bizmatch/payment/create-checkout-session`, { priceId })
|
||||||
|
.pipe(
|
||||||
|
switchMap((session: any) => {
|
||||||
|
return this.stripeService.redirectToCheckout({ sessionId: session.id });
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe(result => {
|
||||||
|
// If `redirectToCheckout` fails due to a browser or network
|
||||||
|
// error, you should display the localized error message to your
|
||||||
|
// customer using `error.message`.
|
||||||
|
if (result.error) {
|
||||||
|
alert(result.error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ export class AccountComponent {
|
||||||
const confirmed = await this.confirmationService.showConfirmation({ message: 'Are you sure you want to switch to Buyer ? All your listings as well as all your professionals informations will be deleted' });
|
const confirmed = await this.confirmationService.showConfirmation({ message: 'Are you sure you want to switch to Buyer ? All your listings as well as all your professionals informations will be deleted' });
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
const id = this.user.id;
|
const id = this.user.id;
|
||||||
this.user = createDefaultUser(this.user.email, this.user.firstname, this.user.lastname);
|
this.user = createDefaultUser(this.user.email, this.user.firstname, this.user.lastname, 'free');
|
||||||
this.user.customerType = 'buyer';
|
this.user.customerType = 'buyer';
|
||||||
this.user.id = id;
|
this.user.id = id;
|
||||||
this.imageService.deleteLogoImagesByMail(this.user.email);
|
this.imageService.deleteLogoImagesByMail(this.user.email);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-lg max-w-md">
|
||||||
|
<div class="text-center">
|
||||||
|
@if(user && (user.subscriptionPlan==='professional' || user.subscriptionPlan==='broker')){
|
||||||
|
<svg class="mx-auto h-16 w-16 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 mt-4">Subscription Successful!</h2>
|
||||||
|
<p class="text-gray-600 mt-2">Thank you for subscribing to our service.</p>
|
||||||
|
<p class="text-gray-600 mt-2">
|
||||||
|
You have successfully subscribed to the @if(user.subscriptionPlan==='professional'){
|
||||||
|
<span class="font-semibold text-gray-800">Professional (CPA, Attorney, Title Company) Plan</span>
|
||||||
|
} @if(user.subscriptionPlan==='broker'){
|
||||||
|
<span class="font-semibold text-gray-800">Business Broker Plan</span>. }
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-600 mt-2">We are excited to have you on board!</p>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<a routerLink="/account" class="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg">Go to your Account</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-sm text-gray-500">
|
||||||
|
<p>If you have any questions, feel free to <a routerLink="/emailUs" class="text-blue-500 hover:underline">contact our support team</a>.</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="text-gray-600 mt-2">We are processing your subscription - Please be patient</p>
|
||||||
|
<div class="loader mt-8 mx-auto"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/* HTML: <div class="loader"></div> */
|
||||||
|
.loader {
|
||||||
|
width: 15px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.loader::before,
|
||||||
|
.loader::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.loader::before {
|
||||||
|
box-shadow: -25px 0;
|
||||||
|
animation: l8-1 1s infinite linear;
|
||||||
|
}
|
||||||
|
.loader::after {
|
||||||
|
transform: rotate(0deg) translateX(25px);
|
||||||
|
animation: l8-2 1s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes l8-1 {
|
||||||
|
100% {
|
||||||
|
transform: translateX(25px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes l8-2 {
|
||||||
|
100% {
|
||||||
|
transform: rotate(-180deg) translateX(25px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { Router, RouterModule } from '@angular/router';
|
||||||
|
import { KeycloakService } from 'keycloak-angular';
|
||||||
|
import { User } from '../../../../../bizmatch-server/src/models/db.model';
|
||||||
|
import { LogService } from '../../services/log.service';
|
||||||
|
import { UserService } from '../../services/user.service';
|
||||||
|
import { map2User } from '../../utils/utils';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-success',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterModule],
|
||||||
|
templateUrl: './success.component.html',
|
||||||
|
styleUrl: './success.component.scss',
|
||||||
|
})
|
||||||
|
export class SuccessComponent {
|
||||||
|
user: User;
|
||||||
|
constructor(private keycloakService: KeycloakService, private userService: UserService, private logService: LogService, private router: Router) {}
|
||||||
|
async ngOnInit() {
|
||||||
|
let email = null;
|
||||||
|
try {
|
||||||
|
const token = await this.keycloakService.getToken();
|
||||||
|
const keycloakUser = map2User(token);
|
||||||
|
email = keycloakUser.email;
|
||||||
|
this.user = await this.userService.getByMail(email);
|
||||||
|
this.checkSubscriptionPlan(email);
|
||||||
|
} catch (e) {
|
||||||
|
this.checkSubscriptionPlan(email, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async checkSubscriptionPlan(email: string, error?: string) {
|
||||||
|
if (!email) {
|
||||||
|
this.logService.log({ severity: 'error', text: `Unauthorized Access to Success Page ${error}` });
|
||||||
|
this.router.navigate(['home']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 5;
|
||||||
|
const interval = 5000; // 5 Sekunden
|
||||||
|
|
||||||
|
const intervalId = setInterval(async () => {
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
console.error('Max attempts reached');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
this.user = await this.userService.getByMail(email);
|
||||||
|
|
||||||
|
if (this.user && this.user.subscriptionPlan) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
console.log('Subscription plan is set:', this.user.subscriptionPlan);
|
||||||
|
} else {
|
||||||
|
console.log(`Attempt ${attempts}: Subscription plan is not set yet.`);
|
||||||
|
}
|
||||||
|
}, interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -90,3 +90,7 @@ p-menubarsub ul {
|
||||||
.p-dropdown-panel .p-dropdown-header .p-dropdown-filter {
|
.p-dropdown-panel .p-dropdown-header .p-dropdown-filter {
|
||||||
margin-right: 0 !important;
|
margin-right: 0 !important;
|
||||||
}
|
}
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: #cfd7e0 !important;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue