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
61 changes: 61 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2271,6 +2271,67 @@
"quantityMin": "Menge muss mindestens 0 sein",
"maxQuantityMin": "Maximale Menge muss mindestens 1 sein"
}
},
"public": {
"pageTitle": "Angebotsübersicht",
"validUntil": "Gültig bis {date}",
"preparedFor": "Erstellt für {client}",
"status": "Status",
"statusActive": "Aktiv",
"statusAccepted": "Angenommen",
"statusExpired": "Abgelaufen",
"statusRevoked": "Widerrufen",
"proposalExpiredBanner": "Angebot abgelaufen",
"proposalExpiredNotice": "Dieses Angebot hat sein Gültigkeitsdatum am {date} erreicht und kann nicht mehr online angenommen werden. Bitte kontaktieren Sie das Studio für ein aktualisiertes Angebot.",
"proposalRevokedBanner": "Link nicht mehr aktiv",
"proposalRevokedNotice": "Dieser Angebotslink wurde aktualisiert oder widerrufen. Bitte kontaktieren Sie das Studio für den aktuellen Link.",
"proposalAcceptedBanner": "Angebot angenommen",
"proposalAcceptedNotice": "Vielen Dank! Dieses Angebot wurde am {date} von {name} ({email}) angenommen.",
"basePackageTitle": "Basispaket",
"deliverablesTitle": "Enthaltene Leistungen",
"addonsTitle": "Optionale Zusatzleistungen",
"addonsSubtitle": "Wählen Sie optionale Zusatzleistungen oder passen Sie die Mengen an Ihre Wünsche an.",
"noAddons": "Keine optionalen Zusatzleistungen für dieses Angebot vorhanden.",
"quantity": "Menge",
"unitPrice": "Einzelpreis",
"total": "Gesamt",
"included": "Inklusive",
"pricingSummaryTitle": "Preisübersicht",
"subtotal": "Zwischensumme",
"vat": "USt.",
"vatRate": "USt. ({rate}%)",
"totalAmount": "Gesamtbetrag",
"pricesIncludeTax": "Preise verstehen sich inklusive Mehrwertsteuer",
"pricesExcludeTax": "Preise verstehen sich zuzüglich Mehrwertsteuer",
"kleinunternehmerNotice": "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet oder erhoben.",
"acceptProposalButton": "Angebot annehmen",
"accepting": "Wird angenommen...",
"studioContact": "Fragen? Kontaktieren Sie {studioName}",
"modal": {
"title": "Angebot annehmen",
"description": "Bitte prüfen und bestätigen Sie Ihre Angaben, um dieses Angebot verbindlich anzunehmen.",
"fullNameLabel": "Vollständiger Name",
"fullNamePlaceholder": "z. B. Max Mustermann",
"emailLabel": "E-Mail-Adresse",
"emailPlaceholder": "max@beispiel.de",
"agreementCheckbox": "Ich nehme dieses Angebot, die beschriebenen Leistungen und den Gesamtbetrag von {total} an.",
"confirmButton": "Verbindlich annehmen",
"cancelButton": "Abbrechen"
},
"validation": {
"nameRequired": "Vollständiger Name ist erforderlich",
"emailRequired": "E-Mail-Adresse ist erforderlich",
"emailInvalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"agreementRequired": "Sie müssen der Annahme des Angebots zustimmen"
},
"rateLimit": {
"title": "Zu viele Anfragen",
"message": "Zu viele Anfragen von Ihrer IP-Adresse. Bitte warten Sie einen Moment, bevor Sie es erneut versuchen."
},
"serviceUnavailable": {
"title": "Dienst nicht verfügbar",
"message": "Bitte versuchen Sie es in einem Moment erneut."
}
}
}
}
61 changes: 61 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2271,6 +2271,67 @@
"quantityMin": "Quantity must be at least 0",
"maxQuantityMin": "Max quantity must be at least 1"
}
},
"public": {
"pageTitle": "Proposal Review",
"validUntil": "Valid until {date}",
"preparedFor": "Prepared for {client}",
"status": "Status",
"statusActive": "Active",
"statusAccepted": "Accepted",
"statusExpired": "Expired",
"statusRevoked": "Revoked",
"proposalExpiredBanner": "Proposal Expired",
"proposalExpiredNotice": "This proposal reached its validity expiration date on {date} and can no longer be accepted online. Please contact the studio for an updated proposal.",
"proposalRevokedBanner": "Link No Longer Active",
"proposalRevokedNotice": "This proposal link has been superseded or revoked. Please contact the studio for an updated link.",
"proposalAcceptedBanner": "Proposal Accepted",
"proposalAcceptedNotice": "Thank you! This proposal was accepted on {date} by {name} ({email}).",
"basePackageTitle": "Base Service Package",
"deliverablesTitle": "Included Deliverables",
"addonsTitle": "Optional Add-ons & Services",
"addonsSubtitle": "Select optional add-ons or customize quantities to tailor the package to your needs.",
"noAddons": "No optional add-on services attached to this proposal.",
"quantity": "Quantity",
"unitPrice": "Unit Price",
"total": "Total",
"included": "Included",
"pricingSummaryTitle": "Pricing Summary",
"subtotal": "Subtotal",
"vat": "VAT",
"vatRate": "VAT ({rate}%)",
"totalAmount": "Total Amount",
"pricesIncludeTax": "Prices include applicable VAT",
"pricesExcludeTax": "Prices exclude applicable VAT",
"kleinunternehmerNotice": "According to § 19 UStG, no value-added tax is calculated or charged.",
"acceptProposalButton": "Accept Proposal",
"accepting": "Accepting Proposal...",
"studioContact": "Questions? Contact {studioName}",
"modal": {
"title": "Accept Proposal",
"description": "Please review and confirm your details to accept this proposal and enter into a binding agreement.",
"fullNameLabel": "Full Name",
"fullNamePlaceholder": "e.g. Jane Doe",
"emailLabel": "Email Address",
"emailPlaceholder": "jane@example.com",
"agreementCheckbox": "I accept this proposal, the deliverables described, and the total amount of {total}.",
"confirmButton": "Confirm & Accept Proposal",
"cancelButton": "Cancel"
},
"validation": {
"nameRequired": "Full name is required",
"emailRequired": "Email address is required",
"emailInvalid": "Please enter a valid email address",
"agreementRequired": "You must confirm acceptance of the proposal terms"
},
"rateLimit": {
"title": "Too Many Requests",
"message": "Too many requests from your IP address. Please wait a moment before trying again."
},
"serviceUnavailable": {
"title": "Service Unavailable",
"message": "Please try again in a moment."
}
}
}
}
207 changes: 207 additions & 0 deletions src/app/[locale]/p/[token]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { headers } from 'next/headers';
import { createHash } from 'node:crypto';
import { eq, asc, desc } from 'drizzle-orm';
import { db } from '@/server/db/client';
import { routing } from '@/i18n/routing';
import { resolveClientIp } from '@/server/http/client-ip';
import { consumeProposalRateLimit } from '@/server/auth/rate-limiter';
import { proposals, proposalLineItems } from '@/server/db/schema/proposals';
import { clientContacts } from '@/server/db/schema/clients';
import { getStudioSettings } from '@/server/settings/repo';
import { PublicProposalView } from '@/components/proposals/public/public-proposal-view';
import { ShieldAlert } from 'lucide-react';
import { getTranslations } from 'next-intl/server';

