Compare commits

..

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
ba5007e194 Address code review: show actual role instead of generic message
Co-authored-by: ephraimduncan <55143799+ephraimduncan@users.noreply.github.com>
2025-10-29 00:03:51 +00:00
copilot-swe-agent[bot]
416bc8d6c6 Improve billing settings accessibility with both solutions A and B
Co-authored-by: ephraimduncan <55143799+ephraimduncan@users.noreply.github.com>
2025-10-28 23:59:48 +00:00
copilot-swe-agent[bot]
8b3b005b41 Initial plan 2025-10-28 23:48:56 +00:00
6 changed files with 89 additions and 228 deletions

View File

@@ -282,18 +282,6 @@ export const OrgMenuSwitcher = () => {
</DropdownMenuItem>
)}
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/settings/profile">
<Trans>Account</Trans>
</Link>
</DropdownMenuItem>
{currentOrganisation &&
canExecuteOrganisationAction(
'MANAGE_ORGANISATION',
@@ -314,6 +302,18 @@ export const OrgMenuSwitcher = () => {
</DropdownMenuItem>
)}
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/settings/profile">
<Trans>Account</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="text-muted-foreground px-4 py-2"
onClick={() => setLanguageSwitcherOpen(true)}

View File

@@ -0,0 +1,74 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { CreditCardIcon } from 'lucide-react';
import { Link } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
export const AccountBillingOrganisations = () => {
const { _ } = useLingui();
const { user, organisations } = useSession();
if (!IS_BILLING_ENABLED()) {
return null;
}
// Filter to only organisations where user can manage billing
const billingOrganisations = organisations.filter((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
if (billingOrganisations.length === 0) {
return null;
}
return (
<div className="max-w-xl">
<h3 className="text-foreground mb-2 text-lg font-semibold">
<Trans>Billing Management</Trans>
</h3>
<p className="text-muted-foreground mb-4 text-sm">
<Trans>Manage billing for organisations where you have billing permissions.</Trans>
</p>
<div className="space-y-3">
{billingOrganisations.map((org) => (
<Card key={org.id} className="overflow-hidden">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<AvatarWithText
avatarSrc={formatAvatarUrl(org.avatarImageId)}
avatarClass="h-10 w-10"
avatarFallback={org.name.slice(0, 1).toUpperCase()}
primaryText={<span className="font-medium">{org.name}</span>}
secondaryText={
org.ownerUserId === user.id
? _(msg`Owner`)
: _(ORGANISATION_MEMBER_ROLE_MAP[org.currentOrganisationRole])
}
/>
</div>
<Button variant="outline" size="sm" asChild>
<Link to={`/o/${org.url}/settings/billing`}>
<CreditCardIcon className="mr-2 h-4 w-4" />
<Trans>Manage Billing</Trans>
</Link>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
};

View File

@@ -10,6 +10,7 @@ import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animat
import { AccountDeleteDialog } from '~/components/dialogs/account-delete-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { ProfileForm } from '~/components/forms/profile';
import { AccountBillingOrganisations } from '~/components/general/organisations/account-billing-organisations';
import { SettingsHeader } from '~/components/general/settings-header';
import { TeamEmailUsage } from '~/components/general/teams/team-email-usage';
import { appMetaTags } from '~/utils/meta';
@@ -47,6 +48,8 @@ export default function SettingsProfile() {
)}
</AnimatePresence>
<AccountBillingOrganisations />
<AccountDeleteDialog />
</div>
</div>

13
package-lock.json generated
View File

@@ -19,7 +19,6 @@
"inngest-cli": "^0.29.1",
"luxon": "^3.5.0",
"mupdf": "^1.0.0",
"pdf2json": "^4.0.0",
"react": "^18",
"typescript": "5.6.2",
"zod": "3.24.1"
@@ -27199,18 +27198,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdf2json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/pdf2json/-/pdf2json-4.0.0.tgz",
"integrity": "sha512-WkezNsLK8sGpuFC7+PPP0DsXROwdoOxmXPBTtUWWkCwCi/Vi97MRC52Ly6FWIJjOKIywpm/L2oaUgSrmtU+7ZQ==",
"license": "Apache-2.0",
"bin": {
"pdf2json": "bin/pdf2json.js"
},
"engines": {
"node": ">=20.18.0"
}
},
"node_modules/pdfjs-dist": {
"version": "3.11.174",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",

View File

@@ -74,7 +74,6 @@
"inngest-cli": "^0.29.1",
"luxon": "^3.5.0",
"mupdf": "^1.0.0",
"pdf2json": "^4.0.0",
"react": "^18",
"typescript": "5.6.2",
"zod": "3.24.1"

View File

@@ -1,202 +0,0 @@
import { PDFDocument, rgb } from '@cantoo/pdf-lib';
import PDFParser from 'pdf2json';
import { getPageSize } from './get-page-size';
type TextPosition = {
text: string;
x: number;
y: number;
w: number;
};
type CharIndexMapping = {
textPosIndex: number;
};
type PlaceholderInfo = {
placeholder: string;
fieldType: string;
recipient: string;
isRequired: string;
page: number;
// PDF2JSON coordinates (in page units - these are relative to page dimensions)
x: number;
y: number;
width: number;
height: number;
// Page dimensions from PDF2JSON (in page units)
pageWidth: number;
pageHeight: number;
};
/*
Questions for later:
- Does it handle multi-page PDFs?
- What happens with incorrect placeholders? E.g. those containing non-accepted properties.
- The placeholder data is dynamic. How to handle this parsing? Perhaps we need to do it similar to the fieldMeta parsing.
*/
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
return new Promise((resolve, reject) => {
const parser = new PDFParser(null, true);
parser.on('pdfParser_dataError', (errData) => {
reject(errData);
});
parser.on('pdfParser_dataReady', (pdfData) => {
const placeholders: PlaceholderInfo[] = [];
pdfData.Pages.forEach((page, pageIndex) => {
/*
pdf2json returns the PDF page content as an array of characters.
We need to concatenate the characters to get the full text.
We also need to get the position of the text so we can place the placeholders in the correct position.
Page dimensions from PDF2JSON are in "page units" (relative coordinates)
*/
const pageWidth = page.Width;
const pageHeight = page.Height;
let pageText = '';
const textPositions: TextPosition[] = [];
const charIndexToTextPos: CharIndexMapping[] = [];
page.Texts.forEach((text) => {
/*
R is an array that contains objects with each character.
The decodedText contains only the character, without any other information.
textPositions stores each character and its position on the page.
*/
const decodedText = text.R.map((run) => decodeURIComponent(run.T)).join('');
for (let i = 0; i < decodedText.length; i++) {
charIndexToTextPos.push({
textPosIndex: textPositions.length,
});
}
pageText += decodedText;
textPositions.push({
text: decodedText,
x: text.x,
y: text.y,
w: text.w || 0,
});
});
const placeholderMatches = pageText.matchAll(/{{([^}]+)}}/g);
for (const match of placeholderMatches) {
const placeholder = match[0];
const placeholderData = match[1].split(',').map((part) => part.trim());
const [fieldType, recipient, isRequired] = placeholderData;
/*
Find the position of where the placeholder starts in the text
Then find the position of where the placeholder ends in the text by adding the length of the placeholder to the index of the placeholder.
*/
const matchIndex = match.index;
const placeholderLength = placeholder.length;
const placeholderEndIndex = matchIndex + placeholderLength;
const startCharInfo = charIndexToTextPos[matchIndex];
const endCharInfo = charIndexToTextPos[placeholderEndIndex - 1];
if (!startCharInfo || !endCharInfo) {
console.error('Could not find text position for placeholder', placeholder);
return;
}
const startTextPos = textPositions[startCharInfo.textPosIndex];
const endTextPos = textPositions[endCharInfo.textPosIndex];
/*
PDF2JSON coordinates - these are in "page units" (relative coordinates)
Calculate width as the distance from start to end, plus a portion of the last character's width
Use 10% of the last character width to avoid extending too far beyond the placeholder
*/
const x = startTextPos.x;
const y = startTextPos.y;
const width = endTextPos.x + endTextPos.w * 0.1 - startTextPos.x;
placeholders.push({
placeholder,
fieldType,
recipient,
isRequired,
page: pageIndex + 1,
x,
y,
width,
height: 1,
pageWidth,
pageHeight,
});
}
});
resolve(placeholders);
});
parser.parseBuffer(pdf);
});
};
export const replacePlaceholdersInPDF = async (pdf: Buffer): Promise<Buffer> => {
const placeholders = await extractPlaceholdersFromPDF(pdf);
const pdfDoc = await PDFDocument.load(new Uint8Array(pdf));
const pages = pdfDoc.getPages();
for (const placeholder of placeholders) {
const pageIndex = placeholder.page - 1;
const page = pages[pageIndex];
const { width: pdfLibPageWidth, height: pdfLibPageHeight } = getPageSize(page);
/*
Convert PDF2JSON coordinates to pdf-lib coordinates:
PDF2JSON uses relative "page units":
- x, y, width, height are in page units
- Page dimensions (Width, Height) are also in page units
pdf-lib uses absolute points (1 point = 1/72 inch):
- Need to convert from page units to points
- Y-axis in pdf-lib is bottom-up (origin at bottom-left)
- Y-axis in PDF2JSON is top-down (origin at top-left)
Conversion formulas:
- x_points = (x / pageWidth) * pdfLibPageWidth
- y_points = pdfLibPageHeight - ((y / pageHeight) * pdfLibPageHeight)
- width_points = (width / pageWidth) * pdfLibPageWidth
- height_points = (height / pageHeight) * pdfLibPageHeight
*/
const xPoints = (placeholder.x / placeholder.pageWidth) * pdfLibPageWidth;
const yPoints = pdfLibPageHeight - (placeholder.y / placeholder.pageHeight) * pdfLibPageHeight;
const widthPoints = (placeholder.width / placeholder.pageWidth) * pdfLibPageWidth;
const heightPoints = (placeholder.height / placeholder.pageHeight) * pdfLibPageHeight;
page.drawRectangle({
x: xPoints,
y: yPoints - heightPoints, // Adjust for height since y is at baseline
width: widthPoints,
height: heightPoints,
color: rgb(1, 1, 1),
borderColor: rgb(1, 1, 1),
borderWidth: 2,
});
}
const modifiedPdfBytes = await pdfDoc.save();
return Buffer.from(modifiedPdfBytes);
};