Compare commits
8 Commits
claude/ten
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5845334454 | |||
| 170461c359 | |||
| aacec1cce3 | |||
| 6b12fcd7d7 | |||
| 8e58d04568 | |||
| c2e52bee64 | |||
| 9cd641923d | |||
| 8abab40778 |
77
backend/package-lock.json
generated
77
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.11",
|
"version": "2026.3.17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.11",
|
"version": "2026.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/common": "^10.4.15",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"resend": "^6.9.4",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"stripe": "^20.4.1",
|
"stripe": "^20.4.1",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
@@ -2791,6 +2792,12 @@
|
|||||||
"integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
|
"integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tokenizer/inflate": {
|
"node_modules/@tokenizer/inflate": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
|
||||||
@@ -5357,6 +5364,12 @@
|
|||||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fb-watchman": {
|
"node_modules/fb-watchman": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
|
||||||
@@ -8723,6 +8736,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postal-mime": {
|
||||||
|
"version": "2.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz",
|
||||||
|
"integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==",
|
||||||
|
"license": "MIT-0"
|
||||||
|
},
|
||||||
"node_modules/postgres-array": {
|
"node_modules/postgres-array": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
@@ -9207,6 +9226,27 @@
|
|||||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/resend": {
|
||||||
|
"version": "6.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/resend/-/resend-6.9.4.tgz",
|
||||||
|
"integrity": "sha512-/M3dsJzu5OgozqVsA4Psd/1L7EdePgOIIxClas453GOQYFG3VHc2ZyCHZFlvqsc9aZCCd2BJRRqZgWC8D9c7/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"postal-mime": "2.7.3",
|
||||||
|
"svix": "1.86.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@react-email/render": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@react-email/render": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -9779,6 +9819,16 @@
|
|||||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -10037,6 +10087,29 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svix": {
|
||||||
|
"version": "1.86.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/svix/-/svix-1.86.0.tgz",
|
||||||
|
"integrity": "sha512-/HTvXwjLJe1l/MsLXAO1ddCYxElJk4eNR4DzOjDOEmGrPN/3BtBE8perGwMAaJ2sT5T172VkBYzmHcjUfM1JRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"standardwebhooks": "1.0.0",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/svix/node_modules/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/swagger-ui-dist": {
|
"node_modules/swagger-ui-dist": {
|
||||||
"version": "5.17.14",
|
"version": "5.17.14",
|
||||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz",
|
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"resend": "^6.9.4",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"stripe": "^20.4.1",
|
"stripe": "^20.4.1",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
|
|||||||
@@ -1,64 +1,159 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
import { Resend } from 'resend';
|
||||||
|
|
||||||
/**
|
|
||||||
* Stubbed email service — logs to console and stores in shared.email_log.
|
|
||||||
* Replace internals with Resend/SendGrid when ready for production.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailService {
|
export class EmailService {
|
||||||
private readonly logger = new Logger(EmailService.name);
|
private readonly logger = new Logger(EmailService.name);
|
||||||
|
private resend: Resend | null = null;
|
||||||
|
private fromAddress: string;
|
||||||
|
private replyToAddress: string;
|
||||||
|
|
||||||
constructor(private dataSource: DataSource) {}
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {
|
||||||
|
const apiKey = this.configService.get<string>('RESEND_API_KEY');
|
||||||
|
if (apiKey && !apiKey.includes('placeholder')) {
|
||||||
|
this.resend = new Resend(apiKey);
|
||||||
|
this.logger.log('Resend email service initialized');
|
||||||
|
} else {
|
||||||
|
this.logger.warn('Resend not configured — emails will be logged only (stub mode)');
|
||||||
|
}
|
||||||
|
this.fromAddress = this.configService.get<string>('RESEND_FROM_ADDRESS') || 'noreply@hoaledgeriq.com';
|
||||||
|
this.replyToAddress = this.configService.get<string>('RESEND_REPLY_TO') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ──────────────────────────────────────────────
|
||||||
|
|
||||||
async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise<void> {
|
async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise<void> {
|
||||||
const subject = `Activate your ${businessName} account on HOA LedgerIQ`;
|
const subject = `Activate your ${businessName} account on HOA LedgerIQ`;
|
||||||
const body = [
|
const html = this.buildTemplate({
|
||||||
`Welcome to HOA LedgerIQ!`,
|
preheader: 'Your HOA LedgerIQ account is ready to activate.',
|
||||||
``,
|
heading: 'Welcome to HOA LedgerIQ!',
|
||||||
`Your organization "${businessName}" has been created.`,
|
body: `
|
||||||
`Please activate your account by clicking the link below:`,
|
<p>Your organization <strong>${this.esc(businessName)}</strong> has been created and is ready to go.</p>
|
||||||
``,
|
<p>Click the button below to set your password and activate your account:</p>
|
||||||
activationUrl,
|
`,
|
||||||
``,
|
ctaText: 'Activate My Account',
|
||||||
`This link expires in 72 hours.`,
|
ctaUrl: activationUrl,
|
||||||
].join('\n');
|
footer: 'This activation link expires in 72 hours. If you did not sign up for HOA LedgerIQ, please ignore this email.',
|
||||||
|
});
|
||||||
|
|
||||||
await this.log(email, subject, body, 'activation', { businessName, activationUrl });
|
await this.send(email, subject, html, 'activation', { businessName, activationUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendWelcomeEmail(email: string, businessName: string): Promise<void> {
|
async sendWelcomeEmail(email: string, businessName: string): Promise<void> {
|
||||||
|
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
|
||||||
const subject = `Welcome to HOA LedgerIQ — ${businessName}`;
|
const subject = `Welcome to HOA LedgerIQ — ${businessName}`;
|
||||||
const body = `Your account is active. Log in at http://localhost to get started.`;
|
const html = this.buildTemplate({
|
||||||
await this.log(email, subject, body, 'welcome', { businessName });
|
preheader: `${businessName} is all set up on HOA LedgerIQ.`,
|
||||||
|
heading: `You're all set!`,
|
||||||
|
body: `
|
||||||
|
<p>Your account for <strong>${this.esc(businessName)}</strong> is now active.</p>
|
||||||
|
<p>Log in to start managing your HOA's finances, assessments, and investments — all in one place.</p>
|
||||||
|
`,
|
||||||
|
ctaText: 'Go to Dashboard',
|
||||||
|
ctaUrl: `${appUrl}/dashboard`,
|
||||||
|
footer: 'If you have any questions, just reply to this email and we\'ll help you get started.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.send(email, subject, html, 'welcome', { businessName });
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendPaymentFailedEmail(email: string, businessName: string): Promise<void> {
|
async sendPaymentFailedEmail(email: string, businessName: string): Promise<void> {
|
||||||
const subject = `Payment failed for ${businessName} on HOA LedgerIQ`;
|
const subject = `Action required: Payment failed for ${businessName}`;
|
||||||
const body = `We were unable to process your payment. Please update your payment method.`;
|
const html = this.buildTemplate({
|
||||||
await this.log(email, subject, body, 'payment_failed', { businessName });
|
preheader: 'We were unable to process your payment.',
|
||||||
|
heading: 'Payment Failed',
|
||||||
|
body: `
|
||||||
|
<p>We were unable to process the latest payment for <strong>${this.esc(businessName)}</strong>.</p>
|
||||||
|
<p>Please update your payment method to avoid any interruption to your service.</p>
|
||||||
|
`,
|
||||||
|
ctaText: 'Update Payment Method',
|
||||||
|
ctaUrl: `${this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com'}/settings`,
|
||||||
|
footer: 'If you believe this is an error, please reply to this email and we\'ll look into it.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.send(email, subject, html, 'payment_failed', { businessName });
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendInviteMemberEmail(email: string, orgName: string, inviteUrl: string): Promise<void> {
|
async sendInviteMemberEmail(email: string, orgName: string, inviteUrl: string): Promise<void> {
|
||||||
const subject = `You've been invited to ${orgName} on HOA LedgerIQ`;
|
const subject = `You've been invited to ${orgName} on HOA LedgerIQ`;
|
||||||
const body = `You've been invited to join ${orgName}. Click here to accept: ${inviteUrl}`;
|
const html = this.buildTemplate({
|
||||||
await this.log(email, subject, body, 'invite_member', { orgName, inviteUrl });
|
preheader: `Join ${orgName} on HOA LedgerIQ.`,
|
||||||
|
heading: 'You\'re Invited!',
|
||||||
|
body: `
|
||||||
|
<p>You've been invited to join <strong>${this.esc(orgName)}</strong> on HOA LedgerIQ.</p>
|
||||||
|
<p>Click below to accept the invitation and set up your account:</p>
|
||||||
|
`,
|
||||||
|
ctaText: 'Accept Invitation',
|
||||||
|
ctaUrl: inviteUrl,
|
||||||
|
footer: 'This invitation link expires in 7 days. If you were not expecting this, please ignore this email.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.send(email, subject, html, 'invite_member', { orgName, inviteUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
|
async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
|
||||||
const subject = 'Reset your HOA LedgerIQ password';
|
const subject = 'Reset your HOA LedgerIQ password';
|
||||||
const body = [
|
const html = this.buildTemplate({
|
||||||
`You requested a password reset for your HOA LedgerIQ account.`,
|
preheader: 'Password reset requested for your HOA LedgerIQ account.',
|
||||||
``,
|
heading: 'Password Reset',
|
||||||
`Click the link below to reset your password:`,
|
body: `
|
||||||
resetUrl,
|
<p>We received a request to reset your password. Click the button below to choose a new one:</p>
|
||||||
``,
|
`,
|
||||||
`This link expires in 15 minutes. If you didn't request this, ignore this email.`,
|
ctaText: 'Reset Password',
|
||||||
].join('\n');
|
ctaUrl: resetUrl,
|
||||||
|
footer: 'This link expires in 1 hour. If you did not request a password reset, please ignore this email — your password will remain unchanged.',
|
||||||
|
});
|
||||||
|
|
||||||
await this.log(email, subject, body, 'password_reset', { resetUrl });
|
await this.send(email, subject, html, 'password_reset', { resetUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Core send logic ────────────────────────────────────────
|
||||||
|
|
||||||
|
private async send(
|
||||||
|
toEmail: string,
|
||||||
|
subject: string,
|
||||||
|
html: string,
|
||||||
|
template: string,
|
||||||
|
metadata: Record<string, any>,
|
||||||
|
): Promise<void> {
|
||||||
|
// Always log to the database
|
||||||
|
await this.log(toEmail, subject, html, template, metadata);
|
||||||
|
|
||||||
|
if (!this.resend) {
|
||||||
|
this.logger.log(`📧 EMAIL STUB → ${toEmail}`);
|
||||||
|
this.logger.log(` Subject: ${subject}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.resend.emails.send({
|
||||||
|
from: this.fromAddress,
|
||||||
|
to: [toEmail],
|
||||||
|
replyTo: this.replyToAddress || undefined,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
this.logger.error(`Resend error for ${toEmail}: ${JSON.stringify(result.error)}`);
|
||||||
|
await this.updateLogStatus(toEmail, template, 'failed', result.error.message);
|
||||||
|
} else {
|
||||||
|
this.logger.log(`✅ Email sent to ${toEmail} (id: ${result.data?.id})`);
|
||||||
|
await this.updateLogStatus(toEmail, template, 'sent', result.data?.id);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Failed to send email to ${toEmail}: ${err.message}`);
|
||||||
|
await this.updateLogStatus(toEmail, template, 'failed', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Database logging ───────────────────────────────────────
|
||||||
|
|
||||||
private async log(
|
private async log(
|
||||||
toEmail: string,
|
toEmail: string,
|
||||||
subject: string,
|
subject: string,
|
||||||
@@ -66,10 +161,6 @@ export class EmailService {
|
|||||||
template: string,
|
template: string,
|
||||||
metadata: Record<string, any>,
|
metadata: Record<string, any>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.log(`EMAIL STUB -> ${toEmail}`);
|
|
||||||
this.logger.log(` Subject: ${subject}`);
|
|
||||||
this.logger.log(` Body:\n${body}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.dataSource.query(
|
await this.dataSource.query(
|
||||||
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
|
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
|
||||||
@@ -80,4 +171,119 @@ export class EmailService {
|
|||||||
this.logger.warn(`Failed to log email: ${err}`);
|
this.logger.warn(`Failed to log email: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async updateLogStatus(toEmail: string, template: string, status: string, detail?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.email_log
|
||||||
|
SET metadata = metadata || $1::jsonb
|
||||||
|
WHERE to_email = $2 AND template = $3
|
||||||
|
AND created_at = (
|
||||||
|
SELECT MAX(created_at) FROM shared.email_log
|
||||||
|
WHERE to_email = $2 AND template = $3
|
||||||
|
)`,
|
||||||
|
[JSON.stringify({ send_status: status, send_detail: detail || '' }), toEmail, template],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Best effort — don't block the flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTML email template ────────────────────────────────────
|
||||||
|
|
||||||
|
private esc(text: string): string {
|
||||||
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTemplate(opts: {
|
||||||
|
preheader: string;
|
||||||
|
heading: string;
|
||||||
|
body: string;
|
||||||
|
ctaText: string;
|
||||||
|
ctaUrl: string;
|
||||||
|
footer: string;
|
||||||
|
}): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${this.esc(opts.heading)}</title>
|
||||||
|
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
|
<!-- Preheader (hidden preview text) -->
|
||||||
|
<div style="display:none;max-height:0;overflow:hidden;">${this.esc(opts.preheader)}</div>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7;padding:24px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;">
|
||||||
|
|
||||||
|
<!-- Logo bar -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:24px 0 16px;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#1a73e8;letter-spacing:-0.5px;">
|
||||||
|
HOA LedgerIQ
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Main card -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||||
|
style="background-color:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:40px 32px;">
|
||||||
|
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#1a1a2e;">
|
||||||
|
${this.esc(opts.heading)}
|
||||||
|
</h1>
|
||||||
|
<div style="font-size:15px;line-height:1.6;color:#4a4a68;">
|
||||||
|
${opts.body}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:28px 0 8px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="background-color:#1a73e8;border-radius:6px;">
|
||||||
|
<a href="${opts.ctaUrl}"
|
||||||
|
target="_blank"
|
||||||
|
style="display:inline-block;padding:14px 32px;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;border-radius:6px;">
|
||||||
|
${this.esc(opts.ctaText)}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Fallback URL -->
|
||||||
|
<p style="font-size:12px;color:#999;word-break:break-all;margin-top:16px;">
|
||||||
|
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||||
|
<a href="${opts.ctaUrl}" style="color:#1a73e8;">${opts.ctaUrl}</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px 32px;text-align:center;">
|
||||||
|
<p style="font-size:12px;color:#999;line-height:1.5;margin:0;">
|
||||||
|
${this.esc(opts.footer)}
|
||||||
|
</p>
|
||||||
|
<p style="font-size:12px;color:#bbb;margin:12px 0 0;">
|
||||||
|
© ${new Date().getFullYear()} HOA LedgerIQ — Smart Financial Management for HOAs
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,25 @@ services:
|
|||||||
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
||||||
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
||||||
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
||||||
|
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
|
||||||
|
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
|
||||||
|
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
|
||||||
|
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
|
||||||
|
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-}
|
||||||
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||||
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||||
|
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/google/callback}
|
||||||
|
- AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-}
|
||||||
|
- AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-}
|
||||||
|
- AZURE_TENANT_ID=${AZURE_TENANT_ID:-}
|
||||||
|
- AZURE_CALLBACK_URL=${AZURE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/azure/callback}
|
||||||
|
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-app.hoaledgeriq.com}
|
||||||
|
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-https://app.hoaledgeriq.com}
|
||||||
|
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-}
|
||||||
|
- APP_URL=${APP_URL:-https://app.hoaledgeriq.com}
|
||||||
|
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
||||||
|
- RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com}
|
||||||
|
- RESEND_REPLY_TO=${RESEND_REPLY_TO:-sales@hoaledgeriq.com}
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ services:
|
|||||||
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-localhost}
|
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-localhost}
|
||||||
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-http://localhost}
|
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-http://localhost}
|
||||||
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-dev-invite-secret}
|
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-dev-invite-secret}
|
||||||
|
- APP_URL=${APP_URL:-http://localhost}
|
||||||
|
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
||||||
|
- RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com}
|
||||||
|
- RESEND_REPLY_TO=${RESEND_REPLY_TO:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/src:/app/src
|
- ./backend/src:/app/src
|
||||||
- ./backend/nest-cli.json:/app/nest-cli.json
|
- ./backend/nest-cli.json:/app/nest-cli.json
|
||||||
|
|||||||
@@ -89,20 +89,20 @@ export function ProjectionChart({ datapoints, title = 'Financial Projection', su
|
|||||||
<AreaChart data={chartData}>
|
<AreaChart data={chartData}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#228be6" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="#228be6" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#228be6" stopOpacity={0} />
|
<stop offset="95%" stopColor="#228be6" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0} />
|
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#7950f2" stopOpacity={0} />
|
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#b197fc" stopOpacity={0} />
|
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Text, Stack, Card, Group, SimpleGrid, ThemeIcon,
|
Title, Text, Stack, Card, Group,
|
||||||
SegmentedControl, Loader, Center, ActionIcon, Tooltip, Badge,
|
SegmentedControl, Loader, Center, ActionIcon, Tooltip, Badge,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconCash, IconBuildingBank, IconChartAreaLine,
|
|
||||||
IconArrowLeft, IconArrowRight, IconCalendar,
|
IconArrowLeft, IconArrowRight, IconCalendar,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@@ -108,30 +107,6 @@ export function CashFlowForecastPage() {
|
|||||||
return datapoints.slice(viewStartIndex, viewStartIndex + 12);
|
return datapoints.slice(viewStartIndex, viewStartIndex + 12);
|
||||||
}, [datapoints, viewStartIndex]);
|
}, [datapoints, viewStartIndex]);
|
||||||
|
|
||||||
// Compute summary stats for the current view
|
|
||||||
const summaryStats = useMemo(() => {
|
|
||||||
if (!viewData.length) return null;
|
|
||||||
const last = viewData[viewData.length - 1];
|
|
||||||
const first = viewData[0];
|
|
||||||
|
|
||||||
const totalOperating = last.operating_cash + last.operating_investments;
|
|
||||||
const totalReserve = last.reserve_cash + last.reserve_investments;
|
|
||||||
const totalAll = totalOperating + totalReserve;
|
|
||||||
|
|
||||||
const firstTotal = first.operating_cash + first.operating_investments +
|
|
||||||
first.reserve_cash + first.reserve_investments;
|
|
||||||
const netChange = totalAll - firstTotal;
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalOperating,
|
|
||||||
totalReserve,
|
|
||||||
totalAll,
|
|
||||||
netChange,
|
|
||||||
periodStart: first.month,
|
|
||||||
periodEnd: last.month,
|
|
||||||
};
|
|
||||||
}, [viewData]);
|
|
||||||
|
|
||||||
// Determine the first forecast month index within the view
|
// Determine the first forecast month index within the view
|
||||||
const forecastStartLabel = useMemo(() => {
|
const forecastStartLabel = useMemo(() => {
|
||||||
const idx = viewData.findIndex((d) => d.is_forecast);
|
const idx = viewData.findIndex((d) => d.is_forecast);
|
||||||
@@ -181,65 +156,6 @@ export function CashFlowForecastPage() {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
|
||||||
{summaryStats && (
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
|
||||||
<Card withBorder p="md">
|
|
||||||
<Group gap="xs" mb={4}>
|
|
||||||
<ThemeIcon variant="light" color="blue" size="sm">
|
|
||||||
<IconCash size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Total</Text>
|
|
||||||
</Group>
|
|
||||||
<Text fw={700} size="xl" ff="monospace">
|
|
||||||
{fmt(summaryStats.totalOperating)}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
<Card withBorder p="md">
|
|
||||||
<Group gap="xs" mb={4}>
|
|
||||||
<ThemeIcon variant="light" color="violet" size="sm">
|
|
||||||
<IconBuildingBank size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Total</Text>
|
|
||||||
</Group>
|
|
||||||
<Text fw={700} size="xl" ff="monospace">
|
|
||||||
{fmt(summaryStats.totalReserve)}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
<Card withBorder p="md">
|
|
||||||
<Group gap="xs" mb={4}>
|
|
||||||
<ThemeIcon variant="light" color="teal" size="sm">
|
|
||||||
<IconChartAreaLine size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Combined Total</Text>
|
|
||||||
</Group>
|
|
||||||
<Text fw={700} size="xl" ff="monospace">
|
|
||||||
{fmt(summaryStats.totalAll)}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
<Card withBorder p="md">
|
|
||||||
<Group gap="xs" mb={4}>
|
|
||||||
<ThemeIcon
|
|
||||||
variant="light"
|
|
||||||
color={summaryStats.netChange >= 0 ? 'green' : 'red'}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<IconCash size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Period Change</Text>
|
|
||||||
</Group>
|
|
||||||
<Text
|
|
||||||
fw={700}
|
|
||||||
size="xl"
|
|
||||||
ff="monospace"
|
|
||||||
c={summaryStats.netChange >= 0 ? 'green' : 'red'}
|
|
||||||
>
|
|
||||||
{fmt(summaryStats.netChange)}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chart Navigation */}
|
{/* Chart Navigation */}
|
||||||
<Card withBorder p="lg">
|
<Card withBorder p="lg">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
@@ -287,20 +203,20 @@ export function CashFlowForecastPage() {
|
|||||||
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#339af0" stopOpacity={0.4} />
|
<stop offset="5%" stopColor="#339af0" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#339af0" stopOpacity={0.05} />
|
<stop offset="95%" stopColor="#339af0" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.4} />
|
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.05} />
|
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.4} />
|
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.05} />
|
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.4} />
|
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.05} />
|
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
|
||||||
|
|||||||
@@ -47,11 +47,12 @@ const plans = [
|
|||||||
{
|
{
|
||||||
id: 'enterprise',
|
id: 'enterprise',
|
||||||
name: 'Enterprise',
|
name: 'Enterprise',
|
||||||
price: '$199',
|
price: 'Custom',
|
||||||
period: '/month',
|
period: '',
|
||||||
description: 'For large communities and management firms',
|
description: 'For large communities and management firms',
|
||||||
icon: IconCrown,
|
icon: IconCrown,
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
|
externalUrl: 'https://www.hoaledgeriq.com/#preview-signup',
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Unlimited units', included: true },
|
{ text: 'Unlimited units', included: true },
|
||||||
{ text: 'Everything in Professional', included: true },
|
{ text: 'Everything in Professional', included: true },
|
||||||
@@ -162,10 +163,10 @@ export function PricingPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group align="baseline" gap={4}>
|
<Group align="baseline" gap={4}>
|
||||||
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: 36 }}>
|
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: plan.externalUrl ? 28 : 36 }}>
|
||||||
{plan.price}
|
{plan.externalUrl ? 'Request Quote' : plan.price}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">{plan.period}</Text>
|
{plan.period && <Text size="sm" c="dimmed">{plan.period}</Text>}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<List spacing="xs" size="sm" center>
|
<List spacing="xs" size="sm" center>
|
||||||
@@ -193,10 +194,14 @@ export function PricingPage() {
|
|||||||
size="md"
|
size="md"
|
||||||
color={plan.color}
|
color={plan.color}
|
||||||
variant={plan.popular ? 'filled' : 'light'}
|
variant={plan.popular ? 'filled' : 'light'}
|
||||||
loading={loading === plan.id}
|
loading={!plan.externalUrl ? loading === plan.id : false}
|
||||||
onClick={() => handleSelectPlan(plan.id)}
|
onClick={() =>
|
||||||
|
plan.externalUrl
|
||||||
|
? window.open(plan.externalUrl, '_blank', 'noopener')
|
||||||
|
: handleSelectPlan(plan.id)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Get Started
|
{plan.externalUrl ? 'Request Quote' : 'Get Started'}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user