Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/api/stripe/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { logAuditEvent } from '@/lib/audit-log';
import { db } from '@/lib/db';
import { getConfiguredAppBaseUrl } from '@/lib/env.server';
import { getCorrelationIdFromRequest, reportApplicationError, withCorrelationIdHeader } from '@/lib/observability';
import { isPortfolioDemoMode, PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo';
import { isAllowedRequestOrigin } from '@/lib/request-origin';
import { getStripe } from '@/lib/stripe';
import { absoluteUrl } from '@/lib/url';
Expand All @@ -24,6 +25,10 @@ export async function POST(request: Request) {
if (process.env.NODE_ENV === 'production' && !isAllowedRequestOrigin(request, getConfiguredAppBaseUrl())) {
return withCorrelation(NextResponse.json({ error: 'Invalid request origin' }, { status: 403 }));
}
if (isPortfolioDemoMode()) {
return withCorrelation(errorRedirect(PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE));
}

const { userId } = await auth();
if (!userId) {
return withCorrelation(NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 }));
Expand Down
7 changes: 7 additions & 0 deletions app/api/stripe/portal/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { logAuditEvent } from '@/lib/audit-log';
import { db } from '@/lib/db';
import { getConfiguredAppBaseUrl } from '@/lib/env.server';
import { getCorrelationIdFromRequest, reportApplicationError, withCorrelationIdHeader } from '@/lib/observability';
import { isPortfolioDemoMode, PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo';
import { isAllowedRequestOrigin } from '@/lib/request-origin';
import { getStripe } from '@/lib/stripe';
import { absoluteUrl } from '@/lib/url';
Expand All @@ -19,6 +20,12 @@ export async function POST(request: Request) {
if (process.env.NODE_ENV === 'production' && !isAllowedRequestOrigin(request, getConfiguredAppBaseUrl())) {
return withCorrelation(NextResponse.json({ error: 'Invalid request origin' }, { status: 403 }));
}
if (isPortfolioDemoMode()) {
return withCorrelation(
NextResponse.redirect(absoluteUrl(`/app/billing?error=${encodeURIComponent(PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE)}`), { status: 303 })
);
}

const { userId } = await auth();
if (!userId) {
return withCorrelation(NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 }));
Expand Down
29 changes: 24 additions & 5 deletions app/app/billing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { requireBusiness } from '@/lib/auth';
import { getBillingUsageSnapshotForBusiness } from '@/lib/business-access';
import { db } from '@/lib/db';
import { getPortfolioDemoBlockedCount, isPortfolioDemoMode } from '@/lib/portfolio-demo';
import { getPortfolioDemoBlockedCount, isPortfolioDemoMode, PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo';
import { getBusinessBillingAccessState } from '@/lib/subscription';
import { getBillingDisplayLabel } from '@/lib/system-status';
import { getStripe } from '@/lib/stripe';
Expand Down Expand Up @@ -212,6 +212,7 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
</div>

{error ? <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{error}</div> : null}
{demoMode ? <div className="rounded-md border bg-muted/40 p-3 text-sm">{PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE}</div> : null}
{requestedPlan ? (
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-sm">
Selected plan: <strong>{requestedPlan === 'starter' ? 'Starter' : 'Growth'}</strong>. Continue with the checkout card below.
Expand Down Expand Up @@ -250,7 +251,9 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
<div className="flex flex-wrap gap-3">
{business.stripeCustomerId ? (
<form action="/api/stripe/portal" method="post">
<Button type="submit">Update Payment Method</Button>
<Button disabled={demoMode} title={demoMode ? PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE : undefined} type="submit">
Update Payment Method
</Button>
</form>
) : (
<Link className={buttonVariants()} href="#plan-options">
Expand Down Expand Up @@ -332,7 +335,12 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
<CardFooter>
<form action="/api/stripe/checkout" method="post" className="w-full">
<input type="hidden" name="priceId" value={starterPriceId ?? ''} />
<Button type="submit" className="w-full" disabled={!starterPriceId}>
<Button
type="submit"
className="w-full"
disabled={demoMode || !starterPriceId}
title={demoMode ? PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE : undefined}
>
Choose Starter
</Button>
</form>
Expand All @@ -351,7 +359,12 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
<CardFooter>
<form action="/api/stripe/checkout" method="post" className="w-full">
<input type="hidden" name="priceId" value={growthPriceId ?? ''} />
<Button type="submit" className="w-full" disabled={!growthPriceId}>
<Button
type="submit"
className="w-full"
disabled={demoMode || !growthPriceId}
title={demoMode ? PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE : undefined}
>
Choose Growth
</Button>
</form>
Expand All @@ -372,7 +385,13 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
</Link>
{business.stripeCustomerId ? (
<form action="/api/stripe/portal" method="post" className="w-full">
<Button type="submit" variant="outline" className="w-full">
<Button
type="submit"
variant="outline"
className="w-full"
disabled={demoMode}
title={demoMode ? PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE : undefined}
>
Open Billing Portal
</Button>
</form>
Expand Down
16 changes: 15 additions & 1 deletion app/app/leads/[leadId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
smsStateLabels,
} from '@/lib/lead-presenters';
import { formatPhoneForDisplay } from '@/lib/phone';
import { getPortfolioDemoLeadDetail, isPortfolioDemoMode } from '@/lib/portfolio-demo';
import { getPortfolioDemoLeadDetail, isPortfolioDemoMode, PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo';

function resolveSafeReturnPath(value: string | null | undefined) {
if (!value) return '/app/leads';
Expand Down Expand Up @@ -55,14 +55,24 @@ function LeadStatusActionForm({
successRedirectTo,
label,
variant,
disabled = false,
}: {
leadId: string;
status: 'CONTACTED' | 'BOOKED' | 'LOST';
redirectTo: string;
successRedirectTo: string;
label: string;
variant?: 'default' | 'outline' | 'destructive';
disabled?: boolean;
}) {
if (disabled) {
return (
<Button className="w-full" disabled title={PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE} type="button" variant={variant}>
{label}
</Button>
);
}

return (
<form action={updateLeadStatusAction}>
<input type="hidden" name="leadId" value={leadId} />
Expand Down Expand Up @@ -126,6 +136,7 @@ export default async function LeadDetailPage({

{error ? <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{error}</div> : null}
{saved ? <div className="rounded-md border border-accent bg-accent/40 p-3 text-sm">Lead updated.</div> : null}
{demoMode ? <div className="rounded-md border bg-muted/40 p-3 text-sm">{PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE}</div> : null}
{messageIssues.length > 0 ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
This lead had an SMS delivery issue. Manual follow-up is recommended.
Expand Down Expand Up @@ -168,13 +179,15 @@ export default async function LeadDetailPage({
successRedirectTo={successRedirectTo}
label="Mark contacted"
variant="outline"
disabled={demoMode}
/>
<LeadStatusActionForm
leadId={lead.id}
status="BOOKED"
redirectTo={detailRedirectTo}
successRedirectTo={successRedirectTo}
label="Mark booked"
disabled={demoMode}
/>
<LeadStatusActionForm
leadId={lead.id}
Expand All @@ -183,6 +196,7 @@ export default async function LeadDetailPage({
successRedirectTo={successRedirectTo}
label="Mark lost"
variant="destructive"
disabled={demoMode}
/>
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions app/app/leads/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { redirect } from 'next/navigation';

import { requireBusiness } from '@/lib/auth';
import { updateLeadStatusForBusiness } from '@/lib/business-access';
import { isPortfolioDemoMode, PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo';
import { leadStatusSchema } from '@/lib/validators';

function resolveSafeAppRedirect(value: FormDataEntryValue | null, fallback: string) {
Expand All @@ -20,6 +21,10 @@ export async function updateLeadStatusAction(formData: FormData) {
const redirectTo = resolveSafeAppRedirect(formData.get('redirectTo'), fallbackPath);
const successRedirectTo = resolveSafeAppRedirect(formData.get('successRedirectTo'), redirectTo);

if (isPortfolioDemoMode()) {
redirect(`${redirectTo}${redirectTo.includes('?') ? '&' : '?'}error=${encodeURIComponent(PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE)}`);
}

const business = await requireBusiness();
const parsed = leadStatusSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
Expand Down
5 changes: 5 additions & 0 deletions app/app/settings/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { averageJobValueDollarsToCents } from '@/lib/business-settings';
import { db } from '@/lib/db';
import { formatPhoneDetail, maskSid, recordBusinessOperatorEvent } from '@/lib/operator-events';
import { maskPhoneForAudit, normalizePhoneNumber, normalizePhoneNumberToE164 } from '@/lib/phone';
import { isPortfolioDemoMode, PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo';
import {
getBusinessTwilioSaveErrorMessage,
getMessagingComplianceSidValidationError,
Expand Down Expand Up @@ -87,6 +88,10 @@ function redirectToSettingsError(message: string): never {
}

export async function saveBusinessSettingsAction(formData: FormData) {
if (isPortfolioDemoMode()) {
redirect(`/app/settings?error=${encodeURIComponent(PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE)}`);
}

const business = await getBusinessForOwner();
const parsed = businessSettingsSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
Expand Down
9 changes: 7 additions & 2 deletions app/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { getBusinessNotificationSettingsForBusiness } from '@/lib/business-acces
import { getBusinessRoutingNumber, getPublicBusinessPhone } from '@/lib/business-phone-setup';
import { db } from '@/lib/db';
import { formatPhoneForDisplay } from '@/lib/phone';
import { isPortfolioDemoMode } from '@/lib/portfolio-demo';
import { isPortfolioDemoMode, PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo';
import { getCustomerSystemStatus } from '@/lib/system-status';

import { saveBusinessSettingsAction } from './actions';
Expand Down Expand Up @@ -133,6 +133,7 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re

{error ? <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{error}</div> : null}
{saved ? <div className="rounded-md border border-accent bg-accent/40 p-3 text-sm">Business settings saved.</div> : null}
{demoMode ? <div className="rounded-md border bg-muted/40 p-3 text-sm">{PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE}</div> : null}

<section className="grid gap-4 md:grid-cols-3">
{statusCards.map((item) => (
Expand All @@ -157,6 +158,7 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re
</CardHeader>
<CardContent>
<form action={saveBusinessSettingsAction} className="grid gap-4 md:grid-cols-2">
<fieldset disabled={demoMode} className="contents">
<input type="hidden" name="phoneSetupPath" value={business.phoneSetupPath} />
<input type="hidden" name="forwardedCallAnswerMode" value={business.forwardedCallAnswerMode} />
<input type="hidden" name="messagingSetupMode" value={business.messagingSetupMode} />
Expand Down Expand Up @@ -239,8 +241,11 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re
</div>
</label>
<div className="md:col-span-2">
<Button type="submit">Save settings</Button>
<Button disabled={demoMode} title={demoMode ? PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE : undefined} type="submit">
Save settings
</Button>
</div>
</fieldset>
</form>
</CardContent>
</Card>
Expand Down
21 changes: 17 additions & 4 deletions components/home-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { Button, buttonVariants } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { getLeadStatusBadgeVariant, leadStatusLabels } from '@/lib/lead-presenters';
import { PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo';
import { cn } from '@/lib/utils';

type DashboardFeedback = {
Expand Down Expand Up @@ -48,13 +49,23 @@ function LeadStatusActionForm({
redirectTo,
label,
variant,
disabled = false,
}: {
leadId: string;
status: 'CONTACTED' | 'BOOKED' | 'LOST';
redirectTo: string;
label: string;
variant?: 'default' | 'outline' | 'destructive';
disabled?: boolean;
}) {
if (disabled) {
return (
<Button disabled size="sm" title={PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE} type="button" variant={variant}>
{label}
</Button>
);
}

return (
<form action={updateLeadStatusAction}>
<input type="hidden" name="leadId" value={leadId} />
Expand All @@ -68,7 +79,7 @@ function LeadStatusActionForm({
);
}

function LeadAttentionCard({ lead }: { lead: DashboardLeadCard }) {
function LeadAttentionCard({ isDemoMode, lead }: { isDemoMode: boolean; lead: DashboardLeadCard }) {
return (
<Card className="border-primary/15 bg-card/95">
<CardHeader className="space-y-3">
Expand Down Expand Up @@ -100,9 +111,10 @@ function LeadAttentionCard({ lead }: { lead: DashboardLeadCard }) {
<Link className={buttonVariants({ size: 'sm', variant: 'outline' })} href={lead.leadHref}>
Open lead
</Link>
<LeadStatusActionForm leadId={lead.id} label="Mark contacted" redirectTo="/app" status="CONTACTED" variant="outline" />
<LeadStatusActionForm leadId={lead.id} label="Mark booked" redirectTo="/app" status="BOOKED" />
<LeadStatusActionForm disabled={isDemoMode} leadId={lead.id} label="Mark contacted" redirectTo="/app" status="CONTACTED" variant="outline" />
<LeadStatusActionForm disabled={isDemoMode} leadId={lead.id} label="Mark booked" redirectTo="/app" status="BOOKED" />
</div>
{isDemoMode ? <p className="text-xs text-muted-foreground">{PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE}</p> : null}
</CardContent>
</Card>
);
Expand Down Expand Up @@ -149,6 +161,7 @@ export function HomeDashboard({
<div className="space-y-6">
{feedback.error ? <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{feedback.error}</div> : null}
{feedback.saved ? <div className="rounded-md border border-accent bg-accent/40 p-3 text-sm">Lead updated.</div> : null}
{isDemoMode ? <div className="rounded-md border bg-muted/40 p-3 text-sm">{PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE}</div> : null}

<section className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
Expand Down Expand Up @@ -220,7 +233,7 @@ export function HomeDashboard({
) : (
<div className="grid gap-4 2xl:grid-cols-2">
{attentionLeads.map((lead) => (
<LeadAttentionCard key={lead.id} lead={lead} />
<LeadAttentionCard key={lead.id} isDemoMode={isDemoMode} lead={lead} />
))}
</div>
)}
Expand Down
2 changes: 2 additions & 0 deletions lib/portfolio-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ type LeadDetailRecord = Lead & { call: Call | null; messages: Message[]; ownerNo
const DEMO_USER_ID = 'user_portfolio_demo';
const DEMO_BUSINESS_ID = 'biz_portfolio_demo';

export const PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE = 'Demo mode only - actions are disabled.';

export function isPortfolioDemoMode() {
return process.env.PORTFOLIO_DEMO_MODE === '1';
}
Expand Down
Loading
Loading