export const dynamic = 'force-dynamic';

interface ProposalPageProps {
params: Promise<{ locale: string; token: string }>;
}

export async function generateMetadata({ params }: ProposalPageProps): Promise<Metadata> {
const { token } = await params;
let studioName = 'Ownlight Studio';
try {
const settings = await getStudioSettings();
if (settings.studio_name) {
studioName = settings.studio_name;
}
} catch {
studioName = 'Ownlight Studio';
}

const tokenHash = createHash('sha256').update(token).digest('hex');
const [proposal] = await db
.select({ title: proposals.title })
.from(proposals)
.where(eq(proposals.tokenHash, tokenHash))
.limit(1);

const title = proposal ? `${proposal.title} | ${studioName}` : `Proposal | ${studioName}`;

return {
title,
robots: {
index: false,
follow: false,
nocache: true,
},
};
}

/**
* Public Proposal Acceptance Page at /[locale]/p/[token] (TASK-0062 / ADR-0018 §3).
* Cookieless, rate-limited, mobile-first presentation of commercial deliverables and options.
*/
export default async function PublicProposalPage({ params }: ProposalPageProps) {
const { locale, token } = await params;

if (!routing.locales.includes(locale as 'en' | 'de')) {
notFound();
}

// 1. Rate limiting check (30 requests/minute per client IP)
let clientIp = '127.0.0.1';
try {
const h = await headers();
clientIp = resolveClientIp(h) ?? '127.0.0.1';
} catch {
// Outside request context
}

let isRateLimited = false;
let isRateLimitError = false;

try {
const rateCheck = await consumeProposalRateLimit(clientIp);
if (!rateCheck.allowed) {
isRateLimited = true;
}
} catch {
isRateLimitError = true;
}

const t = await getTranslations({ locale, namespace: 'proposals.public' });

if (isRateLimited) {
return (
<div
className="mx-auto max-w-md px-4 py-16 text-center space-y-4"
data-testid="rate-limit-exceeded"
>
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400">
<ShieldAlert className="h-6 w-6" aria-hidden="true" />
</div>
<h1 className="text-xl font-bold text-foreground">{t('rateLimit.title')}</h1>
<p className="text-sm text-muted-foreground">{t('rateLimit.message')}</p>
</div>
);
}

if (isRateLimitError) {
return (
<div className="mx-auto max-w-md px-4 py-16 text-center space-y-4">
<h1 className="text-xl font-bold text-foreground">{t('serviceUnavailable.title')}</h1>
<p className="text-sm text-muted-foreground">{t('serviceUnavailable.message')}</p>
</div>
);
}

// 2. Lookup proposal by token_hash
const tokenHash = createHash('sha256').update(token).digest('hex');
const [proposal] = await db
.select()
.from(proposals)
.where(eq(proposals.tokenHash, tokenHash))
.limit(1);

if (!proposal) {
notFound();
}

// 3. Query line items
const lineItems = await db
.select()
.from(proposalLineItems)
.where(eq(proposalLineItems.proposalId, proposal.id))
.orderBy(asc(proposalLineItems.position), asc(proposalLineItems.createdAt));

// 4. Resolve studio name and client contacts
let studioName = 'Ownlight Studio';
try {
const settings = await getStudioSettings();
if (settings.studio_name) {
studioName = settings.studio_name;
}
} catch {
studioName = 'Ownlight Studio';
}

let clientContactName = proposal.acceptedByName ?? '';
let clientContactEmail = proposal.acceptedByEmail ?? '';

if (!clientContactName || !clientContactEmail) {
const [contact] = await db
.select({
givenName: clientContacts.givenName,
familyName: clientContacts.familyName,
email: clientContacts.email,
})
.from(clientContacts)
.where(eq(clientContacts.clientId, proposal.clientId))
.orderBy(desc(clientContacts.isPrimary), asc(clientContacts.createdAt))
.limit(1);

if (contact) {
const contactFullName = [contact.givenName, contact.familyName]
.filter(Boolean)
.join(' ')
.trim();
if (!clientContactName && contactFullName) clientContactName = contactFullName;
if (!clientContactEmail && contact.email) clientContactEmail = contact.email;
}
}

return (
<PublicProposalView
proposal={{
id: proposal.id,
title: proposal.title,
status: proposal.status,
currency: proposal.currency,
subtotalMinor: proposal.subtotalMinor,
taxMinor: proposal.taxMinor,
totalMinor: proposal.totalMinor,
pricesIncludeTax: proposal.pricesIncludeTax,
isKleinunternehmer: proposal.isKleinunternehmer,
taxNotice: proposal.taxNotice,
validUntil: proposal.validUntil ? proposal.validUntil.toISOString() : null,
tokenRevokedAt: proposal.tokenRevokedAt ? proposal.tokenRevokedAt.toISOString() : null,
acceptedAt: proposal.acceptedAt ? proposal.acceptedAt.toISOString() : null,
acceptedByName: proposal.acceptedByName,
acceptedByEmail: proposal.acceptedByEmail,
lineItems: lineItems.map((item) => ({
id: item.id,
itemType: item.itemType,
name: item.name,
description: item.description,
unitPriceMinor: item.unitPriceMinor,
quantity: item.quantity,
minQuantity: item.minQuantity,
maxQuantity: item.maxQuantity,
taxRateBasisPoints: item.taxRateBasisPoints,
isOptional: item.isOptional,
isSelected: item.isSelected,
position: item.position,
})),
}}
studioName={studioName}
token={token}
locale={locale}
clientContactName={clientContactName}
clientContactEmail={clientContactEmail}
/>
);
}
Loading
Loading