feat: add QR code functionality to invoice templates and other improvements + bug fixes (#165)

* feat: add QR code functionality to invoice templates and enhance user experience

* Introduced QR code generation for invoices, allowing users to include a QR code with customizable descriptions.
* Updated invoice templates to display QR codes in both default and Stripe formats.
* Enhanced form components to support QR code data input and visibility toggles.
* Added utility functions for generating QR code data URLs.
* Updated tests and snapshots to cover new QR code features and ensure visual consistency across templates.
* Introduced a Code of Conduct document to promote a respectful community environment.

* feat: enhance error handling and metadata management in invoice application

* Updated error handling to reset invoice metadata to default upon error occurrence.
* Refactored invoice client page to utilize a constant for default mobile tab value.
* Improved metadata structure by including last visited mobile tab in the default metadata.
* Adjusted schema to allow optional item name field for better flexibility in invoice items.

* fix: downgrade react-pdf version and update mobile tab handling

* Downgraded react-pdf from version 10.1.0 to 9.2.1 for compatibility.
* Updated mobile tab handling to utilize the last visited tab from app metadata.
* Refactored invoice client page to improve metadata management and ensure proper tab state persistence.

* fix: remove redundant validation message for item name in invoice form

* chore: update TODO list and comment out scroll to top effect in AppPageClient

* Added a new issue link to the TODO.md for tracking.
* Commented out the scroll to top effect in page.client.tsx for potential future use.

* refactor: streamline viewport settings and restore scroll to top effect in AppPageClient

* Simplified viewport configuration by removing unnecessary properties.
* Restored the scroll to top effect in AppPageClient for improved user experience on initial render.

* revert viewport

* fix: prevent jumping on iOS when typing in textarea component

* Added resize-none class to the textarea to improve user experience on iOS devices by preventing layout shifts while typing.

* feat: reworked app logic, improved multi-page pdf layout, add/update e2e tests, improvements and bug fixes

* Enhanced README.md with new features, including multi-page PDF support, QR code functionality, and live preview demos.
* Added demo GIFs to showcase new features and improve user understanding.
* Updated key features section for clarity and added a news & updates section for version tracking.

* feat: add debounced error handling in invoice form component

* Introduced a debounced callback for showing form errors to improve user experience by preventing rapid toast notifications.
* Updated validation logic to utilize the new debounced error handling mechanism.

* chore: update e2e snapshots and improved form errors toast

* fix: improve PDF viewer and QR code layout in invoice templates

* Added state management for page numbers in the mobile PDF viewer to handle multi-page documents.
* Adjusted QR code positioning and size to prevent overlap with the fixed footer.
* Updated padding in the Stripe template styles to resolve overlapping issues with the footer.
* Updated TODO.md with additional context on preventing layout issues in PDF rendering.

* feat: enhance invoice sharing logic with validation error handling

* Added a new test to verify error toast visibility when the invoice form has validation errors.
* Implemented state management for form validation errors in the AppPageClient.
* Updated the InvoiceForm component to manage error states and trigger appropriate toasts for user feedback.
* Ensured that the share button behavior reflects the form's validation state, improving user experience.

* feat: enhance invoice template with authorized person fields

* Added fields for Person Authorized to Receive and Person Authorized to Issue in the default invoice template.
* Implemented visibility toggles for these fields in the invoice form.
* Updated tests to verify the correct behavior of these fields in both default and Stripe invoice templates.
* Enhanced PDF rendering to include names of authorized persons when applicable.

* feat: implement cooldown for CTA toasts and update UI elements

* Introduced a 5-minute cooldown for showing CTA toasts to enhance user experience.
* Updated toast management logic in various components to respect the new cooldown.
* Adjusted text color in the InvoiceClientPage for better visibility.
* Refined tooltip content in the invoice form to clarify functionality.
* Updated TODO.md to reflect changes in toast behavior.

* feat: add unit column switch to stripe invoice template

* Changed toggle labels from Show/hide to Show for various fields in the invoice forms and dialogs to enhance clarity.
* Updated related test cases to reflect the new label changes across buyer, seller, and invoice templates.
* Ensured consistency in user interface elements for better user experience.

* chore: adjust text size

* feat: update README and improve CTA toast logic

* Replaced the EasyInvoicePDF logo with a new design and adjusted its size for better visibility.
* Enhanced the call-to-action (CTA) toast functionality by refining the logic for showing toasts based on user interactions and session activity.
* Updated text in the invoice form to clarify user actions and improve overall user experience.
* Added a new logo image to the project assets.

* update readme

* refactor: improved mobile PDF viewer by importing the PDF worker directl, improve CTA toast logic, update readme

* Renamed sections in README for clarity, including Live Preview to Invoice PDF Live Preview and Instant Download to Instant PDF Download.
* Adjusted text in CTA toasts for better engagement.
* Updated minimum time on page and interactions required for showing CTA toasts to enhance user experience.
* Improved mobile PDF viewer by importing the PDF worker directly and addressing related issues in TODO.md.

* fix: adjust CTA toast display timing for improved user experience

* Updated the timeout for showing the CTA toast to 6 seconds after the invoice link notification, enhancing the visibility and timing of user prompts.

* feat: Improved handling of invoice sharing logic to differentiate between mobile and desktop sharing methods, enhancing user experience.

* Added a new command in package.json for running cloudflared tunnel.
* Updated tests to verify the visibility of the share invoice link description toast.
* Refined toast notifications in the AppPageClient to include new IDs for better tracking and user feedback.
* Improved handling of invoice sharing logic to differentiate between mobile and desktop sharing methods, enhancing user experience.

* feat: enhance toast notifications with unique IDs for better ux

* fix: adjust invoice item limit in tests and update translations for total excluding tax

* Reduced the maximum number of invoice items in the test from 20 to 15 to better align with URL limits.
* Updated translations in the PDF i18n schema and related files to include total excluding tax in multiple languages.
* Modified the invoice PDF template to display the total excluding tax using localized text.
* fix qr code race condition

* fix: update URL variable names and enhance sharing logic for better clarity

* Renamed variables for generated URLs to improve code readability.
* Updated sharing logic to ensure consistent use of the new variable names across mobile and desktop sharing methods.
* Enhanced toast notifications to dismiss previous messages and track share events more effectively.

* fix: update tax label helper message for clarity in invoice items

* readme upd

* fix: adjust idle time and minimum interactions for CTA toast display

* Increased idle time from 3 seconds to 5 seconds to improve user engagement.
* Updated minimum interactions required for showing the CTA toast from 2 to 3 to enhance user experience.

* feat: implement cooldown logic for CTA toast display

* Added functionality to track the last shown timestamp of the CTA toast using localStorage.
* Introduced a 7-day cooldown period to prevent the toast from being shown multiple times within a week.
* Updated context to reflect whether the CTA toast was shown recently, enhancing user experience.

* chore: update dependencies and add GitHub workflows for linting and type checking

* Updated package versions in package.json and pnpm-lock.yaml for better compatibility and performance.
* Added GitHub Actions workflows for ESLint and TypeScript type checking to ensure code quality and consistency.
* Enhanced buyer and seller management components with improved state management and type safety.

* feat: enhance invoice sharing and download tracking

* Added functionality to track the number of times invoices are shared via link and downloaded as PDF.
* Updated the app metadata structure to include  and .
* Implemented logic to increment these counts upon sharing and downloading invoices, improving analytics and user engagement.

* refactor: update README and TODO for clarity and consistency
This commit is contained in:
Vlad Sazonau 2026-02-24 19:53:24 +01:00 committed by GitHub
parent b88ae5309c
commit 6192cca49d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
154 changed files with 5133 additions and 1529 deletions

BIN
.github/demos/instant-download.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

BIN
.github/demos/invoice-template.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
.github/demos/lang-currency.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
.github/demos/live-preview.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

BIN
.github/demos/qr-code.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 MiB

BIN
.github/demos/share-link.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

BIN
.github/demos/tax-custom.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

66
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,66 @@
name: 🧹 ESLint
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
name: Run linting
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
# we use pinned versions because there are safer to use: https://x.com/paulmillr/status/1900948425325031448
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
name: 🛎️ Checkout repository
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
name: 📦 Setup pnpm
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # 4.3.0
name: 📚 Setup Node.js
with:
node-version: lts/*
cache: "pnpm"
- name: 🚚 Install dependencies
run: pnpm install
- name: 🔎 Run linting
run: pnpm eslint .
- name: 🔍 Get PR URL
if: failure()
id: pr-url
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_URL=$(curl -s \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/pulls?head=${{ github.repository_owner }}:${{ github.ref_name }}" \
| jq -r '.[0].html_url')
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
- name: 📧 Send email on failure
if: failure()
uses: dawidd6/action-send-mail@611879133a9569642c41be66f4a323286e9b8a3b # v4
with:
server_address: smtp.gmail.com
server_port: 587
from: GitHub Actions
to: ${{ secrets.EMAIL_USERNAME }}
username: ${{ secrets.EMAIL_USERNAME }}
password: ${{ secrets.EMAIL_PASSWORD }}
subject: ❌ Linting Failed for ${{ github.repository }}
body: |
Linting for ${{ github.repository }} has failed.
Branch: ${{ github.ref_name }}
Commit: ${{ github.sha }}
For more details, please check:
- GitHub Actions run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
${{ steps.pr-url.outputs.pr_url && format('- Pull Request: {0}', steps.pr-url.outputs.pr_url) }}
- Commit: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}

66
.github/workflows/type-check.yml vendored Normal file
View file

@ -0,0 +1,66 @@
name: 📝 TypeScript Type Check
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
type-check:
name: Run type check
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
# we use pinned versions because there are safer to use: https://x.com/paulmillr/status/1900948425325031448
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
name: 🛎️ Checkout repository
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
name: 📦 Setup pnpm
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # 4.3.0
name: 📚 Setup Node.js
with:
node-version: lts/*
cache: "pnpm"
- name: 🚚 Install dependencies
run: pnpm install
- name: 🔍 Run type check
run: pnpm type-check:go
- name: 🔍 Get PR URL
if: failure()
id: pr-url
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_URL=$(curl -s \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/pulls?head=${{ github.repository_owner }}:${{ github.ref_name }}" \
| jq -r '.[0].html_url')
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
- name: 📧 Send email on failure
if: failure()
uses: dawidd6/action-send-mail@611879133a9569642c41be66f4a323286e9b8a3b # v4
with:
server_address: smtp.gmail.com
server_port: 587
from: GitHub Actions
to: ${{ secrets.EMAIL_USERNAME }}
username: ${{ secrets.EMAIL_USERNAME }}
password: ${{ secrets.EMAIL_PASSWORD }}
subject: ❌ Type Check Failed for ${{ github.repository }}
body: |
Type check for ${{ github.repository }} has failed.
Branch: ${{ github.ref_name }}
Commit: ${{ github.sha }}
For more details, please check:
- GitHub Actions run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
${{ steps.pr-url.outputs.pr_url && format('- Pull Request: {0}', steps.pr-url.outputs.pr_url) }}
- Commit: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}

View file

@ -31,6 +31,19 @@ jobs:
id: vitest
run: pnpm vitest run --reporter=verbose
- name: 🔍 Get PR URL
if: failure()
id: pr-url
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_URL=$(curl -s \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/pulls?head=${{ github.repository_owner }}:${{ github.ref_name }}" \
| jq -r '.[0].html_url')
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
- name: 📧 Send email on failure
if: failure()
uses: dawidd6/action-send-mail@611879133a9569642c41be66f4a323286e9b8a3b # v4
@ -50,3 +63,5 @@ jobs:
For more details, please check:
- GitHub Actions run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
${{ steps.pr-url.outputs.pr_url && format('- Pull Request: {0}', steps.pr-url.outputs.pr_url) }}
- Commit: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}

4
.gitignore vendored
View file

@ -51,4 +51,6 @@ node_modules/
.eslintcache
# i18n
messages/en.d.json.ts
messages/en.d.json.ts
.pnpm-store/

128
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
vlad@mail.easyinvoicepdf.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View file

@ -1,10 +1,9 @@
<div align="center">
<img src=".github/screenshots/easy-invoice-logo.svg" alt="EasyInvoicePDF Logo" width="80" height="80">
<img src=".github/screenshots/easy-invoice-new-logo.png" alt="EasyInvoicePDF Logo" width="160" height="160">
<h1>EasyInvoicePDF</h1>
<p><strong>Free & Open-Source Invoice Generator</strong></p>
<h3>Free & Open-Source Invoice Generator</h3>
<p>Create professional invoices instantly in your browser with <strong>Live Preview</strong>, <strong>Multiple Templates</strong> (including a Stripe-style design). <strong>No Sign-Up Required</strong>.</p>
<p><a href="https://easyinvoicepdf.com/?template=stripe&ref=github">Get Started</a></p>
<p><a href="https://easyinvoicepdf.com/?template=stripe&ref=github"><strong>Get Started</strong></a></p>
<p>
<a href="https://easyinvoicepdf.com/en/about?ref=github">About</a>
·
@ -15,7 +14,7 @@
</a>
</div>
## ✨ Key Features
## ✨ Key Features of EasyInvoicePDF:
- ⭐ **No Sign-Up Required**: Start creating invoices immediately without any registration
- 📄 **Instant PDF**: One-click download ready for printing or sending
@ -24,12 +23,81 @@
- 🎨 **Multiple Templates**: Including modern **Stripe-style design**
- 📱 **Browser Only**: No server uploads, your data stays private
- 💰 **Flexible Tax Support**: VAT, GST, Sales Tax, and custom tax formats with automatic calculations
- 🌍 **Multi-Language**: Support for 10+ languages and all major currencies
- 🌍 **Multi-Language & Currency**: Support for 10+ languages and 120+ currencies
- 📱 **Mobile-Friendly**: Create invoices on the go from any device
- 🏞️ **QR Code Support**: Add payment QR codes with any invoice-related information (payment links, UPI, contact details, custom data)
- 📑 **Multi-Page PDFs**: Seamless multi-page support with automatic pagination and page breaks
Learn more about [features](https://easyinvoicepdf.com/en/about#features).
<h4>Learn more about <a href="https://easyinvoicepdf.com/en/about#features">features</a>.</h4>
<img width="1440" height="769" alt="EasyInvoicePDF Default Template Screenshot" src=".github/screenshots/default-template.png" />
---
<div align="center">
### 🎬 Invoice PDF Live Preview
<img src=".github/demos/live-preview.gif" width="800" alt="Live Preview Demo">
_See changes in **real-time** as you type_
---
### 📥 Instant PDF Download
<img src=".github/demos/instant-download.gif" width="800" alt="Instant Download Demo">
_**One-click PDF download** ready for printing or sending_
---
### 🔗 Shareable Links
<img src=".github/demos/share-link.gif" width="800" alt="Shareable Links Demo">
_**Send invoices directly to clients** without attachments_
---
### 📲 QR Codes & Advanced Multi-Page PDF Support
<img src=".github/demos/qr-code.gif" width="800" alt="QR Code Support Demo">
_**Add payment QR codes** with any invoice-related information (payment links, UPI, contact details, custom data) and **seamless multi-page support** with automatic pagination and page breaks for large invoices_
---
### 🏷️ Customizable Tax Settings
<img src=".github/demos/tax-custom.gif" width="800" alt="Customizable Tax Settings Demo">
_**Customize tax labels** (VAT, Sales Tax, IVA, etc.)_
---
### 🌍 Language & Currency
<img src=".github/demos/lang-currency.gif" width="800" alt="Language & Currency Demo">
_**Switch between 10 languages and 120+ currencies instantly** with live PDF preview updates_
---
### 🎨 Professional Invoice Templates
<img src=".github/demos/invoice-template.gif" width="800" alt="Invoice Templates Demo">
_**Choose between multiple professional templates** (Default and Stripe) to match your brand and style_
| Default Invoice Template | Stripe Invoice Template |
| :-----------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------: |
| <img src=".github/screenshots/default-template.png" width="1200" height="960" alt="Default Invoice Template"> | <img src=".github/screenshots/stripe-template.png" width="1200" height="960" alt="Stripe Invoice Template"> |
</div>
## 📢 News & Updates
- **Jan 11, 2026**: Added customizable tax/VAT labels, improved internationalization (i18n) translations, enhanced overall performance, and fixed multiple bugs. [Release notes for v1.0.1](https://github.com/VladSez/easy-invoice-pdf/releases/tag/EasyInvoicePDF-1.0.1)
- **Nov 19, 2025**: EasyInvoicePDF version 1.0.0 released! Create professional invoices in seconds. Welcome to try EasyInvoicePDF. [Release notes for v1.0.0](https://github.com/VladSez/easy-invoice-pdf/releases/tag/EasyInvoicePDF-v1.0.0)
## 🎥 Demo Video

24
TODO.md
View file

@ -1,22 +1,38 @@
TODO list
- [ ] replace native select for currencies with Combobox (https://ui.shadcn.com/docs/components/radix/combobox) i.e. make it searchable? (also add Groups - Major, EU, Africa, Asia, Latin America, etc.)
IMPORTANT:
- [ ] double check all invoice translations and fix them
- [ ] double check all invoice translations and fix them (if needed)
- [ ] we can now try to update to latest Next.js version + react, react-dom, because fix for @react-pdf/renderer which was causing issues during PDF regeneration and toggling on/off of some invoice fields is now available (https://github.com/diegomura/react-pdf/pull/3261)
---
etc:
- [ ] rename "Invoice Type" field to "Invoice Notes" in the invoice form (better name)
- [ ] Double check if we need to save to local storage in the page.client.tsx (line: 235)
Useful stuff:
useful stuff:
- https://github.com/karlhorky/playwright-tricks?tab=readme-ov-file#screenshot-comparison-tests-of-pdfs
Issues to follow:
react-pdf/renderer quirks:
- https://github.com/diegomura/react-pdf/issues/774#issuecomment-560069810 (Preventing other elements from crashing into a fixed footer)
react-pdf quirks (non-desktop pdf viewer):
- https://github.com/wojtekmaj/react-pdf/issues/1824
issues to follow:
- https://github.com/microsoft/playwright/issues/13873
- https://github.com/microsoft/playwright/issues/19253
- https://github.com/wojtekmaj/react-pdf/issues/2026
---
To create GIFs for README.md, you can use:
- https://www.freeconvert.com/mov-to-gif (set width to 1200px and Compression to 1)

View file

@ -18,7 +18,16 @@ test.describe("About page", () => {
);
const header = page.getByRole("banner");
// Check header elements
/* CHECK HEADER ELEMENTS */
// Check language switcher button in header
const languageSwitcher = header.getByRole("button", {
name: "Switch language",
});
await expect(languageSwitcher).toBeVisible();
// check app link button in header
await expect(header.getByText("EasyInvoicePDF")).toBeVisible();
const goToAppButton = header.getByRole("link", {
name: "Go to app",
@ -193,8 +202,9 @@ test.describe("About page", () => {
const header = page.getByRole("banner");
// Check header elements in French
await expect(header.getByText("EasyInvoicePDF")).toBeVisible();
const goToAppButton = header.getByRole("link", {
name: "Aller à l'application",
name: "Ouvrir",
exact: true,
});
await expect(goToAppButton).toBeVisible();
@ -268,7 +278,7 @@ test.describe("About page", () => {
// Check header elements in German
await expect(header.getByText("EasyInvoicePDF")).toBeVisible();
const goToAppButton = header.getByRole("link", {
name: "Zur App gehen",
name: "Öffnen",
exact: true,
});
await expect(goToAppButton).toBeVisible();
@ -331,7 +341,7 @@ test.describe("About page", () => {
const header = page.getByRole("banner");
await expect(
header.getByRole("link", {
name: "Aller à l'application",
name: "Ouvrir",
exact: true,
}),
).toBeVisible();

View file

@ -27,9 +27,25 @@ test.describe("Buyer management", () => {
notes: "This is a test note",
} as const satisfies BuyerData;
// ------- TEST BUYER MANAGEMENT DIALOG FORM -------
/*
* TEST BUYER MANAGEMENT DIALOG FORM
*/
const manageBuyerDialog = page.getByTestId(`manage-buyer-dialog`);
// Verify "Pre-fill with values from the current invoice form" switch is visible
const prefillSwitch = manageBuyerDialog.getByRole("switch", {
name: `Pre-fill with values from the current invoice form`,
});
await expect(prefillSwitch).toBeVisible();
await expect(prefillSwitch).not.toBeChecked();
// Verify the label is visible
await expect(
manageBuyerDialog.getByLabel(
"Pre-fill with values from the current invoice form",
),
).toBeVisible();
// Fill in form fields
await manageBuyerDialog
.getByRole("textbox", { name: "Name" })
@ -56,7 +72,7 @@ test.describe("Buyer management", () => {
.fill(TEST_BUYER_DATA.email);
const taxNumberSwitchInDialogForm = manageBuyerDialog.getByRole("switch", {
name: `Show/hide the 'Tax Number' field in the PDF`,
name: `Show the 'Tax Number' field in the PDF`,
});
// Verify VAT visibility switch is checked by default
@ -73,7 +89,7 @@ test.describe("Buyer management", () => {
.fill(TEST_BUYER_DATA.notes);
const notesSwitchInDialogForm = manageBuyerDialog.getByRole("switch", {
name: `Show/hide the 'Notes' field in the PDF`,
name: `Show the 'Notes' field in the PDF`,
});
// Verify notes visibility switch is CHECKED by default
@ -121,7 +137,10 @@ test.describe("Buyer management", () => {
notesFieldIsVisible: true,
} satisfies BuyerData);
// ------- TEST SAVED DETAILS IN INVOICE FORM -------
/*
* TEST SAVED DETAILS IN INVOICE FORM AFTER SAVING BUYER
*/
// Verify all saved details in the Buyer Information section form
const buyerForm = page.getByTestId(`buyer-information-section`);
@ -218,7 +237,10 @@ test.describe("Buyer management", () => {
buyerForm.getByRole("combobox", { name: "Select Buyer" }),
).toContainText(TEST_BUYER_DATA.name);
// ------- TEST EDIT FUNCTIONALITY -------
/*
* TEST EDIT FUNCTIONALITY IN BUYER MANAGEMENT DIALOG
*/
await buyerForm.getByRole("button", { name: "Edit buyer" }).click();
// Verify all fields are populated in edit dialog
@ -257,7 +279,7 @@ test.describe("Buyer management", () => {
await expect(notesSwitchInDialogForm).toHaveRole("switch");
await expect(notesSwitchInDialogForm).toHaveAccessibleName(
`Show/hide the 'Notes' field in the PDF`,
`Show the 'Notes' field in the PDF`,
);
await expect(notesSwitchInDialogForm).toBeChecked();
@ -283,7 +305,10 @@ test.describe("Buyer management", () => {
page.getByText("Buyer updated successfully", { exact: true }),
).toBeVisible();
// ------- TEST UPDATED INFORMATION IN INVOICE FORM -------
/*
* TEST UPDATED INFORMATION IN INVOICE FORM AFTER UPDATING BUYER IN DIALOG
*/
// Verify updated information is displayed
await expect(buyerForm.getByRole("textbox", { name: "Name" })).toHaveValue(
updatedName,

View file

@ -5,7 +5,10 @@ import path from "node:path";
// IMPORTANT: we use custom extended test fixture that provides a temporary download directory for each test
import { test, expect } from "../utils/extended-playwright-test";
import { renderPdfOnCanvas } from "../utils/render-pdf-on-canvas";
import {
renderPdfOnCanvas,
renderMultiPagePdfOnCanvas,
} from "../utils/render-pdf-on-canvas";
test.describe("Default Invoice Template", () => {
test.beforeEach(async ({ page }) => {
@ -208,6 +211,46 @@ test.describe("Default Invoice Template", () => {
const totalTextbox = page.getByRole("textbox", { name: "Total" });
await expect(totalTextbox).toHaveValue("3,300.00");
/** TEST PERSON AUTHORIZED TO RECEIVE FIELD */
const personAuthorizedToReceiveFieldset = finalSection.getByRole("group", {
name: "Person Authorized to Receive",
});
// Verify that "Show Person Authorized to Receive in PDF" switch is on by default
const showPersonAuthorizedToReceiveSwitch =
personAuthorizedToReceiveFieldset.getByRole("switch", {
name: "Show Person Authorized to Receive in PDF",
});
await expect(showPersonAuthorizedToReceiveSwitch).toBeVisible();
await expect(showPersonAuthorizedToReceiveSwitch).toBeEnabled();
await expect(showPersonAuthorizedToReceiveSwitch).toBeChecked();
const personAuthorizedToReceiveNameInput =
personAuthorizedToReceiveFieldset.getByRole("textbox", {
name: "Name",
});
await personAuthorizedToReceiveNameInput.fill("John Doe");
/** TEST PERSON AUTHORIZED TO ISSUE FIELD */
const personAuthorizedToIssueFieldset = finalSection.getByRole("group", {
name: "Person Authorized to Issue",
});
const showPersonAuthorizedToIssueSwitch =
personAuthorizedToIssueFieldset.getByRole("switch", {
name: "Show Person Authorized to Issue in PDF",
});
await expect(showPersonAuthorizedToIssueSwitch).toBeVisible();
await expect(showPersonAuthorizedToIssueSwitch).toBeEnabled();
await expect(showPersonAuthorizedToIssueSwitch).toBeChecked();
const personAuthorizedToIssueNameInput =
personAuthorizedToIssueFieldset.getByRole("textbox", {
name: "Name",
});
await personAuthorizedToIssueNameInput.fill("Adam Smith");
// wait, because we update pdf on debounce timeout
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(700);
@ -349,7 +392,7 @@ test.describe("Default Invoice Template", () => {
.selectOption(DATE_FORMAT);
await page
.getByRole("textbox", { name: "Invoice Type" })
.getByRole("textbox", { name: "Header Notes" })
.fill("HELLO FROM PLAYWRIGHT TEST!");
/** UPDATE SELLER INFORMATION */
@ -363,20 +406,23 @@ test.describe("Default Invoice Template", () => {
// Toggle VAT Number visibility off
await sellerSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(0)
.getByRole("switch", {
name: `Show the 'Seller Tax Number' Field in the PDF`,
})
.click();
// Toggle Account Number visibility off
await sellerSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(1)
.getByRole("switch", {
name: `Show the 'Account Number' Field in the PDF`,
})
.click();
// Toggle SWIFT visibility off
await sellerSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(2)
.getByRole("switch", {
name: `Show the 'SWIFT/BIC' Field in the PDF`,
})
.click();
// update notes
@ -1149,4 +1195,328 @@ test.describe("Default Invoice Template", () => {
`should-display-and-persist-invoice-number-in-different-languages-stripe-template.png`,
);
});
test("displays QR code in PDF when QR code data is provided", async ({
page,
browserName,
downloadDir,
}, testInfo) => {
const QR_CODE_TEST_DATA = {
data: "https://easyinvoicepdf.com",
description: "QR Code Description",
} as const satisfies {
data: string;
description: string;
};
// verify that we are on the default template
await expect(page).toHaveURL("/?template=default");
const finalSection = page.getByTestId("final-section");
const qrCodeFieldset = finalSection.getByRole("group", {
name: "QR Code",
});
await expect(qrCodeFieldset).toBeVisible();
// Verify that "Show QR Code in PDF" switch is on by default
const showQrCodeSwitch = qrCodeFieldset.getByRole("switch", {
name: "Show QR Code in PDF",
});
await expect(showQrCodeSwitch).toBeVisible();
await expect(showQrCodeSwitch).toBeEnabled();
await expect(showQrCodeSwitch).toBeChecked();
// Fill in the QR code data field
await qrCodeFieldset
.getByRole("textbox", { name: "Data" })
.fill(QR_CODE_TEST_DATA.data);
// Fill in the QR code description field
await qrCodeFieldset
.getByRole("textbox", { name: "Description (optional)" })
.fill(QR_CODE_TEST_DATA.description);
// for better debugging screenshots, we fill in the notes field with a test note =)
await finalSection
.getByRole("textbox", { name: "Notes", exact: true })
.fill(`Test: ${testInfo.title} (${testInfo.project.name})`);
// Wait for debounce timeout
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(700);
const downloadPdfEnglishButton = page.getByRole("link", {
name: "Download PDF in English",
});
await expect(downloadPdfEnglishButton).toBeVisible();
await expect(downloadPdfEnglishButton).toBeEnabled();
// Click the download button and wait for download
const [download] = await Promise.all([
page.waitForEvent("download"),
downloadPdfEnglishButton.click(),
]);
// Get the suggested filename
const suggestedFilename = download.suggestedFilename();
// save the file to temporary directory
const pdfFilePath = path.join(
downloadDir,
`${browserName}-${suggestedFilename}`,
);
await download.saveAs(pdfFilePath);
// Convert to absolute path and use proper file URL format
const absolutePath = path.resolve(pdfFilePath);
await expect.poll(() => fs.existsSync(absolutePath)).toBe(true);
/**
* Render the PDF on a canvas and take a screenshot of it
*/
const pdfBytes = fs.readFileSync(absolutePath);
await page.goto("about:blank");
await renderPdfOnCanvas(page, pdfBytes);
await page.waitForFunction(
() =>
(window as unknown as { __PDF_RENDERED__: boolean })
.__PDF_RENDERED__ === true,
);
await expect(page.locator("canvas")).toHaveScreenshot(
"displays-qr-code-in-pdf-default-template.png",
);
/**
* TURN OFF QR CODE IN PDF AND DOWNLOAD PDF AGAIN
*/
// navigate back to the previous page
await page.goto("/", { waitUntil: "commit" });
// verify that we are on the default template
await expect(page).toHaveURL("/?template=default");
const newFinalSection = page.getByTestId("final-section");
const newQrCodeFieldset = newFinalSection.getByRole("group", {
name: "QR Code",
});
await expect(newQrCodeFieldset).toBeVisible();
// Verify that "Show QR Code in PDF" switch is on by default
const newShowQrCodeSwitch = newQrCodeFieldset.getByRole("switch", {
name: "Show QR Code in PDF",
});
await expect(newShowQrCodeSwitch).toBeVisible();
await expect(newShowQrCodeSwitch).toBeEnabled();
await expect(newShowQrCodeSwitch).toBeChecked();
// toggle the switch off
await newShowQrCodeSwitch.click();
// verify that the switch is off
await expect(newShowQrCodeSwitch).not.toBeChecked();
// Verify QR Code Data field retains its value after toggling visibility off
const newQrCodeDataTextarea = newQrCodeFieldset.getByRole("textbox", {
name: "Data",
});
await expect(newQrCodeDataTextarea).toBeVisible();
await expect(newQrCodeDataTextarea).toHaveValue(QR_CODE_TEST_DATA.data);
// Verify QR Code Description field retains its value after toggling visibility off
const newQrCodeDescriptionTextarea = newQrCodeFieldset.getByRole(
"textbox",
{
name: "Description (optional)",
},
);
await expect(newQrCodeDescriptionTextarea).toBeVisible();
await expect(newQrCodeDescriptionTextarea).toHaveValue(
QR_CODE_TEST_DATA.description,
);
// for better debugging screenshots, we fill in the notes field with a test note =)
await newFinalSection
.getByRole("textbox", { name: "Notes", exact: true })
.fill(
`Test: ${testInfo.title} - QR code hidden in PDF (${testInfo.project.name})`,
);
// wait for debounce timeout
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(700);
const newDownloadPdfEnglishButton = page.getByRole("link", {
name: "Download PDF in English",
});
// Download the PDF again
const [downloadPdfWithoutQrCode] = await Promise.all([
page.waitForEvent("download"),
newDownloadPdfEnglishButton.click(),
]);
// Get the suggested filename
const suggestedFilenameWithoutQrCode =
downloadPdfWithoutQrCode.suggestedFilename();
// save the file to temporary directory
const pdfFilePath2 = path.join(
downloadDir,
`${browserName}-${suggestedFilenameWithoutQrCode}`,
);
await downloadPdfWithoutQrCode.saveAs(pdfFilePath2);
/**
* Render the PDF on a canvas and take a screenshot to verify QR code is not displayed
*/
const pdfBytesWithoutQrCode = fs.readFileSync(pdfFilePath2);
await page.goto("about:blank");
await renderPdfOnCanvas(page, pdfBytesWithoutQrCode);
await page.waitForFunction(
() =>
(window as unknown as { __PDF_RENDERED__: boolean })
.__PDF_RENDERED__ === true,
);
await expect(page.locator("canvas")).toHaveScreenshot(
"qr-code-hidden-in-pdf-default-template.png",
);
});
test("generates multi-page PDF when invoice has many items", async ({
page,
browserName,
downloadDir,
}, testInfo) => {
// Verify we're on the default template
await expect(page).toHaveURL("/?template=default");
const invoiceItemsSection = page.getByTestId("invoice-items-section");
// Update tax label for the first item
const firstItemFieldset = invoiceItemsSection.getByRole("group", {
name: "Item 1",
});
const firstItemTaxSettingsFieldset = firstItemFieldset.getByRole("group", {
name: "Tax Settings",
});
await firstItemTaxSettingsFieldset
.getByRole("textbox", { name: "Tax Label" })
.fill("Sales Tax");
// Add additional invoice items to trigger multiple-page PDF
for (let i = 0; i < 17; i++) {
await invoiceItemsSection
.getByRole("button", { name: "Add invoice item" })
.click();
// Fill minimal required fields for the new item
const itemFieldset = invoiceItemsSection.getByRole("group", {
name: `Item ${i + 2}`, // Item numbers start at 1
});
await itemFieldset
.getByRole("textbox", { name: "Name" })
.fill(
`Item ${i + 2}. Some long item name that should be wrapped to the next line. Some long item name that should be wrapped to the next line. Some long item name that should be wrapped to the next line.`,
);
// Set VAT to 10% for each item
const taxSettingsFieldset = itemFieldset.getByRole("group", {
name: "Tax Settings",
});
// Use different tax rates: 10%, 20%, or 50%
const taxRate =
// eslint-disable-next-line playwright/no-conditional-in-test
(i + 2) % 3 === 0 ? "50" : (i + 2) % 2 === 0 ? "20" : "10";
await taxSettingsFieldset
.getByRole("textbox", { name: "Sales Tax", exact: true })
.fill(taxRate);
await itemFieldset
.getByRole("spinbutton", {
name: "Net Price (Rate or Unit Price)",
})
.fill(`${1000 * (i + 1)}`);
}
const finalSection = page.getByTestId("final-section");
// for better debugging screenshots, we fill in the notes field with a test note
await finalSection
.getByRole("textbox", { name: "Notes", exact: true })
.fill(
`Test: generates multi-page PDF when invoice has many items (${testInfo.project.name})`,
);
// Wait for PDF preview to regenerate after invoice data changes (debounce timeout)
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(700);
const downloadPdfEnglishButton = page.getByRole("link", {
name: "Download PDF in English",
});
await expect(downloadPdfEnglishButton).toBeVisible();
await expect(downloadPdfEnglishButton).toBeEnabled();
// Click the download button and wait for download
const [download] = await Promise.all([
page.waitForEvent("download"),
downloadPdfEnglishButton.click(),
]);
// Get the suggested filename
const suggestedFilename = download.suggestedFilename();
// save the file to temporary directory
const pdfFilePath = path.join(
downloadDir,
`${browserName}-${suggestedFilename}`,
);
await download.saveAs(pdfFilePath);
// Convert to absolute path and use proper file URL format
const absolutePath = path.resolve(pdfFilePath);
await expect.poll(() => fs.existsSync(absolutePath)).toBe(true);
/**
* RENDER ALL PDF PAGES ON A SINGLE CANVAS AND TAKE SCREENSHOT
*/
const pdfBytes = fs.readFileSync(absolutePath);
await page.goto("about:blank");
await renderMultiPagePdfOnCanvas(page, pdfBytes);
await page.waitForFunction(
() =>
(window as unknown as { __PDF_RENDERED__: boolean })
.__PDF_RENDERED__ === true,
);
await expect(page.locator("canvas")).toHaveScreenshot(
"default-template-multi-pages.png",
);
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 161 KiB

View file

@ -142,6 +142,25 @@ test.describe("Generate Invoice Link", () => {
});
await expect(totalField).toHaveValue("615.00");
// check QR code data field
const qrCodeFieldset = finalSection.getByRole("group", {
name: "QR Code",
});
// check QR code data field
const qrCodeDataField = qrCodeFieldset.getByRole("textbox", {
name: "Data",
});
await qrCodeDataField.fill("https://easyinvoicepdf.com");
// check QR code description field
const qrCodeDescriptionField = qrCodeFieldset.getByRole("textbox", {
name: "Description (optional)",
});
await qrCodeDescriptionField.fill("QR Code TEST Description");
// wait for debounce timeout
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(700);
@ -162,6 +181,9 @@ test.describe("Generate Invoice Link", () => {
const newPage = await context.newPage();
await newPage.goto(sharedUrl);
// Verify the URL contains the shared invoice data
await expect(newPage).toHaveURL(/\?template=default&data=/);
// Get elements from the new page context
const newInvoiceNumberFieldset = newPage.getByRole("group", {
name: "Invoice Number",
@ -268,12 +290,36 @@ test.describe("Generate Invoice Link", () => {
exact: true,
}),
).toHaveValue("615.00");
// Verify QR code data field
const newQrCodeDataField = newPageFinalSection.getByRole("textbox", {
name: "Data",
});
await expect(newQrCodeDataField).toHaveValue("https://easyinvoicepdf.com");
// Verify QR code description field
const newQrCodeDescriptionField = newPageFinalSection.getByRole("textbox", {
name: "Description (optional)",
});
await expect(newQrCodeDescriptionField).toHaveValue(
"QR Code TEST Description",
);
});
test("shows notification when invoice link is broken", async ({ page }) => {
test("shows error notification when invoice link is broken", async ({
page,
}) => {
// Navigate to page with invalid data parameter
await page.goto("/?data=invalid-data-string");
await expect(page).toHaveURL("/?data=invalid-data-string&template=default");
// ensure page is loaded
await expect(
page.getByRole("link", { name: "Download PDF in English" }),
).toBeVisible();
// Verify error toast appears
await expect(
page.getByText("The shared invoice URL appears to be incorrect"),
@ -282,10 +328,12 @@ test.describe("Generate Invoice Link", () => {
// Verify error description is shown
await expect(
page.getByText(
"Please verify that you have copied the complete invoice URL. The link may be truncated or corrupted. Try refreshing the page and generating a new link.",
"Please verify that you have copied the complete invoice URL. The link may be truncated or corrupted.",
),
).toBeVisible();
await expect(page.getByText("Try generating a new link.")).toBeVisible();
const clearUrlButton = page.getByRole("button", {
name: "Clear URL",
});
@ -302,19 +350,100 @@ test.describe("Generate Invoice Link", () => {
await expect(
page.getByText(
"Please verify that you have copied the complete invoice URL. The link may be truncated or corrupted. Try refreshing the page and generating a new link.",
"Please verify that you have copied the complete invoice URL. The link may be truncated or corrupted.",
),
).toBeHidden();
await expect(page.getByText("Try generating a new link.")).toBeHidden();
// Wait for URL to be cleared and verify
await expect(page).toHaveURL("/?template=default");
// ensure page content is displayed
await expect(
page.getByRole("link", { name: "Download PDF in English" }),
).toBeVisible();
});
test("shows info notification when invoice link is corrupted and cleared after form change", async ({
page,
}) => {
// Navigate to page with invalid data parameter
await page.goto("/?data=corrupted-url");
await expect(page).toHaveURL("/?data=corrupted-url&template=default");
/* Ensure page content is displayed */
await expect(
page.getByRole("link", { name: "Download PDF in English" }),
).toBeVisible();
/* VERIFY ERROR TOAST IS SHOWN */
await expect(
page.getByText("The shared invoice URL appears to be incorrect"),
).toBeVisible();
// Verify error description is shown
await expect(
page.getByText(
"Please verify that you have copied the complete invoice URL. The link may be truncated or corrupted.",
),
).toBeVisible();
await expect(page.getByText("Try generating a new link.")).toBeVisible();
/** UPDATE INVOICE FORM TO TRIGGER URL CLEARING (Because URL is corrupted) */
await page
.getByRole("textbox", { name: "Header Notes" })
.fill("CORRUPTED URL TEST");
/* VERIFY ERROR TOAST IS HIDDEN */
await expect(
page.getByText("The shared invoice URL appears to be incorrect"),
).toBeHidden();
await expect(
page.getByText(
"Please verify that you have copied the complete invoice URL. The link may be truncated or corrupted.",
),
).toBeHidden();
await expect(page.getByText("Try generating a new link.")).toBeHidden();
/* VERIFY INFO TOAST (CORRUPTED URL CLEARED) IS SHOWN */
await expect(page.getByText("Corrupted URL Cleared")).toBeVisible();
await expect(
page.getByText(
"The invalid invoice URL has been removed from the address bar.",
),
).toBeVisible();
await expect(
page.getByText(
"Click 'Generate a link to invoice' to create a new shareable link.",
),
).toBeVisible();
// Wait for URL to be cleared and verify
await expect(page).toHaveURL("/?template=default");
/* Ensure page content is displayed */
await expect(
page.getByRole("link", { name: "Download PDF in English" }),
).toBeVisible();
});
test("backwards compatibility: old uncompressed URLs work the same as new compressed URLs", async ({
page,
}) => {
// Test URLs provided in the user query - these are old format URLs before compression was implemented
const OLD_UNCOMPRESSED_URL =
"N4IgNghgdg5grhGBTEAuEAHMIA0IAmEALkgGID2ATgLbFogCyTDABACI4sCaPXuIAYziVKSKAICe9AKoBlNvxLUsxFOgDORSgEsMKPGHIxy9ftqgA3ctoFIAcnGoAjJJQDyTgFZIBRNKEgXbHRSCABrImEIFihKdDwLCDA4NRAARgB6AAYADgBaACYsgoBWEABfPEISNwAzAEl1dRT6ItK83Ly0sqrVOtlXCxtUtpKO-IBmNLNLa1sAFQk9egAlJAtXdSQWAGEACwhKZBmrYcW9Um0kMHxGgDVtdW0nMDUtFLwtsFfKfxBtfD0NIAdgALFkAGw5UElCagiZZHogKAQaipABS5D2UHY5H0IAg+Hwoia9AAMuQoPhKZxpABpfiJIh2EzoNIFOElCGM4gsy7XW7qB5PF5vSgfEBIWjaYIgTxYqAAAWlYAAdAJyNR+BABBq4FBmY4XL82RyYdy8Dq9QaHM5XPybvdHs9Xmh3khPgB3bS1IgAIRsQLNXP46m9voDAgdguFLrFEqg5BI6noyb8eETyejTpFrtQtSSW0qICccAkrj+AKBwNhoIhWRhJWBDf4KLR9AAggI0bsTJaiSSU+g7EhPdwqGFOHYuLTZB2eczWelgxaQEy+VdHULnaK3eKPZKVfQdWjlRAZerNa2k0ghyBr1nNzGd3n3cXtEohwBtUDmU62eolFtY0czjPcE1RVJZHIX1PUObY2HWa5yAwNEDVbSDs23XN4wPIgliQOoAHF5mkUw8HwvRiNIrDY13fNCwPVFyH1PxUDSS1qBYg1aJfXC8H1D96C2SghlsfhBKIXicPAg8oCQIgAAUdHE9iSiyDSMwU5ThmksDUHdBI6GHRSFz0+jDORBSOy41i0G6DSsi0ogbO4qSn1Aiz9yMlzbPQ1AnLXYhXNY8zX28zBRHmCAAA8Qv8hzNMipBorivz3IFTzwpScoAF0KKTJJ7PUpKmWi0VZEcWhKAkLL+MwCAJDQogGAUvZyEBdBvVEFgtGgdRagrPAMEa5rWqIdr8DC+qRqasQiDYFp0FGcZClBUMtF0JBFMatwoDAcwkGkShZQfW9ViQygthYAQDiOfFM1vabZOGzZKQ7OAJqobQAC8kHweZyDWWxtA2Z6DIivQrvez72p0P6AfIRpmjIDzsP0t8gA";
@ -446,4 +575,378 @@ test.describe("Generate Invoice Link", () => {
// Verify significant compression occurred (at least 20% reduction)
expect(compressionRatio).toBeGreaterThan(20);
});
test("shows info toast when shared invoice is modified", async ({
page,
context,
}) => {
const INVOICE_TEST_DATA = {
invoiceNumber: "MODIFY-TEST-001",
invoiceItemAmount: "50",
invoiceItemQuantity: "3",
} as const satisfies {
invoiceNumber: string;
invoiceItemAmount: string;
invoiceItemQuantity: string;
};
// Fill in invoice number
const invoiceNumberFieldset = page.getByRole("group", {
name: "Invoice Number",
});
const invoiceNumberValueField = invoiceNumberFieldset.getByRole("textbox", {
name: "Value",
});
await invoiceNumberValueField.fill(INVOICE_TEST_DATA.invoiceNumber);
// Fill in seller information
const sellerSection = page.getByTestId("seller-information-section");
await sellerSection
.getByRole("textbox", { name: "Name" })
.fill(TEST_SELLER_DATA.name);
// Fill in buyer information
const buyerSection = page.getByTestId("buyer-information-section");
await buyerSection
.getByRole("textbox", { name: "Name" })
.fill(TEST_BUYER_DATA.name);
// Fill in an invoice item
const invoiceItemsSection = page.getByTestId("invoice-items-section");
await invoiceItemsSection
.getByRole("spinbutton", { name: "Amount (Quantity)" })
.fill(INVOICE_TEST_DATA.invoiceItemQuantity);
await invoiceItemsSection
.getByRole("spinbutton", {
name: "Net Price (Rate or Unit Price)",
})
.fill(INVOICE_TEST_DATA.invoiceItemAmount);
// Wait for debounce timeout
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(700);
// Generate share link
await page
.getByRole("button", { name: "Generate a link to invoice" })
.click();
// Wait for URL to update with share data
await page.waitForURL((url) => url.searchParams.has("data"));
// Verify the share invoice link description toast appears after generating the link
const toast = page.getByTestId("share-invoice-link-description-toast");
await expect(toast).toBeVisible();
await expect(
page.getByText(
"Share this link to let others view and edit this invoice",
),
).toBeVisible();
// Get the current URL which should now contain the share data
const sharedUrl = page.url();
expect(sharedUrl).toContain("?template=default&data=");
/*
* VERIFY SHARED INVOICE DATA IS LOADED IN NEW TAB
*/
// Open URL in new tab
const newPage = await context.newPage();
await newPage.goto(sharedUrl);
// Verify the URL contains the shared invoice data
await expect(newPage).toHaveURL(/\?template=default&data=/);
// Get the invoice number field from the new page
const newInvoiceNumberFieldset = newPage.getByRole("group", {
name: "Invoice Number",
});
const newInvoiceNumberValueField = newInvoiceNumberFieldset.getByRole(
"textbox",
{
name: "Value",
},
);
/**
* VERIFY SHARED INVOICE DATA IS LOADED IN NEW TAB
*/
// Verify original value is loaded
await expect(newInvoiceNumberValueField).toHaveValue(
INVOICE_TEST_DATA.invoiceNumber,
);
// Verify seller information is loaded
const newSellerSection = newPage.getByTestId("seller-information-section");
await expect(
newSellerSection.getByRole("textbox", { name: "Name" }),
).toHaveValue(TEST_SELLER_DATA.name);
// Verify buyer information is loaded
const newBuyerSection = newPage.getByTestId("buyer-information-section");
await expect(
newBuyerSection.getByRole("textbox", { name: "Name" }),
).toHaveValue(TEST_BUYER_DATA.name);
// Verify invoice item data is loaded
const newInvoiceItemsSection = newPage.getByTestId("invoice-items-section");
await expect(
newInvoiceItemsSection.getByRole("spinbutton", {
name: "Amount (Quantity)",
}),
).toHaveValue(INVOICE_TEST_DATA.invoiceItemQuantity);
await expect(
newInvoiceItemsSection.getByRole("spinbutton", {
name: "Net Price (Rate or Unit Price)",
}),
).toHaveValue(INVOICE_TEST_DATA.invoiceItemAmount);
/**
* MODIFY INVOICE DATA AND TRIGGER TOAST
*/
// Modify the invoice number to trigger the toast
await newInvoiceNumberValueField.fill("MODIFY-TEST-002");
// Wait for debounce timeout to allow change detection
// eslint-disable-next-line playwright/no-wait-for-timeout
await newPage.waitForTimeout(700);
// Verify toast notification appears with correct text
await expect(newPage.getByText("Invoice Updated")).toBeVisible();
await expect(
newPage.getByText(
"Your changes have modified this invoice from its shared version.",
),
).toBeVisible();
// Verify the data parameter is removed from URL after modification
await expect(newPage).toHaveURL("?template=default");
});
test("shows error toast when invoice data exceeds URL size limit", async ({
page,
}) => {
// Fill in invoice number
const invoiceNumberFieldset = page.getByRole("group", {
name: "Invoice Number",
});
await invoiceNumberFieldset
.getByRole("textbox", { name: "Label" })
.fill("URL-LIMIT-TEST-001");
/**
* FILL IN SELLER INFORMATION WITH LONG DATA
*/
const sellerSection = page.getByTestId("seller-information-section");
await sellerSection
.getByRole("textbox", { name: "Name" })
.fill(
"Very Long Seller Company Name With Many Words To Increase Data Size For Testing URL Limits Corporation Ltd",
);
await sellerSection
.getByRole("textbox", { name: "Address" })
.fill(
"123 Very Long Street Name With Apartment Number And Additional Details, Building A, Floor 5, Suite 500, Business District, Metropolitan Area, State Province, Country Name With Long Description",
);
await sellerSection
.getByRole("textbox", { name: "Email" })
.fill("seller-with-very-long-email-address@example-company.com");
// Fill in seller tax number
const sellerVatNumberFieldset = sellerSection.getByRole("group", {
name: "Seller Tax Number",
});
await sellerVatNumberFieldset
.getByRole("textbox", { name: "Label" })
.fill("Seller Tax Identification Number");
await sellerVatNumberFieldset
.getByRole("textbox", { name: "Value" })
.fill("SELLER-TAX-123456789-LONG-FORMAT");
// Fill in seller account number
await sellerSection
.getByRole("textbox", { name: "Account Number" })
.fill(
"SELLER-LONG-ACCOUNT-NUMBER-IBAN-GB12-BANK-1234-5678-9012-3456-7890-1234",
);
// Fill in seller SWIFT/BIC
await sellerSection
.getByRole("textbox", { name: "SWIFT/BIC" })
.fill(
"SELLER-LONG-SWIFT-BIC-BANKGB12XXX-WITH-MANY-CHARACTERS-FOR-URL-LIMIT-TESTING",
);
/**
* FILL IN BUYER INFORMATION WITH LONG DATA
*/
const buyerSection = page.getByTestId("buyer-information-section");
await buyerSection
.getByRole("textbox", { name: "Name" })
.fill(
"Very Long Buyer Company Name With Many Words To Increase Data Size For Testing URL Limits International Inc",
);
await buyerSection
.getByRole("textbox", { name: "Address" })
.fill(
"456 Another Very Long Street Name With Apartment Details, Building B, Floor 10, Suite 1000, Downtown District, Urban Metropolitan Area, State Province Region, Country Name With Extended Description",
);
await buyerSection
.getByRole("textbox", { name: "Email" })
.fill("buyer-with-very-long-email-address@example-corporation.com");
const buyerVatNumberFieldset = buyerSection.getByRole("group", {
name: "Buyer Tax Number",
});
await buyerVatNumberFieldset
.getByRole("textbox", { name: "Label" })
.fill("Buyer Tax Identification Number");
await buyerVatNumberFieldset
.getByRole("textbox", { name: "Value" })
.fill("BUYER-TAX-987654321-LONG-FORMAT");
// Generate many invoice items with long descriptions
const invoiceItemsSection = page.getByTestId("invoice-items-section");
// Update tax label for the first item
const firstItemFieldset = invoiceItemsSection.getByRole("group", {
name: "Item 1",
});
const firstItemTaxSettingsFieldset = firstItemFieldset.getByRole("group", {
name: "Tax Settings",
});
await firstItemTaxSettingsFieldset
.getByRole("textbox", { name: "Tax Label" })
.fill("Sales Tax");
// Fill first item with long description
await firstItemFieldset
.getByRole("textbox", { name: "Name" })
.fill(
"Item 1. Professional consulting services with detailed description including scope of work, deliverables, timeline expectations, quality standards, and additional terms and conditions that need to be specified in the invoice line item.",
);
await firstItemTaxSettingsFieldset
.getByRole("textbox", { name: "Sales Tax", exact: true })
.fill("20");
await firstItemFieldset
.getByRole("spinbutton", {
name: "Net Price (Rate or Unit Price)",
})
.fill("5000");
// Add additional invoice items to exceed URL limit
for (let i = 0; i < 15; i++) {
await invoiceItemsSection
.getByRole("button", { name: "Add invoice item" })
.click();
const itemFieldset = invoiceItemsSection.getByRole("group", {
name: `Item ${i + 2}`,
});
await itemFieldset
.getByRole("textbox", { name: "Name" })
.fill(
`Item ${i + 2}. Professional services and products with comprehensive detailed description including all specifications, technical requirements, quality standards, delivery terms, warranty information, support details, and additional terms that increase the overall data size significantly for URL limit testing purposes. This extended description ensures we exceed the URL length limits by adding more detailed information about the service or product being invoiced in this particular line item.`,
);
await itemFieldset
.getByRole("spinbutton", {
name: "Net Price (Rate or Unit Price)",
})
.fill(`${1000 * (i + 1)}`);
}
const finalSection = page.getByTestId("final-section");
// Add QR code data to increase data size
const qrCodeFieldset = finalSection.getByRole("group", {
name: "QR Code",
});
const qrCodeDataField = qrCodeFieldset.getByRole("textbox", {
name: "Data",
});
await qrCodeDataField.fill(
"https://easyinvoicepdf.com/invoice/payment/details/with/very/long/path/and/parameters?id=12345678901234567890&token=abcdefghijklmnopqrstuvwxyz123456789&session=verylongsessionidentifier",
);
const qrCodeDescriptionField = qrCodeFieldset.getByRole("textbox", {
name: "Description (optional)",
});
await qrCodeDescriptionField.fill(
"QR Code for payment processing with detailed instructions and additional information for the recipient",
);
// Add long notes to further increase data size
await finalSection
.getByRole("textbox", { name: "Notes", exact: true })
.fill(
"Important notes and additional information: This invoice contains detailed terms and conditions, payment instructions, delivery schedules, warranty information, support contact details, and various other important details that need to be communicated to the recipient. Please review all items carefully and contact us if you have any questions or concerns about the invoice contents or payment procedures.",
);
// Wait for debounce timeout
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(700);
// Try to generate share link - should fail with error toast
await page
.getByRole("button", { name: "Generate a link to invoice" })
.click();
// Verify error toast appears with correct text
await expect(
page.getByText("Invoice data is too large to share via URL"),
).toBeVisible();
// Verify error description is shown
await expect(
page.getByText(
"Download the invoice as PDF instead or remove some invoice items and try again.",
),
).toBeVisible();
// Verify URL was NOT updated with data parameter
await expect(page).toHaveURL("/?template=default");
// Verify toast is still visible after 2 seconds
await expect(
page.getByText("Invoice data is too large to share via URL"),
).toBeVisible();
await expect(
page.getByText(
"Download the invoice as PDF instead or remove some invoice items and try again.",
),
).toBeVisible();
});
});

View file

@ -2,6 +2,7 @@ import {
ACCORDION_STATE_LOCAL_STORAGE_KEY,
CURRENCY_SYMBOLS,
CURRENCY_TO_LABEL,
DEFAULT_DATE_FORMAT,
LANGUAGE_TO_LABEL,
PDF_DATA_LOCAL_STORAGE_KEY,
SUPPORTED_CURRENCIES,
@ -13,7 +14,7 @@ import {
import { expect, test } from "@playwright/test";
import dayjs from "dayjs";
import { INITIAL_INVOICE_DATA } from "../src/app/constants";
import { STATIC_ASSETS_URL, VIDEO_DEMO_URL } from "@/config";
import { GITHUB_URL, STATIC_ASSETS_URL, VIDEO_DEMO_URL } from "@/config";
test.describe("Invoice Generator Page", () => {
test.beforeEach(async ({ page }) => {
@ -151,7 +152,14 @@ test.describe("Invoice Generator Page", () => {
).toBeHidden();
// Verify GitHub Star CTA button is visible
await expect(page.getByTestId("github-star-cta-button")).toBeVisible();
const githubStarCtaButton = page.getByRole("link", {
name: "Star project on GitHub",
exact: true,
});
await expect(githubStarCtaButton).toBeVisible();
await expect(githubStarCtaButton).toHaveAttribute("href", GITHUB_URL);
await expect(githubStarCtaButton).toHaveAttribute("target", "_blank");
// Verify buttons are enabled
await expect(
@ -232,7 +240,7 @@ test.describe("Invoice Generator Page", () => {
// Verify all supported date formats are available as options with correct labels
for (const dateFormat of SUPPORTED_DATE_FORMATS) {
const preview = dayjs().format(dateFormat);
const isDefault = dateFormat === SUPPORTED_DATE_FORMATS[0];
const isDefault = dateFormat === DEFAULT_DATE_FORMAT;
await expect(
dateFormatSelect.locator(`option[value="${dateFormat}"]`),
@ -266,12 +274,14 @@ test.describe("Invoice Generator Page", () => {
// Invoice Type
await expect(
generalInfoSection.getByRole("textbox", { name: "Invoice Type" }),
generalInfoSection.getByRole("textbox", { name: "Header Notes" }),
).toHaveValue(INITIAL_INVOICE_DATA.invoiceType);
// Visibility toggles
await expect(
generalInfoSection.getByRole("switch", { name: "Show in PDF" }),
generalInfoSection.getByRole("switch", {
name: `Show the "Header Notes" Field in the PDF`,
}),
).toBeChecked();
// **CHECK SELLER INFORMATION SECTION**
@ -297,8 +307,11 @@ test.describe("Invoice Generator Page", () => {
await expect(
sellerVatFieldset.getByRole("textbox", { name: "Value" }),
).toHaveValue(INITIAL_INVOICE_DATA.seller.vatNo);
await expect(
sellerSection.getByRole("switch", { name: /Show in PDF/i }).nth(0),
sellerSection.getByRole("switch", {
name: `Show the 'Seller Tax Number' Field in the PDF`,
}),
).toBeChecked();
// Email field
@ -310,16 +323,22 @@ test.describe("Invoice Generator Page", () => {
await expect(
sellerSection.getByRole("textbox", { name: "Account Number" }),
).toHaveValue(INITIAL_INVOICE_DATA.seller.accountNumber);
await expect(
sellerSection.getByRole("switch", { name: /Show in PDF/i }).nth(1),
sellerSection.getByRole("switch", {
name: `Show the 'Account Number' Field in the PDF`,
}),
).toBeChecked();
// SWIFT/BIC field and visibility toggle
await expect(
sellerSection.getByRole("textbox", { name: "SWIFT/BIC" }),
).toHaveValue(INITIAL_INVOICE_DATA.seller.swiftBic);
await expect(
sellerSection.getByRole("switch", { name: /Show in PDF/i }).nth(2),
sellerSection.getByRole("switch", {
name: `Show the 'SWIFT/BIC' Field in the PDF`,
}),
).toBeChecked();
// Verify Seller Management button is present
@ -395,7 +414,9 @@ test.describe("Invoice Generator Page", () => {
invoiceItemsSection.getByRole("textbox", { name: "Name" }),
).toHaveValue(firstItem.name);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(0),
invoiceItemsSection.getByRole("switch", {
name: "Show the 'Name of Goods/Service' Column in the PDF for item 1",
}),
).toBeChecked();
// Type of GTU field and visibility toggle
@ -403,7 +424,9 @@ test.describe("Invoice Generator Page", () => {
invoiceItemsSection.getByRole("textbox", { name: "Type of GTU" }),
).toHaveValue(firstItem.typeOfGTU);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(1),
invoiceItemsSection.getByRole("switch", {
name: "Show the 'Type of GTU' Column in the PDF for item 1",
}),
).not.toBeChecked(); // we don't want to show this in PDF by default
// Amount field and visibility toggle
@ -413,7 +436,9 @@ test.describe("Invoice Generator Page", () => {
}),
).toHaveValue(firstItem.amount.toString());
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(2),
invoiceItemsSection.getByRole("switch", {
name: "Show the 'Amount' Column in the PDF for item 1",
}),
).toBeChecked();
// Unit field and visibility toggle
@ -421,7 +446,9 @@ test.describe("Invoice Generator Page", () => {
invoiceItemsSection.getByRole("textbox", { name: "Unit" }),
).toHaveValue(firstItem.unit);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(3),
invoiceItemsSection.getByRole("switch", {
name: "Show the 'Unit' Column in the PDF for item 1",
}),
).toBeChecked();
// Net Price field and visibility toggle
@ -431,7 +458,9 @@ test.describe("Invoice Generator Page", () => {
}),
).toHaveValue(firstItem.netPrice.toString());
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(4),
invoiceItemsSection.getByRole("switch", {
name: "Show the 'Net Price' Column in the PDF for item 1",
}),
).toBeChecked();
// VAT field and visibility toggle
@ -439,7 +468,9 @@ test.describe("Invoice Generator Page", () => {
invoiceItemsSection.getByRole("textbox", { name: "VAT", exact: true }),
).toHaveValue(firstItem.vat);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(5),
invoiceItemsSection.getByRole("switch", {
name: "Show the 'VAT' Column in the PDF for item 1",
}),
).toBeChecked();
// Verify VAT helper text is displayed
@ -462,7 +493,9 @@ test.describe("Invoice Generator Page", () => {
}),
);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(6),
invoiceItemsSection.getByRole("switch", {
name: "Show the 'Net Amount' Column in the PDF for item 1",
}),
).toBeChecked();
// VAT Amount field (read-only) and visibility toggle
@ -478,7 +511,9 @@ test.describe("Invoice Generator Page", () => {
}),
);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(7),
invoiceItemsSection.getByRole("switch", {
name: "Show the 'VAT Amount' Column in the PDF for item 1",
}),
).toBeChecked();
// Pre-tax Amount field (read-only) and visibility toggle
@ -494,7 +529,9 @@ test.describe("Invoice Generator Page", () => {
}),
);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(8),
invoiceItemsSection.getByRole("switch", {
name: "Show the 'Pre-tax Amount' Column in the PDF for item 1",
}),
).toBeChecked();
// Verify Add Invoice Item button is present
@ -514,10 +551,11 @@ test.describe("Invoice Generator Page", () => {
invoiceItemsSection.getByText("Item 2", { exact: true }),
).toBeVisible();
// Fill in new item details
// Fill in new invoice item details
const itemNameInput = invoiceItemsSection
.getByRole("textbox", { name: "Name" })
.nth(1);
await itemNameInput.fill("TEST INVOICE ITEM");
await expect(itemNameInput).toHaveValue("TEST INVOICE ITEM");
@ -595,15 +633,25 @@ test.describe("Invoice Generator Page", () => {
await invoiceItemsSection.getByRole("textbox", { name: "Name" }).clear();
await expect(
page.getByText("Seller name is required", { exact: true }),
sellerSection.getByText("Seller name is required", { exact: true }),
).toBeVisible();
// Check that notification is also visible
await expect(
page
.getByLabel("Notifications alt+T")
.getByText("Seller name is required"),
).toBeVisible();
await expect(
page.getByText("Buyer name is required", { exact: true }),
buyerSection.getByText("Buyer name is required", { exact: true }),
).toBeVisible();
// Check that notification is also visible
await expect(
page.getByText("Item name is required", { exact: true }),
page
.getByLabel("Notifications alt+T")
.getByText("Buyer name is required"),
).toBeVisible();
const dateOfIssue = dayjs().format("YYYY-MM-DD");
@ -640,12 +688,18 @@ test.describe("Invoice Generator Page", () => {
// Check for error messages to be hidden
await expect(
page.getByText("Seller name is required", { exact: true }),
sellerSection.getByText("Seller name is required", { exact: true }),
).toBeHidden();
// Check that notification is also hidden
await expect(page.getByLabel("Notifications alt+T")).toBeHidden();
await expect(
page.getByText("Buyer name is required", { exact: true }),
buyerSection.getByText("Buyer name is required", { exact: true }),
).toBeHidden();
// Check that notification is also hidden
await expect(page.getByLabel("Notifications alt+T")).toBeHidden();
});
test("persists data in local storage", async ({ page }) => {
@ -931,27 +985,49 @@ test.describe("Invoice Generator Page", () => {
// Test invalid values
await amountInput.fill("-1");
await expect(page.getByText("Amount must be positive")).toBeVisible();
await expect(
invoiceItemsSection.getByText("Amount must be positive"),
).toBeVisible();
// Check that notification is also visible
await expect(
page
.getByLabel("Notifications alt+T")
.getByText("Amount must be positive"),
).toBeVisible();
await amountInput.fill("0");
await expect(page.getByText("Amount must be positive")).toBeVisible();
await expect(
invoiceItemsSection.getByText("Amount must be positive"),
).toBeVisible();
await amountInput.fill("1000000000000"); // 1 trillion
await expect(
page.getByText("Amount must not exceed 9 999 999 999.99"),
invoiceItemsSection.getByText("Amount must not exceed 9 999 999 999.99"),
).toBeVisible();
// Check that notification is also visible
await expect(
page
.getByLabel("Notifications alt+T")
.getByText("Amount must not exceed 9 999 999 999.99"),
).toBeVisible();
// Test valid values
await amountInput.fill("1");
await expect(page.getByText("Amount must be positive")).toBeHidden();
await expect(
page.getByText("Amount must not exceed 9 999 999 999.99"),
invoiceItemsSection.getByText("Amount must be positive"),
).toBeHidden();
await expect(
invoiceItemsSection.getByText("Amount must not exceed 9 999 999 999.99"),
).toBeHidden();
await amountInput.fill("9999999999.99"); // Maximum valid value
await expect(page.getByText("Amount must be positive")).toBeHidden();
await expect(
page.getByText("Amount must not exceed 9 999 999 999.99"),
invoiceItemsSection.getByText("Amount must be positive"),
).toBeHidden();
await expect(
invoiceItemsSection.getByText("Amount must not exceed 9 999 999 999.99"),
).toBeHidden();
// **NET PRICE FIELD**
@ -962,23 +1038,43 @@ test.describe("Invoice Generator Page", () => {
// Test negative value
await netPriceInput.fill("-100");
await expect(page.getByText("Net price must be >= 0")).toBeVisible();
await expect(
invoiceItemsSection.getByText("Net price must be >= 0"),
).toBeVisible();
// Check that notification is also visible
await expect(
page
.getByLabel("Notifications alt+T")
.getByText("Net price must be >= 0"),
).toBeVisible();
// Test exceeding maximum value
await netPriceInput.fill("1000000000000"); // 1 trillion
await expect(
page.getByText("Net price must not exceed 100 billion"),
invoiceItemsSection.getByText("Net price must not exceed 100 billion"),
).toBeVisible();
// Check that notification is also visible
await expect(
page
.getByLabel("Notifications alt+T")
.getByText("Net price must not exceed 100 billion"),
).toBeVisible();
// Test zero value
await netPriceInput.fill("0");
await expect(page.getByText("Net price must be >= 0")).toBeHidden();
await expect(
invoiceItemsSection.getByText("Net price must be >= 0"),
).toBeHidden();
// Test valid value
await netPriceInput.fill("1");
await expect(page.getByText("Net price must be >= 0")).toBeHidden();
await expect(
page.getByText("Net price must not exceed 100 billion"),
invoiceItemsSection.getByText("Net price must be >= 0"),
).toBeHidden();
await expect(
invoiceItemsSection.getByText("Net price must not exceed 100 billion"),
).toBeHidden();
// **VAT FIELD**
@ -988,27 +1084,32 @@ test.describe("Invoice Generator Page", () => {
exact: true,
});
const helperText = `Must be a number between 0-100 or any text (i.e. NP, OO, etc).`;
const helperText = `Tax rate must be a number between 0-100 or any text (i.e. NP, OO, etc).`;
// Try invalid values
await vatInput.fill("101");
await expect(page.getByText(helperText)).toBeVisible();
await expect(invoiceItemsSection.getByText(helperText)).toBeVisible();
// Check that notification is also visible
await expect(
page.getByLabel("Notifications alt+T").getByText(helperText),
).toBeVisible();
await vatInput.fill("-1");
await expect(page.getByText(helperText)).toBeVisible();
await expect(invoiceItemsSection.getByText(helperText)).toBeVisible();
// Try valid values
await vatInput.fill("23");
await expect(page.getByText(helperText)).toBeHidden();
await expect(invoiceItemsSection.getByText(helperText)).toBeHidden();
await vatInput.fill("NP");
await expect(page.getByText(helperText)).toBeHidden();
await expect(invoiceItemsSection.getByText(helperText)).toBeHidden();
await vatInput.fill("OO");
await expect(page.getByText(helperText)).toBeHidden();
await expect(invoiceItemsSection.getByText(helperText)).toBeHidden();
await vatInput.fill("CUSTOM");
await expect(page.getByText(helperText)).toBeHidden();
await expect(invoiceItemsSection.getByText(helperText)).toBeHidden();
});
test("handles VAT calculations for different rates", async ({ page }) => {
@ -1159,7 +1260,7 @@ test.describe("Invoice Generator Page", () => {
// Verify the "Update all dates" section appears
await expect(
generalInfoSection.getByText(
"Some dates are out of date. Click the button to update all at once:",
"Some dates are out of date. Click the button below to update all dates at once:",
),
).toBeVisible();
@ -1188,7 +1289,7 @@ test.describe("Invoice Generator Page", () => {
await expect(
generalInfoSection.getByText(
"Some dates are out of date. Click the button to update all at once:",
"Some dates are out of date. Click the button below to update all dates at once:",
),
).toBeHidden();
});

View file

@ -35,7 +35,24 @@ test.describe("Seller management", () => {
const manageSellerDialog = page.getByTestId(`manage-seller-dialog`);
// ------- TEST SELLER MANAGEMENT DIALOG FORM -------
// Verify "Pre-fill with values from the current invoice form" switch is visible
const prefillSwitch = manageSellerDialog.getByRole("switch", {
name: `Pre-fill with values from the current invoice form`,
});
await expect(prefillSwitch).toBeVisible();
await expect(prefillSwitch).not.toBeChecked();
// Verify the label is visible
await expect(
manageSellerDialog.getByLabel(
"Pre-fill with values from the current invoice form",
),
).toBeVisible();
/*
* TEST SELLER MANAGEMENT DIALOG FORM
*/
// Fill in form fields
await manageSellerDialog
.getByRole("textbox", { name: "Name" })
@ -69,17 +86,17 @@ test.describe("Seller management", () => {
.fill(TEST_SELLER_DATA.swiftBic);
const taxNumberSwitchInDialogForm = manageSellerDialog.getByRole("switch", {
name: `Show/hide the 'Tax Number' field in the PDF`,
name: `Show the 'Tax Number' field in the PDF`,
});
const accountNumberSwitchInDialogForm = manageSellerDialog.getByRole(
"switch",
{
name: `Show/hide the 'Account Number' field in the PDF`,
name: `Show the 'Account Number' field in the PDF`,
},
);
const swiftBicSwitchInDialogForm = manageSellerDialog.getByRole("switch", {
name: `Show/hide the 'SWIFT/BIC' field in the PDF`,
name: `Show the 'SWIFT/BIC' field in the PDF`,
});
// Verify all switches are checked by default
@ -159,7 +176,10 @@ test.describe("Seller management", () => {
page.getByText("Seller added successfully", { exact: true }),
).toBeVisible();
// ------- TEST SAVED DETAILS IN INVOICE FORM -------
/*
* TEST SAVED DETAILS IN INVOICE FORM AFTER SAVING SELLER IN DIALOG
*/
// Verify all saved details in the Seller Information section form
const sellerForm = page.getByTestId(`seller-information-section`);
@ -265,7 +285,10 @@ test.describe("Seller management", () => {
// Test edit functionality
await sellerForm.getByRole("button", { name: "Edit seller" }).click();
// ------- TEST EDIT FUNCTIONALITY IN SELLER MANAGEMENT DIALOG -------
/*
* TEST EDIT FUNCTIONALITY IN SELLER MANAGEMENT DIALOG
*/
// Verify all fields are populated in edit dialog
await expect(
manageSellerDialog.getByRole("textbox", { name: "Name" }),

View file

@ -79,18 +79,19 @@ test.describe("Stripe Invoice Sharing Logic", () => {
const finalSection = newPage.getByTestId(`final-section`);
// Verify that signature fields are hidden (there are only for default template)
await expect(
finalSection.getByRole("switch", {
name: 'Show "Person Authorized to Receive" Signature Field in the PDF',
}),
).toBeHidden();
/** TEST PERSON AUTHORIZED TO RECEIVE FIELD TO BE HIDDEN (there are only for default template)*/
const personAuthorizedToReceiveFieldset = finalSection.getByRole("group", {
name: "Person Authorized to Receive",
});
await expect(
finalSection.getByRole("switch", {
name: 'Show "Person Authorized to Issue" Signature Field in the PDF',
}),
).toBeHidden();
await expect(personAuthorizedToReceiveFieldset).toBeHidden();
/** TEST PERSON AUTHORIZED TO ISSUE FIELD TO BE HIDDEN (there are only for default template)*/
const personAuthorizedToIssueFieldset = finalSection.getByRole("group", {
name: "Person Authorized to Issue",
});
await expect(personAuthorizedToIssueFieldset).toBeHidden();
// Close the new page
await newPage.close();
@ -185,8 +186,14 @@ test.describe("Stripe Invoice Sharing Logic", () => {
expect(dataParam).toBeTruthy();
expect(dataParam).not.toBe("");
// Verify the share invoice link description toast appears after generating the link
const toast = page.getByTestId("share-invoice-link-description-toast");
await expect(toast).toBeVisible();
await expect(
page.getByText("Invoice link copied to clipboard!"),
page.getByText(
"Share this link to let others view and edit this invoice",
),
).toBeVisible();
});
@ -326,4 +333,71 @@ test.describe("Stripe Invoice Sharing Logic", () => {
await expect(shareButton).toHaveAttribute("data-disabled", "false");
await expect(shareButton).toBeEnabled();
});
test("shows error toast when form has validation errors and generates link after fixing", async ({
page,
}) => {
// Switch to Stripe template
await page
.getByRole("combobox", { name: "Invoice Template" })
.selectOption("stripe");
await page.waitForURL("/?template=stripe");
// Locate the Net Price input for the first invoice item
const netPriceInput = page.locator("#itemNetPrice0");
await expect(netPriceInput).toBeVisible();
// Clear the Net Price field to trigger "Net price is required" validation error
await netPriceInput.clear();
// Wait for debounce timeout so form validation runs and invoiceFormHasErrors becomes true
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(700);
// Click share button — should show error toast because form has validation errors
const shareButton = page.getByRole("button", {
name: "Generate a link to invoice",
});
await shareButton.click();
// Verify the "Unable to Share Invoice" error toast for form errors is visible
await expect(page.getByText("Unable to Share Invoice")).toBeVisible();
await expect(
page.getByText(
"Please fix the errors in the invoice form to generate a shareable link.",
),
).toBeVisible();
// Fill back the Net Price with a valid value
await netPriceInput.fill("100");
// Wait for debounce timeout so form validation passes and invoiceFormHasErrors resets to false
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(700);
// Click share button again — should succeed this time
await shareButton.click();
// Verify link was generated: URL should contain data param
await page.waitForURL((url) => url.searchParams.has("data"));
const url = page.url();
expect(url).toContain("?template=stripe&data=");
// Verify data parameter is not empty
const urlObj = new URL(url);
const dataParam = urlObj.searchParams.get("data");
expect(dataParam).toBeTruthy();
expect(dataParam).not.toBe("");
// Verify the share invoice link description toast appears after generating the link
const toast = page.getByTestId("share-invoice-link-description-toast");
await expect(toast).toBeVisible();
await expect(
page.getByText(
"Share this link to let others view and edit this invoice",
),
).toBeVisible();
});
});

View file

@ -1,7 +1,7 @@
import {
DEFAULT_DATE_FORMAT,
PDF_DATA_LOCAL_STORAGE_KEY,
STRIPE_DEFAULT_DATE_FORMAT,
SUPPORTED_DATE_FORMATS,
type InvoiceData,
} from "@/app/schema";
import fs from "node:fs";
@ -10,7 +10,10 @@ import { SMALL_TEST_IMAGE_BASE64, uploadBase64LogoAsFile } from "./utils";
// IMPORTANT: we use custom extended test fixture that provides a temporary download directory for each test
import { expect, test } from "../utils/extended-playwright-test";
import { renderPdfOnCanvas } from "../utils/render-pdf-on-canvas";
import {
renderPdfOnCanvas,
renderMultiPagePdfOnCanvas,
} from "../utils/render-pdf-on-canvas";
import { STATIC_ASSETS_URL } from "@/config";
test.describe("Stripe Invoice Template", () => {
@ -400,23 +403,50 @@ test.describe("Stripe Invoice Template", () => {
const finalSection = page.getByTestId("final-section");
// Get the signature field switches
const personAuthorizedToReceiveSwitch = finalSection.getByRole("switch", {
name: 'Show "Person Authorized to Receive" Signature Field in the PDF',
/** TEST PERSON AUTHORIZED TO RECEIVE FIELD TO BE VISIBLE */
const personAuthorizedToReceiveFieldset = finalSection.getByRole("group", {
name: "Person Authorized to Receive",
});
const personAuthorizedToIssueSwitch = finalSection.getByRole("switch", {
name: 'Show "Person Authorized to Issue" Signature Field in the PDF',
});
await expect(personAuthorizedToReceiveFieldset).toBeVisible();
const personAuthorizedToReceiveNameInput =
personAuthorizedToReceiveFieldset.getByRole("textbox", {
name: "Name",
});
await expect(personAuthorizedToReceiveNameInput).toBeVisible();
const personAuthorizedToReceiveSwitch =
personAuthorizedToReceiveFieldset.getByRole("switch", {
name: "Show Person Authorized to Receive in PDF",
});
// Verify both switches are visible and enabled
await expect(personAuthorizedToReceiveSwitch).toBeVisible();
await expect(personAuthorizedToReceiveSwitch).toBeEnabled();
await expect(personAuthorizedToReceiveSwitch).toBeChecked();
/** TEST PERSON AUTHORIZED TO ISSUE FIELD TO BE VISIBLE */
const personAuthorizedToIssueFieldset = finalSection.getByRole("group", {
name: "Person Authorized to Issue",
});
await expect(personAuthorizedToIssueFieldset).toBeVisible();
const personAuthorizedToIssueNameInput =
personAuthorizedToIssueFieldset.getByRole("textbox", {
name: "Name",
});
await expect(personAuthorizedToIssueNameInput).toBeVisible();
const personAuthorizedToIssueSwitch =
personAuthorizedToIssueFieldset.getByRole("switch", {
name: "Show Person Authorized to Issue in PDF",
});
await expect(personAuthorizedToIssueSwitch).toBeVisible();
await expect(personAuthorizedToIssueSwitch).toBeEnabled();
// Verify initial state (should be checked by default based on initial data)
await expect(personAuthorizedToReceiveSwitch).toBeChecked();
await expect(personAuthorizedToIssueSwitch).toBeChecked();
// Switch to Stripe template to verify switches become hidden
@ -426,72 +456,23 @@ test.describe("Stripe Invoice Template", () => {
await page.waitForURL("/?template=stripe");
// Verify switches are now hidden
await expect(personAuthorizedToReceiveSwitch).toBeHidden();
await expect(personAuthorizedToIssueSwitch).toBeHidden();
});
/** VERIFY SIGNATURE FIELDS ARE NOW HIDDEN */
test("Invoice Type field only appears for default template", async ({
page,
}) => {
// Verify default template is selected by default
await expect(page).toHaveURL("/?template=default");
const newPersonAuthorizedToReceiveFieldset = finalSection.getByRole(
"group",
{
name: "Person Authorized to Receive",
},
);
const generalInfoSection = page.getByTestId("general-information-section");
await expect(newPersonAuthorizedToReceiveFieldset).toBeHidden();
// Get the Invoice Type field and its visibility switch
const invoiceTypeField = generalInfoSection.getByRole("textbox", {
name: "Invoice Type",
});
const invoiceTypeVisibilitySwitch = generalInfoSection.getByRole("switch", {
name: "Show in PDF",
/** VERIFY PERSON AUTHORIZED TO ISSUE FIELD TO BE HIDDEN */
const newPersonAuthorizedToIssueFieldset = finalSection.getByRole("group", {
name: "Person Authorized to Issue",
});
// Verify field and switch are visible and enabled
await expect(invoiceTypeField).toBeVisible();
await expect(invoiceTypeField).toBeEnabled();
await expect(invoiceTypeVisibilitySwitch).toBeVisible();
await expect(invoiceTypeVisibilitySwitch).toBeEnabled();
// Verify initial state (should be checked by default)
await expect(invoiceTypeVisibilitySwitch).toBeChecked();
// Test filling in the Invoice Type field
await invoiceTypeField.fill("Test Invoice Type");
await expect(invoiceTypeField).toHaveValue("Test Invoice Type");
// Test toggling the visibility switch
await invoiceTypeVisibilitySwitch.click();
await expect(invoiceTypeVisibilitySwitch).not.toBeChecked();
// Toggle it back
await invoiceTypeVisibilitySwitch.click();
await expect(invoiceTypeVisibilitySwitch).toBeChecked();
// Clear the field and verify it's empty
await invoiceTypeField.clear();
await expect(invoiceTypeField).toHaveValue("");
// Switch to Stripe template to verify field becomes hidden
await page
.getByRole("combobox", { name: "Invoice Template" })
.selectOption("stripe");
await page.waitForURL("/?template=stripe");
// Verify Invoice Type field is now hidden
await expect(invoiceTypeField).toBeHidden();
// Switch back to default template
await page
.getByRole("combobox", { name: "Invoice Template" })
.selectOption("default");
// Verify field is visible again and data persists
await expect(invoiceTypeField).toBeVisible();
await expect(invoiceTypeField).toHaveValue(""); // Should be empty as we cleared it
await expect(invoiceTypeVisibilitySwitch).toBeVisible();
await expect(invoiceTypeVisibilitySwitch).toBeChecked(); // Should maintain its state
await expect(newPersonAuthorizedToIssueFieldset).toBeHidden();
});
test("Invoice items fields and switches only appear for default template (except for Tax Settings field)", async ({
@ -525,34 +506,34 @@ test.describe("Stripe Invoice Template", () => {
// =============== ITEM FIELD SWITCHES TESTING ===============
// Get all "Show in PDF" switches for individual fields (these are the ones that only show for first item)
const nameFieldSwitch = invoiceItemsSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(0);
const typeOfGTUFieldSwitch = invoiceItemsSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(1);
const amountFieldSwitch = invoiceItemsSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(2);
const unitFieldSwitch = invoiceItemsSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(3);
const netPriceFieldSwitch = invoiceItemsSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(4);
const vatFieldSwitch = invoiceItemsSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(5);
const netAmountFieldSwitch = invoiceItemsSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(6);
const vatAmountFieldSwitch = invoiceItemsSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(7);
const preTaxAmountFieldSwitch = invoiceItemsSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(8);
const nameFieldSwitch = invoiceItemsSection.getByRole("switch", {
name: "Show the 'Name of Goods/Service' Column in the PDF for item 1",
});
const typeOfGTUFieldSwitch = invoiceItemsSection.getByRole("switch", {
name: "Show the 'Type of GTU' Column in the PDF for item 1",
});
const amountFieldSwitch = invoiceItemsSection.getByRole("switch", {
name: "Show the 'Amount' Column in the PDF for item 1",
});
const unitFieldSwitch = invoiceItemsSection.getByRole("switch", {
name: "Show the 'Unit' Column in the PDF for item 1",
});
const netPriceFieldSwitch = invoiceItemsSection.getByRole("switch", {
name: "Show the 'Net Price' Column in the PDF for item 1",
});
const vatFieldSwitch = invoiceItemsSection.getByRole("switch", {
name: "Show the 'VAT' Column in the PDF for item 1",
});
const netAmountFieldSwitch = invoiceItemsSection.getByRole("switch", {
name: "Show the 'Net Amount' Column in the PDF for item 1",
});
const vatAmountFieldSwitch = invoiceItemsSection.getByRole("switch", {
name: "Show the 'VAT Amount' Column in the PDF for item 1",
});
const preTaxAmountFieldSwitch = invoiceItemsSection.getByRole("switch", {
name: "Show the 'Pre-tax Amount' Column in the PDF for item 1",
});
// Verify all field switches are visible and enabled
const fieldSwitches = [
nameFieldSwitch,
@ -629,19 +610,21 @@ test.describe("Stripe Invoice Template", () => {
await expect(showVatTableSummarySwitch).toBeHidden();
await expect(typeOfGTUField).toBeHidden();
// TODO: fix below conditions
// await expect(nameFieldSwitch).toBeHidden();
// await expect(typeOfGTUFieldSwitch).toBeHidden();
// await expect(amountFieldSwitch).toBeHidden();
// await expect(unitFieldSwitch).toBeHidden();
// await expect(netPriceFieldSwitch).toBeHidden();
await expect(nameFieldSwitch).toBeHidden();
await expect(typeOfGTUFieldSwitch).toBeHidden();
await expect(amountFieldSwitch).toBeHidden();
// // we expect vat field switch to be visible because it is the only field that is visible in stripe template
// await expect(vatFieldSwitch).toBeVisible();
// NOTE: Unit field switch is visible in stripe template
await expect(unitFieldSwitch).toBeVisible();
// await expect(netAmountFieldSwitch).toBeHidden();
// await expect(vatAmountFieldSwitch).toBeHidden();
// await expect(preTaxAmountFieldSwitch).toBeHidden();
await expect(netPriceFieldSwitch).toBeHidden();
// NOTE: VAT (Tax Settings) field switch is visible in stripe template
await expect(vatFieldSwitch).toBeVisible();
await expect(netAmountFieldSwitch).toBeHidden();
await expect(vatAmountFieldSwitch).toBeHidden();
await expect(preTaxAmountFieldSwitch).toBeHidden();
// =============== BACK TO DEFAULT TEMPLATE TESTING ===============
@ -726,7 +709,7 @@ test.describe("Stripe Invoice Template", () => {
await expect(paymentMethodVisibilitySwitch).toBeChecked(); // Should maintain its state
});
test("automatically enables VAT field visibility and sets date format when switching to Stripe template", async ({
test("automatically sets date format when switching to Stripe template", async ({
page,
browserName,
downloadDir,
@ -736,10 +719,10 @@ test.describe("Stripe Invoice Template", () => {
const invoiceItemsSection = page.getByTestId("invoice-items-section");
// Get the VAT field switch (5th "Show in PDF" switch)
const vatFieldSwitch = invoiceItemsSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(5);
// Get the VAT field switch
const vatFieldSwitch = invoiceItemsSection.getByRole("switch", {
name: "Show the 'VAT' Column in the PDF for item 1",
});
// Verify VAT switch is visible and checked by default
await expect(vatFieldSwitch).toBeVisible();
@ -750,7 +733,15 @@ test.describe("Stripe Invoice Template", () => {
await vatFieldSwitch.click();
await expect(vatFieldSwitch).not.toBeChecked();
// Set VAT to a numeric value to make Tax column potentially visible in Stripe
// Get the date format select and verify initial value
const dateFormatSelect = page.getByRole("combobox", {
name: "Date Format",
});
// Verify date format is set to default format (YYYY-MM-DD)
await expect(dateFormatSelect).toHaveValue(DEFAULT_DATE_FORMAT);
// Set VAT to a numeric value to make Tax column visible in Stripe
const vatInput = invoiceItemsSection.getByRole("textbox", {
name: "VAT",
exact: true,
@ -760,7 +751,7 @@ test.describe("Stripe Invoice Template", () => {
// Wait debounce timeout
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(800);
await page.waitForTimeout(700);
// Switch to Stripe template
await page
@ -770,11 +761,22 @@ test.describe("Stripe Invoice Template", () => {
// Wait for URL to be updated
await page.waitForURL("/?template=stripe");
// VAT field switch should now be hidden (since most switches are hidden in Stripe)
await expect(vatFieldSwitch).toBeHidden();
const newInvoiceItemsSection = page.getByTestId("invoice-items-section");
const newVatFieldSwitch = newInvoiceItemsSection.getByRole("switch", {
name: "Show the 'VAT' Column in the PDF for item 1",
});
await expect(newVatFieldSwitch).toBeVisible();
// because we toggle VAT field visibility off in default template, it should be unchecked in Stripe template
await expect(newVatFieldSwitch).not.toBeChecked();
// Verify date format is set to Stripe default format (MMMM D, YYYY)
const newDateFormatSelect = page.getByRole("combobox", {
name: "Date Format",
});
await expect(newDateFormatSelect).toHaveValue(STRIPE_DEFAULT_DATE_FORMAT);
// BUT the VAT field visibility should be automatically enabled
// Let's verify this by checking the localStorage data
const storedData = (await page.evaluate((key) => {
return localStorage.getItem(key);
}, PDF_DATA_LOCAL_STORAGE_KEY)) as string;
@ -782,8 +784,7 @@ test.describe("Stripe Invoice Template", () => {
expect(storedData).toBeTruthy();
const parsedData = JSON.parse(storedData) as InvoiceData;
// The VAT field should be automatically enabled for Stripe template
expect(parsedData.items[0].vatFieldIsVisible).toBe(true);
expect(parsedData.items[0].vatFieldIsVisible).toBe(false);
// The date format should be automatically set to Stripe default format
expect(parsedData.dateFormat).toBe(STRIPE_DEFAULT_DATE_FORMAT);
@ -833,13 +834,13 @@ test.describe("Stripe Invoice Template", () => {
);
await expect(page.locator("canvas")).toHaveScreenshot(
"automatically-enables-VAT-field-visibility-and-sets-date-format-when-switching-to-Stripe-template.png",
"automatically-sets-date-format-when-switching-to-Stripe-template.png",
);
// navigate back to the previous page
await page.goto("/", { waitUntil: "commit" });
// verify that the default template is selected
// verify that the stripe template is selected
const templateCombobox = page.getByRole("combobox", {
name: "Invoice Template",
});
@ -853,14 +854,20 @@ test.describe("Stripe Invoice Template", () => {
// Wait for URL to be updated
await page.waitForURL("/?template=default");
// VAT switch should be visible again and should maintain the enabled state
await expect(vatFieldSwitch).toBeVisible();
await expect(vatFieldSwitch).toBeChecked(); // Should be re-enabled due to Stripe template logic
const newVatInput = page.getByRole("textbox", {
name: "VAT",
exact: true,
});
// Verify VAT input still has the numeric value
await expect(vatInput).toHaveValue("20");
await expect(newVatInput).toHaveValue("20");
// Verify that date format is restored to default template format
const finalDateFormatSelect = page.getByRole("combobox", {
name: "Date Format",
});
await expect(finalDateFormatSelect).toHaveValue(DEFAULT_DATE_FORMAT);
const finalStoredData = (await page.evaluate((key) => {
return localStorage.getItem(key);
}, PDF_DATA_LOCAL_STORAGE_KEY)) as string;
@ -869,7 +876,7 @@ test.describe("Stripe Invoice Template", () => {
const finalParsedData = JSON.parse(finalStoredData) as InvoiceData;
// The date format should be restored to default format
expect(finalParsedData.dateFormat).toBe(SUPPORTED_DATE_FORMATS[0]);
expect(finalParsedData.dateFormat).toBe(DEFAULT_DATE_FORMAT);
});
test("generates PDF with logo and payment URL when using Stripe template", async ({
@ -973,4 +980,342 @@ test.describe("Stripe Invoice Template", () => {
"pdf-with-logo-and-payment-url-when-using-stripe-template.png",
);
});
test("displays QR code in PDF when QR code data is provided", async ({
page,
browserName,
downloadDir,
}, testInfo) => {
const QR_CODE_TEST_DATA = {
data: "https://easyinvoicepdf.com",
description: "QR Code Description",
} as const satisfies {
data: string;
description: string;
};
// Switch to Stripe template
await page
.getByRole("combobox", { name: "Invoice Template" })
.selectOption("stripe");
await page.waitForURL("/?template=stripe");
const finalSection = page.getByTestId("final-section");
const qrCodeFieldset = finalSection.getByRole("group", {
name: "QR Code",
});
await expect(qrCodeFieldset).toBeVisible();
// Verify that "Show QR Code in PDF" switch is on by default
const showQrCodeSwitch = qrCodeFieldset.getByRole("switch", {
name: "Show QR Code in PDF",
});
await expect(showQrCodeSwitch).toBeVisible();
await expect(showQrCodeSwitch).toBeEnabled();
await expect(showQrCodeSwitch).toBeChecked();
// Verify QR Code Data field is empty by default
const qrCodeDataTextarea = qrCodeFieldset.getByRole("textbox", {
name: "Data",
});
await expect(qrCodeDataTextarea).toBeVisible();
await expect(qrCodeDataTextarea).toHaveValue("");
// Fill in the QR code data field
await qrCodeDataTextarea.fill(QR_CODE_TEST_DATA.data);
// Verify QR Code Description field is empty by default
const qrCodeDescriptionTextarea = qrCodeFieldset.getByRole("textbox", {
name: "Description (optional)",
});
await expect(qrCodeDescriptionTextarea).toBeVisible();
await expect(qrCodeDescriptionTextarea).toHaveValue("");
// Fill in the QR code description field
await qrCodeDescriptionTextarea.fill(QR_CODE_TEST_DATA.description);
// for better debugging screenshots, we fill in the notes field with a test note =)
await finalSection
.getByRole("textbox", { name: "Notes", exact: true })
.fill(`Test: ${testInfo.title} (${testInfo.project.name})`);
// Wait for debounce timeout
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(700);
const downloadPdfEnglishButton = page.getByRole("link", {
name: "Download PDF in English",
});
await expect(downloadPdfEnglishButton).toBeVisible();
await expect(downloadPdfEnglishButton).toBeEnabled();
// Click the download button and wait for download
const [download] = await Promise.all([
page.waitForEvent("download"),
downloadPdfEnglishButton.click(),
]);
// Get the suggested filename
const suggestedFilename = download.suggestedFilename();
// save the file to temporary directory
const pdfFilePath = path.join(
downloadDir,
`${browserName}-${suggestedFilename}`,
);
await download.saveAs(pdfFilePath);
// Convert to absolute path and use proper file URL format
const absolutePath = path.resolve(pdfFilePath);
await expect.poll(() => fs.existsSync(absolutePath)).toBe(true);
/**
* Render the PDF on a canvas and take a screenshot of it
*/
const pdfBytes = fs.readFileSync(absolutePath);
await page.goto("about:blank");
await renderPdfOnCanvas(page, pdfBytes);
await page.waitForFunction(
() =>
(window as unknown as { __PDF_RENDERED__: boolean })
.__PDF_RENDERED__ === true,
);
await expect(page.locator("canvas")).toHaveScreenshot(
"displays-qr-code-in-pdf-stripe-template.png",
);
/**
* TURN OFF QR CODE IN PDF AND DOWNLOAD PDF AGAIN
*/
// navigate back to the previous page
await page.goto("/", { waitUntil: "commit" });
// verify that we are on the default template
await expect(page).toHaveURL("/?template=stripe");
const newFinalSection = page.getByTestId("final-section");
const newQrCodeFieldset = newFinalSection.getByRole("group", {
name: "QR Code",
});
await expect(newQrCodeFieldset).toBeVisible();
// Verify that "Show QR Code in PDF" switch is on by default
const newShowQrCodeSwitch = newQrCodeFieldset.getByRole("switch", {
name: "Show QR Code in PDF",
});
await expect(newShowQrCodeSwitch).toBeVisible();
await expect(newShowQrCodeSwitch).toBeEnabled();
await expect(newShowQrCodeSwitch).toBeChecked();
// toggle the switch off
await newShowQrCodeSwitch.click();
// verify that the switch is off
await expect(newShowQrCodeSwitch).not.toBeChecked();
// Verify QR Code Data field is empty after toggling off
const newQrCodeDataTextarea = newQrCodeFieldset.getByRole("textbox", {
name: "Data",
});
await expect(newQrCodeDataTextarea).toBeVisible();
await expect(newQrCodeDataTextarea).toHaveValue(QR_CODE_TEST_DATA.data);
// Verify QR Code Description field is empty after toggling off
const newQrCodeDescriptionTextarea = newQrCodeFieldset.getByRole(
"textbox",
{
name: "Description (optional)",
},
);
await expect(newQrCodeDescriptionTextarea).toBeVisible();
await expect(newQrCodeDescriptionTextarea).toHaveValue(
QR_CODE_TEST_DATA.description,
);
// for better debugging screenshots, we fill in the notes field with a test note =)
await newFinalSection
.getByRole("textbox", { name: "Notes", exact: true })
.fill(
`Test: ${testInfo.title} - QR code hidden in PDF (${testInfo.project.name})`,
);
// wait for debounce timeout
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(700);
const newDownloadPdfEnglishButton = page.getByRole("link", {
name: "Download PDF in English",
});
// Download the PDF again
const [downloadPdfWithoutQrCode] = await Promise.all([
page.waitForEvent("download"),
newDownloadPdfEnglishButton.click(),
]);
// Get the suggested filename
const suggestedFilenameWithoutQrCode =
downloadPdfWithoutQrCode.suggestedFilename();
// save the file to temporary directory
const pdfFilePath2 = path.join(
downloadDir,
`${browserName}-${suggestedFilenameWithoutQrCode}`,
);
await downloadPdfWithoutQrCode.saveAs(pdfFilePath2);
/**
* Render the PDF on a canvas and take a screenshot to verify QR code is not displayed
*/
const pdfBytesWithoutQrCode = fs.readFileSync(pdfFilePath2);
await page.goto("about:blank");
await renderPdfOnCanvas(page, pdfBytesWithoutQrCode);
await page.waitForFunction(
() =>
(window as unknown as { __PDF_RENDERED__: boolean })
.__PDF_RENDERED__ === true,
);
await expect(page.locator("canvas")).toHaveScreenshot(
"qr-code-hidden-in-pdf-stripe-template.png",
);
});
test("generates multi-page PDF when invoice has many items", async ({
page,
browserName,
downloadDir,
}, testInfo) => {
// Switch to Stripe template
await page
.getByRole("combobox", { name: "Invoice Template" })
.selectOption("stripe");
// Wait for URL to be updated
await page.waitForURL("/?template=stripe");
// Verify we're on the Stripe template
await expect(page).toHaveURL("/?template=stripe");
const invoiceItemsSection = page.getByTestId("invoice-items-section");
// Add additional invoice items to trigger 2-page PDF
for (let i = 0; i < 17; i++) {
await invoiceItemsSection
.getByRole("button", { name: "Add invoice item" })
.click();
// Fill minimal required fields for the new item
const itemFieldset = invoiceItemsSection.getByRole("group", {
name: `Item ${i + 2}`, // Item numbers start at 1
});
// Add longer descriptions only to odd-numbered items to test mixed content layout
// This verifies that the PDF template handles varying text lengths correctly
// and maintains proper spacing between short and long item descriptions
const itemName =
// eslint-disable-next-line playwright/no-conditional-in-test
(i + 2) % 2 === 1
? `Item ${i + 2} - Professional consulting services including detailed analysis, comprehensive reporting, and ongoing support for enterprise-level implementations`
: `Item ${i + 2}`;
await itemFieldset.getByRole("textbox", { name: "Name" }).fill(itemName);
// Set VAT to 10% for each item
const taxSettingsFieldset = itemFieldset.getByRole("group", {
name: "Tax Settings",
});
// Use different tax rates: 10%, 20%, or 50%
const taxRate =
// eslint-disable-next-line playwright/no-conditional-in-test
(i + 2) % 3 === 0 ? "50" : (i + 2) % 2 === 0 ? "20" : "10";
await taxSettingsFieldset
.getByRole("textbox", { name: "VAT", exact: true })
.fill(taxRate);
await itemFieldset
.getByRole("spinbutton", {
name: "Net Price (Rate or Unit Price)",
})
.fill(`${100 * (i + 1)}`);
}
const finalSection = page.getByTestId("final-section");
// for better debugging screenshots, we fill in the notes field with a test note
await finalSection
.getByRole("textbox", { name: "Notes", exact: true })
.fill(
`Test: generates multi-page PDF when invoice has many items (${testInfo.project.name})`,
);
// Wait for PDF preview to regenerate after invoice data changes (debounce timeout)
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(700);
const downloadPdfEnglishButton = page.getByRole("link", {
name: "Download PDF in English",
});
await expect(downloadPdfEnglishButton).toBeVisible();
await expect(downloadPdfEnglishButton).toBeEnabled();
// Click the download button and wait for download
const [download] = await Promise.all([
page.waitForEvent("download"),
downloadPdfEnglishButton.click(),
]);
// Get the suggested filename
const suggestedFilename = download.suggestedFilename();
// save the file to temporary directory
const pdfFilePath = path.join(
downloadDir,
`${browserName}-${suggestedFilename}`,
);
await download.saveAs(pdfFilePath);
// Convert to absolute path and use proper file URL format
const absolutePath = path.resolve(pdfFilePath);
await expect.poll(() => fs.existsSync(absolutePath)).toBe(true);
/**
* RENDER ALL PDF PAGES ON A SINGLE CANVAS AND TAKE SCREENSHOT
*/
const pdfBytes = fs.readFileSync(absolutePath);
await page.goto("about:blank");
await renderMultiPagePdfOnCanvas(page, pdfBytes);
await page.waitForFunction(
() =>
(window as unknown as { __PDF_RENDERED__: boolean })
.__PDF_RENDERED__ === true,
);
await expect(page.locator("canvas")).toHaveScreenshot(
"stripe-template-multi-pages.png",
);
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View file

@ -55,3 +55,86 @@ export async function renderPdfOnCanvas(page: Page, pdfBytes: Uint8Array) {
</html>
`);
}
/**
* Renders all pages of a multi-page PDF vertically stacked on a single canvas
*
* **Docs**: https://mozilla.github.io/pdf.js/api/draft/module-pdfjsLib.html
*/
export async function renderMultiPagePdfOnCanvas(
page: Page,
pdfBytes: Uint8Array,
) {
await page.setContent(`
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<style>
body { margin: 0 }
canvas { display: block }
</style>
</head>
<body>
<canvas id="pdf"></canvas>
<script type="module">
import * as pdfjsLib from 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.8.69/pdf.mjs'
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.8.69/pdf.worker.mjs'
pdfjsLib.GlobalWorkerOptions.fontExtraProperties = true
const pdfData = new Uint8Array([${pdfBytes.join(",")}])
const pdf = await pdfjsLib.getDocument({ data: pdfData, disableFontFace: true }).promise;
const numPages = pdf.numPages
const pageGap = 40 // Space between pages in pixels
// Get all page viewports to calculate total width and height
const viewports = []
let totalWidth = 0
let maxHeight = 0
for (let i = 1; i <= numPages; i++) {
const page = await pdf.getPage(i)
const viewport = page.getViewport({ scale: 2 })
viewports.push({ page, viewport })
totalWidth += viewport.width
if (i < numPages) {
totalWidth += pageGap // Add gap between pages
}
maxHeight = Math.max(maxHeight, viewport.height)
}
// Create a single canvas to fit all pages horizontally
const canvas = document.getElementById('pdf')
canvas.width = totalWidth
canvas.height = maxHeight
const ctx = canvas.getContext('2d')
ctx.imageSmoothingEnabled = false
// Render all pages sequentially, placing them horizontally
let currentX = 0
for (const { page, viewport } of viewports) {
ctx.save()
ctx.translate(currentX, 0)
await page.render({
canvasContext: ctx,
viewport
}).promise
ctx.restore()
currentX += viewport.width + pageGap
}
window.__PDF_RENDERED__ = true
</script>
</body>
</html>
`);
}

View file

@ -42,6 +42,10 @@
"openSource": {
"title": "Open Source",
"description": "Völlig kostenlos und Open-Source. Nutzen Sie es online oder hosten Sie es selbst mit vollem Zugriff auf den Code."
},
"qrCodeMultiPDFpageSupport": {
"title": "QR-Code & Mehrseitige PDF-Unterstützung",
"description": "Fügen Sie QR-Codes mit Zahlungslinks zu Ihren Rechnungen hinzu und erstellen Sie mehrseitige PDFs für längere Rechnungen mit vielen Positionen."
}
}
},
@ -61,7 +65,7 @@
"createdBy": "Erstellt von"
},
"buttons": {
"goToApp": "Zur App gehen",
"goToApp": "Öffnen",
"viewOnGithub": "Auf GitHub ansehen",
"starOnGithub": "Auf GitHub bewerten",
"switchLanguage": "Sprache wechseln",

View file

@ -35,6 +35,10 @@
"openSource": {
"title": "Open Source",
"description": "Completely free and open-source. Use it online or host it yourself with full access to the code."
},
"qrCodeMultiPDFpageSupport": {
"title": "QR Code & Multi-Page PDF Support",
"description": "Add QR codes with payment links to your invoices and generate multi-page PDFs for longer invoices with many line items."
}
}
},

View file

@ -42,6 +42,10 @@
"openSource": {
"title": "Código Abierto",
"description": "Completamente gratuito y de código abierto. Úselo en línea o alójelo usted mismo con acceso completo al código."
},
"qrCodeMultiPDFpageSupport": {
"title": "Código QR y Soporte PDF Multipágina",
"description": "Agregue códigos QR con enlaces de pago a sus facturas y genere PDFs de varias páginas para facturas más largas con muchos artículos."
}
}
},
@ -61,7 +65,7 @@
"createdBy": "Creado por"
},
"buttons": {
"goToApp": "Ir a la aplicación",
"goToApp": "Abrir",
"viewOnGithub": "Ver en GitHub",
"starOnGithub": "Dar estrella en GitHub",
"switchLanguage": "Cambiar idioma",

View file

@ -34,6 +34,10 @@
"openSource": {
"title": "Open Source",
"description": "Entièrement gratuit et open-source. Utilisez-le en ligne ou hébergez-le vous-même avec un accès complet au code."
},
"qrCodeMultiPDFpageSupport": {
"title": "Code QR et Support PDF Multi-pages",
"description": "Ajoutez des codes QR avec des liens de paiement à vos factures et générez des PDF multi-pages pour les factures plus longues avec de nombreux articles."
}
}
},
@ -53,7 +57,7 @@
"createdBy": "Créé par"
},
"buttons": {
"goToApp": "Aller à l'application",
"goToApp": "Ouvrir",
"viewOnGithub": "Voir sur GitHub",
"starOnGithub": "Mettre une étoile sur GitHub",
"switchLanguage": "Changer de langue",

View file

@ -34,6 +34,10 @@
"openSource": {
"title": "Open Source",
"description": "Completamente gratuito e open-source. Usalo online o ospitalo tu stesso con accesso completo al codice."
},
"qrCodeMultiPDFpageSupport": {
"title": "Codice QR e Supporto PDF Multi-pagina",
"description": "Aggiungi codici QR con link di pagamento alle tue fatture e genera PDF multi-pagina per fatture più lunghe con molte voci."
}
}
},
@ -53,7 +57,7 @@
"createdBy": "Creato da"
},
"buttons": {
"goToApp": "Vai all'app",
"goToApp": "Apri",
"viewOnGithub": "Visualizza su GitHub",
"starOnGithub": "Metti una stella su GitHub",
"switchLanguage": "Cambia lingua",

View file

@ -34,6 +34,10 @@
"openSource": {
"title": "Open Source",
"description": "Volledig gratis en open-source. Gebruik het online of host het zelf met volledige toegang tot de code."
},
"qrCodeMultiPDFpageSupport": {
"title": "QR-code & Meerdere Pagina's PDF-ondersteuning",
"description": "Voeg QR-codes met betaallinks toe aan je facturen en genereer PDF's met meerdere pagina's voor langere facturen met veel items."
}
}
},
@ -53,7 +57,7 @@
"createdBy": "Gemaakt door"
},
"buttons": {
"goToApp": "Ga naar app",
"goToApp": "Open",
"viewOnGithub": "Bekijk op GitHub",
"starOnGithub": "Ster geven op GitHub",
"switchLanguage": "Verander taal",

View file

@ -42,6 +42,10 @@
"openSource": {
"title": "Open Source",
"description": "Całkowicie darmowe i open-source. Używaj online lub hostuj samodzielnie z pełnym dostępem do kodu."
},
"qrCodeMultiPDFpageSupport": {
"title": "Kod QR i Obsługa Wielostronicowych PDF",
"description": "Dodawaj kody QR z linkami płatności do swoich faktur i generuj wielostronicowe pliki PDF dla dłuższych faktur z wieloma pozycjami."
}
}
},
@ -61,7 +65,7 @@
"createdBy": "Stworzone przez"
},
"buttons": {
"goToApp": "Przejdź do aplikacji",
"goToApp": "Otwórz",
"viewOnGithub": "Zobacz na GitHub",
"starOnGithub": "Oznacz gwiazdką na GitHub",
"switchLanguage": "Zmień język",

View file

@ -42,6 +42,10 @@
"openSource": {
"title": "Código Aberto",
"description": "Totalmente gratuito e de código aberto. Use online ou hospede você mesmo com acesso completo ao código."
},
"qrCodeMultiPDFpageSupport": {
"title": "Código QR e Suporte a PDF Multipágina",
"description": "Adicione códigos QR com links de pagamento às suas faturas e gere PDFs de várias páginas para faturas mais longas com muitos itens."
}
}
},
@ -61,7 +65,7 @@
"createdBy": "Criado por"
},
"buttons": {
"goToApp": "Ir para o app",
"goToApp": "Abrir",
"viewOnGithub": "Ver no GitHub",
"starOnGithub": "Dar estrela no GitHub",
"switchLanguage": "Mudar idioma",

View file

@ -42,6 +42,10 @@
"openSource": {
"title": "Открытый исходный код",
"description": "Полностью бесплатно и с открытым исходным кодом. Используйте онлайн или разместите самостоятельно с полным доступом к коду."
},
"qrCodeMultiPDFpageSupport": {
"title": "QR-код и Поддержка Многостраничных PDF",
"description": "Добавляйте QR-коды с платёжными ссылками к вашим инвойсам и создавайте многостраничные PDF для длинных инвойсов с большим количеством позиций."
}
}
},
@ -61,7 +65,7 @@
"createdBy": "Создано"
},
"buttons": {
"goToApp": "Перейти в приложение",
"goToApp": "Открыть",
"viewOnGithub": "Посмотреть на GitHub",
"starOnGithub": "Поставить звезду на GitHub",
"switchLanguage": "Изменить язык",

View file

@ -42,6 +42,10 @@
"openSource": {
"title": "Відкритий код",
"description": "Повністю безкоштовно та з відкритим кодом. Використовуйте онлайн або розмістіть самостійно з повним доступом до коду."
},
"qrCodeMultiPDFpageSupport": {
"title": "QR-код та Підтримка Багатосторінкових PDF",
"description": "Додавайте QR-коди з платіжними посиланнями до ваших рахунків та створюйте багатосторінкові PDF для довгих рахунків з великою кількістю позицій."
}
}
},
@ -61,7 +65,7 @@
"createdBy": "Створено"
},
"buttons": {
"goToApp": "Перейти до додатку",
"goToApp": "Відкрити",
"viewOnGithub": "Переглянути на GitHub",
"starOnGithub": "Поставити зірку на GitHub",
"switchLanguage": "Змінити мову",

View file

@ -2,7 +2,7 @@
"name": "pdf-invoice-generator",
"version": "1.0.1",
"private": true,
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a",
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a",
"engines": {
"node": ">=20.0.0"
},
@ -22,12 +22,14 @@
"e2e": "pnpm exec playwright test --reporter=list",
"e2e:ui": "pnpm exec playwright test --ui",
"e2e:not-flaky": "pnpm e2e --workers=2",
"e2e:update-snapshots": "pnpm e2e --update-snapshots",
"dedupe": "pnpm dedupe",
"lint-stage": "npx lint-staged --verbose",
"check-meta": "pnpx check-site-meta",
"dev:email": "email dev --dir src/emails",
"prepare": "husky",
"expose-to-internet": "pnpm dlx localtunnel --port 3000",
"expose-to-internet": "pnpm dlx cloudflared tunnel --url http://localhost:3000",
"cloudflared:tunnel": "pnpm dlx cloudflared tunnel run easy-invoice-pdf-local-dev",
"corepack:enable": "corepack enable",
"corepack:prepare": "corepack prepare pnpm@latest"
},
@ -55,6 +57,7 @@
"@t3-oss/env-nextjs": "0.13.4",
"@tailwindcss/typography": "0.5.16",
"@types/mdx": "2.0.13",
"@types/qrcode": "1.5.6",
"@types/ua-parser-js": "0.7.39",
"@upstash/ratelimit": "2.0.5",
"@upstash/redis": "1.34.6",
@ -70,16 +73,15 @@
"jszip": "3.10.1",
"lucide-react": "0.477.0",
"lz-string": "1.5.0",
"motion": "12.23.24",
"n2words": "1.21.0",
"next": "14.2.15",
"next-intl": "4.0.2",
"pdf-parse": "1.1.1",
"qrcode": "1.5.4",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hook-form": "7.53.1",
"react-intersection-observer": "9.16.0",
"react-pdf": "10.1.0",
"react-pdf": "9.2.1",
"remark-gfm": "4.0.1",
"resend": "4.2.0",
"sonner": "1.7.4",
@ -95,10 +97,9 @@
"@playwright/test": "1.56.1",
"@types/file-saver": "2.0.7",
"@types/node": "22.8.1",
"@types/pdf-parse": "1.1.5",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript/native-preview": "7.0.0-dev.20250819.1",
"@typescript/native-preview": "7.0.0-dev.20260217.1",
"autoprefixer": "10.4.21",
"dotenv": "17.2.3",
"eslint": "9.33.0",
@ -117,7 +118,7 @@
"shadcn": "3.2.1",
"tailwindcss": "3.4.14",
"typescript": "5.9.3",
"typescript-eslint": "8.40.0",
"typescript-eslint": "8.56.0",
"vitest": "3.2.4"
}
}

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,16 @@
import { env } from "@/env";
export async function fetchGithubStars(): Promise<number> {
import { cache } from "react";
/**
* Fetches the current star count for the GitHub repository.
*
* This function is cached using React's `cache()` to prevent duplicate requests
* during the same render cycle. The data is revalidated every 60 seconds.
*
*/
export const fetchGithubStars = cache(async (): Promise<number> => {
try {
const res = await fetch(
"https://api.github.com/repos/VladSez/easy-invoice-pdf",
@ -25,4 +34,4 @@ export async function fetchGithubStars(): Promise<number> {
console.error("Failed to fetch GitHub stars:", error);
return 0;
}
}
});

View file

@ -85,7 +85,7 @@ function PremiumDonationToast(props: ToastProps) {
return (
<div
className="relative max-w-sm rounded-lg border border-indigo-200 bg-gradient-to-br from-indigo-50 via-white to-purple-50 p-4 shadow-xl"
className="relative w-full rounded-lg border border-indigo-200 bg-gradient-to-br from-indigo-50 via-white to-purple-50 p-4 shadow-xl lg:max-w-sm"
data-testid="download-pdf-toast"
>
{/* Close button - styled like default Sonner toast */}
@ -103,12 +103,12 @@ function PremiumDonationToast(props: ToastProps) {
<p className="mb-4 text-xs leading-relaxed text-gray-700">
{description}
</p>
<div className="flex gap-2">
<div className="flex items-end justify-end gap-2">
<Button
size="sm"
variant="default"
asChild
className="h-8 flex-1 border-gray-300 text-xs transition-all duration-200 hover:scale-105"
className="h-8 max-w-[150px] flex-1 border-gray-300 text-xs transition-all duration-200 hover:scale-105"
>
<a
href={GITHUB_URL}
@ -143,7 +143,7 @@ function PremiumToastFeedbackButton(
return (
<Button
size="sm"
className="h-8 flex-1 border border-gray-300 bg-gray-100 text-xs text-gray-900 transition-all duration-200 hover:bg-gray-200"
className="h-8 max-w-[150px] flex-1 border border-gray-300 bg-gray-100 text-xs text-gray-900 transition-all duration-200 hover:bg-gray-200"
variant="secondary"
asChild
data-testid="toast-cta-btn"
@ -166,7 +166,7 @@ function DefaultDonationToast(props: ToastProps) {
return (
<div
className="flex max-w-md items-start gap-3 rounded-lg border border-indigo-200 bg-gradient-to-br from-indigo-50 via-white to-purple-50 p-4 shadow-xl"
className="flex w-full items-start gap-3 rounded-lg border border-indigo-200 bg-gradient-to-br from-indigo-50 via-white to-purple-50 p-4 shadow-xl lg:max-w-md"
data-testid="download-pdf-toast"
>
{/* Close button - styled like default Sonner toast */}
@ -183,10 +183,10 @@ function DefaultDonationToast(props: ToastProps) {
<p className="mb-3 text-xs leading-relaxed text-gray-600">
{description}
</p>
<div className="flex gap-2">
<div className="flex items-end justify-end gap-2">
<Button
size="sm"
className="group h-8 bg-gray-900 px-3 text-xs font-medium text-white transition-all duration-200 hover:scale-105 hover:bg-gray-800"
className="group h-8 max-w-[150px] bg-gray-900 px-3 text-xs font-medium text-white transition-all duration-200 hover:scale-105 hover:bg-gray-800"
asChild
>
<a
@ -222,7 +222,7 @@ function DefaultToastFeedbackButton(
return (
<Button
size="sm"
className="h-8 flex-1 border border-gray-300 bg-gray-100 text-xs text-gray-900 transition-all duration-200 hover:bg-gray-200"
className="h-8 max-w-[150px] flex-1 border border-gray-300 bg-gray-100 text-xs text-gray-900 transition-all duration-200 hover:bg-gray-200"
variant="secondary"
asChild
data-testid="toast-cta-btn"
@ -243,14 +243,14 @@ function DefaultToastFeedbackButton(
export const CTA_TOASTS = [
{
id: "premium-donation-toast-client-page",
title: "Support My Work",
title: "Support Open Source",
description:
"Your contribution helps me maintain and improve this project for everyone! 🚀",
"Your contribution helps me maintain and improve this open-source project for everyone! 🚀",
show: customPremiumToast,
},
{
id: "default-donation-toast-client-page",
title: "Love this project?",
title: "Like this open-source project?",
description:
"Help me keep this free tool running! Your support enables me to add new features and maintain the service. 🙏",
show: customDefaultToast,
@ -275,4 +275,4 @@ export const showRandomCTAToast = () => {
/**
* Slight delay to prevent the toast from appearing too quickly
*/
export const CTA_TOAST_TIMEOUT = 2_000; // in ms
export const CTA_TOAST_TIMEOUT = 2_500; // in ms

View file

@ -10,10 +10,11 @@ import dayjs from "dayjs";
import { AlertCircleIcon, FileTextIcon, PencilIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { TWITTER_URL } from "@/config";
import { getAppMetadata, updateAppMetadata } from "../utils/get-app-metadata";
import { InvoiceForm } from "./invoice-form";
import { InvoicePDFDownloadLink } from "./invoice-pdf-download-link";
import { TWITTER_URL } from "@/config";
import type { Dispatch, SetStateAction } from "react";
const DesktopPDFViewerModuleLoading = () => (
<div className="flex h-[580px] w-full items-center justify-center border border-gray-200 bg-gray-200 lg:h-[620px] 2xl:h-[700px]">
@ -60,19 +61,31 @@ const PdfViewer = ({
invoiceData,
errorWhileGeneratingPdfIsShown,
isMobile,
qrCodeDataUrl,
}: {
invoiceData: InvoiceData;
errorWhileGeneratingPdfIsShown: boolean;
isMobile: boolean;
qrCodeDataUrl: string;
}) => {
// Render the appropriate template based on the selected template
const renderTemplate = () => {
switch (invoiceData.template) {
case "stripe":
return <StripeInvoicePdfTemplate invoiceData={invoiceData} />;
return (
<StripeInvoicePdfTemplate
invoiceData={invoiceData}
qrCodeDataUrl={qrCodeDataUrl}
/>
);
case "default":
default:
return <InvoicePdfTemplate invoiceData={invoiceData} />;
return (
<InvoicePdfTemplate
invoiceData={invoiceData}
qrCodeDataUrl={qrCodeDataUrl}
/>
);
}
};
@ -82,7 +95,12 @@ const PdfViewer = ({
// This is due to limitations of the standard PDF viewer in these environments
// https://github.com/diegomura/react-pdf/issues/714
if (isMobile) {
return <MobileInvoicePDFViewer invoiceData={invoiceData} />;
return (
<MobileInvoicePDFViewer
invoiceData={invoiceData}
qrCodeDataUrl={qrCodeDataUrl}
/>
);
}
const template = renderTemplate();
@ -109,6 +127,8 @@ export function InvoiceClientPage({
setErrorWhileGeneratingPdfIsShown,
setCanShareInvoice,
canShareInvoice,
qrCodeDataUrl,
setInvoiceFormHasErrors,
}: {
invoiceDataState: InvoiceData;
handleInvoiceDataChange: (invoiceData: InvoiceData) => void;
@ -118,6 +138,8 @@ export function InvoiceClientPage({
setErrorWhileGeneratingPdfIsShown: (error: boolean) => void;
setCanShareInvoice: (canShareInvoice: boolean) => void;
canShareInvoice: boolean;
qrCodeDataUrl: string;
setInvoiceFormHasErrors: Dispatch<SetStateAction<boolean>>;
}) {
const appMetadata = getAppMetadata();
@ -169,6 +191,8 @@ export function InvoiceClientPage({
invoiceData={invoiceDataState}
handleInvoiceDataChange={handleInvoiceDataChange}
setCanShareInvoice={setCanShareInvoice}
isMobile
setInvoiceFormHasErrors={setInvoiceFormHasErrors}
/>
</div>
</TabsContent>
@ -180,6 +204,7 @@ export function InvoiceClientPage({
errorWhileGeneratingPdfIsShown
}
isMobile={isMobile}
qrCodeDataUrl={qrCodeDataUrl}
/>
</div>
</TabsContent>
@ -237,6 +262,7 @@ export function InvoiceClientPage({
setErrorWhileGeneratingPdfIsShown={
setErrorWhileGeneratingPdfIsShown
}
qrCodeDataUrl={qrCodeDataUrl}
/>
</div>
{invoiceLastUpdatedAtFormatted && (
@ -246,7 +272,7 @@ export function InvoiceClientPage({
</div>
)}
<div className="mt-3 flex w-full justify-center">
<span className="inline-block text-sm text-zinc-700 duration-500 animate-in fade-in slide-in-from-bottom-2">
<span className="inline-block text-xs text-zinc-900 duration-500 animate-in fade-in slide-in-from-bottom-2">
Made by{" "}
<a
href={TWITTER_URL}
@ -268,6 +294,7 @@ export function InvoiceClientPage({
invoiceData={invoiceDataState}
handleInvoiceDataChange={handleInvoiceDataChange}
setCanShareInvoice={setCanShareInvoice}
setInvoiceFormHasErrors={setInvoiceFormHasErrors}
/>
</div>
@ -294,6 +321,7 @@ export function InvoiceClientPage({
invoiceData={invoiceDataState}
errorWhileGeneratingPdfIsShown={errorWhileGeneratingPdfIsShown}
isMobile={false}
qrCodeDataUrl={qrCodeDataUrl}
/>
</div>
</>

View file

@ -31,8 +31,21 @@ import type { NonReadonly, Prettify } from "@/types";
import { zodResolver } from "@hookform/resolvers/zod";
import * as Sentry from "@sentry/nextjs";
import dayjs from "dayjs";
import React, { memo, useCallback, useEffect, useState } from "react";
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
import React, {
memo,
useCallback,
useEffect,
useState,
type Dispatch,
type SetStateAction,
} from "react";
import {
Controller,
useFieldArray,
useForm,
useWatch,
type FieldErrors,
} from "react-hook-form";
import { toast } from "sonner";
import { useDebouncedCallback } from "use-debounce";
import { z } from "zod";
@ -66,12 +79,16 @@ interface InvoiceFormProps {
invoiceData: InvoiceData;
handleInvoiceDataChange: (updatedData: InvoiceData) => void;
setCanShareInvoice: (canShareInvoice: boolean) => void;
isMobile?: boolean;
setInvoiceFormHasErrors: Dispatch<SetStateAction<boolean>>;
}
export const InvoiceForm = memo(function InvoiceForm({
invoiceData,
handleInvoiceDataChange,
setCanShareInvoice,
isMobile = false,
setInvoiceFormHasErrors,
}: InvoiceFormProps) {
const form = useForm<InvoiceData>({
resolver: zodResolver(invoiceSchema),
@ -81,10 +98,10 @@ export const InvoiceForm = memo(function InvoiceForm({
const {
control,
handleSubmit,
setValue,
formState: { errors },
watch,
trigger,
} = form;
const currency = useWatch({ control, name: "currency" });
@ -159,19 +176,42 @@ export const InvoiceForm = memo(function InvoiceForm({
});
}, [invoiceItems, setValue]);
// top level of component
const debouncedShowFormErrorsToast = useDebouncedCallback(
() => formErrorsToToast({ errors, isMobile }),
isMobile ? 3000 : 1000,
);
// regenerate pdf on every input change with debounce
const debouncedRegeneratePdfOnFormChange = useDebouncedCallback(
(data: InvoiceData) => {
// Submit form to regenerate PDF and run form validations
void handleSubmit(onSubmit)(data as unknown as React.BaseSyntheticEvent);
async (data: InvoiceData) => {
// close all other toasts (if any)
toast.dismiss();
setInvoiceFormHasErrors(false);
// TODO: double check if we need this code, because we already save to local storage in the page.client.tsx (parent component) (line: 267) useEffect "Save to localStorage whenever data changes on form update"
try {
// trigger form validations
const ok = await trigger(undefined, { shouldFocus: true });
if (!ok) {
// show errors to the user
// Debounce error toast to avoid showing too fast
debouncedShowFormErrorsToast();
setInvoiceFormHasErrors(true);
return;
}
const validatedData = invoiceSchema.parse(data);
const stringifiedData = JSON.stringify(validatedData);
localStorage.setItem(PDF_DATA_LOCAL_STORAGE_KEY, stringifiedData);
// pass the updated data to the parent component, to update the invoice data state (use state hook)
onSubmit(validatedData);
} catch (error) {
console.error("Error saving to local storage:", error);
@ -187,7 +227,7 @@ export const InvoiceForm = memo(function InvoiceForm({
// subscribe to form changes to regenerate pdf on every input change
useEffect(() => {
const subscription = watch((value) => {
debouncedRegeneratePdfOnFormChange(value as unknown as InvoiceData);
void debouncedRegeneratePdfOnFormChange(value as unknown as InvoiceData);
});
return () => subscription.unsubscribe();
@ -214,7 +254,7 @@ export const InvoiceForm = memo(function InvoiceForm({
// Manually trigger form submission after removal
const currentFormData = watch();
debouncedRegeneratePdfOnFormChange(currentFormData);
void debouncedRegeneratePdfOnFormChange(currentFormData);
},
[remove, watch, debouncedRegeneratePdfOnFormChange],
);
@ -297,69 +337,7 @@ export const InvoiceForm = memo(function InvoiceForm({
};
return (
<form
className="relative mb-4 space-y-3.5"
onSubmit={handleSubmit(onSubmit, (errors) => {
console.error("Form validation errors:", errors);
toast.error(
<div>
<p className="font-semibold">Please fix the following errors:</p>
<ul className="mt-1 list-inside list-disc">
{Object.entries(errors)
.map(([key, error]) => {
// Handle nested errors (e.g., seller.name, items[0].name)
if (
error &&
typeof error === "object" &&
"message" in error
) {
return (
<li key={key} className="text-sm">
{error?.message || "Unknown error"}
</li>
);
}
// Handle array errors (e.g., items array)
if (Array.isArray(error)) {
return error.map((item, index) =>
Object.entries(
item as { [key: string]: { message?: string } },
).map(([fieldName, fieldError]) => (
<li
key={`${key}.${index}.${fieldName}`}
className="text-sm"
>
{fieldError?.message || "Unknown error"}
</li>
)),
);
}
// Handle nested object errors
if (error && typeof error === "object") {
return Object.entries(
error as { [key: string]: { message?: string } },
).map(([nestedKey, nestedError]) => {
return (
<li key={`${key}.${nestedKey}`} className="text-sm">
{nestedError?.message || "Unknown error"}
</li>
);
});
}
return null;
})
.flat(Infinity)}
</ul>
</div>,
{
closeButton: true,
},
);
})}
>
<form className="relative mb-4 space-y-3.5">
<Accordion
type="multiple"
value={accordionValues}
@ -491,7 +469,7 @@ export const InvoiceForm = memo(function InvoiceForm({
Payment Method
</Label>
{/* Show/hide Payment Method field in PDF switch */}
{/* Show Payment Method field in PDF switch */}
<div className="inline-flex items-center gap-2">
<Controller
name={`paymentMethodFieldIsVisible`}
@ -513,7 +491,7 @@ export const InvoiceForm = memo(function InvoiceForm({
Show in PDF
</Label>
}
content='Show/Hide the "Payment Method" Field in the PDF'
content='Show the "Payment Method" Field in the PDF'
/>
</div>
</div>
@ -616,7 +594,7 @@ export const InvoiceForm = memo(function InvoiceForm({
Notes
</Label>
{/* Show/hide Notes field in PDF switch */}
{/* Show Notes field in PDF switch */}
<div className="inline-flex items-center gap-2">
<Controller
name={`notesFieldIsVisible`}
@ -635,7 +613,7 @@ export const InvoiceForm = memo(function InvoiceForm({
trigger={
<Label htmlFor={`notesFieldIsVisible`}>Show in PDF</Label>
}
content='Show/Hide the "Notes" Field in the PDF'
content='Show the "Notes" Field in the PDF'
/>
</div>
</div>
@ -657,57 +635,224 @@ export const InvoiceForm = memo(function InvoiceForm({
)}
</div>
{/* QR Code */}
<fieldset className="rounded-md border px-4 pb-4">
<legend className="text-base font-semibold lg:text-lg">
QR Code
</legend>
<div className="mb-2 flex items-center justify-end">
{/* Show QR Code in PDF switch */}
<div className="inline-flex items-center gap-2">
<Controller
name={`qrCodeIsVisible`}
control={control}
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`qrCodeIsVisible`}
checked={value}
onCheckedChange={onChange}
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
aria-label="Show QR Code in PDF"
/>
)}
/>
<CustomTooltip
trigger={<Label htmlFor={`qrCodeIsVisible`}>Show in PDF</Label>}
content="Show the QR Code in the PDF"
/>
</div>
</div>
<div className="space-y-4">
{/* QR Code Data */}
<div>
<Label htmlFor={`qrCodeData`} className="mb-2 block">
Data
</Label>
<Controller
name="qrCodeData"
control={control}
render={({ field }) => (
<Input
{...field}
id={`qrCodeData`}
type="text"
placeholder="Enter URL or text to encode"
data-testid="qrCodeData"
/>
)}
/>
<InputHelperMessage>
Enter any text or URL to generate a QR code. The QR code will
appear in the bottom section of the invoice PDF.
</InputHelperMessage>
{errors.qrCodeData && (
<ErrorMessage>{errors.qrCodeData.message}</ErrorMessage>
)}
</div>
{/* QR Code Description */}
<div>
<Label htmlFor={`qrCodeDescription`} className="mb-2 block">
Description (optional)
</Label>
<Controller
name="qrCodeDescription"
control={control}
render={({ field }) => (
<Input
{...field}
id={`qrCodeDescription`}
type="text"
placeholder="Enter a description for the QR code"
data-testid="qrCodeDescription"
/>
)}
/>
<InputHelperMessage>
Optional text that will be displayed below the QR code in the
PDF.
</InputHelperMessage>
{errors.qrCodeDescription && (
<ErrorMessage>{errors.qrCodeDescription.message}</ErrorMessage>
)}
</div>
</div>
</fieldset>
{/*
Stripe template doesn't have these fields
*/}
{invoiceData.template === "default" && (
<div>
<div className="relative mt-5 space-y-4">
{/* Show/hide Person Authorized to Receive field in PDF switch */}
<div className="flex items-center justify-between gap-2">
<Label htmlFor={`personAuthorizedToReceiveFieldIsVisible`}>
Show &quot;Person Authorized to Receive&quot; Signature Field
in the PDF
</Label>
<>
<fieldset className="rounded-md border px-4 pb-4">
<legend className="text-base font-semibold lg:text-lg">
Person Authorized to Receive
</legend>
<div className="mb-2 flex items-center justify-end">
<div className="inline-flex items-center gap-2">
<Controller
name="personAuthorizedToReceiveFieldIsVisible"
control={control}
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id="personAuthorizedToReceiveFieldIsVisible"
checked={value}
onCheckedChange={onChange}
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
aria-label="Show Person Authorized to Receive in PDF"
/>
)}
/>
<CustomTooltip
trigger={
<Label htmlFor="personAuthorizedToReceiveFieldIsVisible">
Show in PDF
</Label>
}
content="Show the Person Authorized to Receive signature field in the PDF"
/>
</div>
</div>
<div>
<Label
htmlFor="personAuthorizedToReceiveName"
className="mb-2 block"
>
Name
</Label>
<Controller
name={`personAuthorizedToReceiveFieldIsVisible`}
name="personAuthorizedToReceiveName"
control={control}
render={({ field: { value, onChange, ...field } }) => (
<Switch
render={({ field }) => (
<Input
{...field}
id={`personAuthorizedToReceiveFieldIsVisible`}
checked={value}
onCheckedChange={onChange}
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
id="personAuthorizedToReceiveName"
type="text"
placeholder="Enter name of person authorized to receive"
data-testid="personAuthorizedToReceiveName"
/>
)}
/>
<InputHelperMessage>
Name displayed above the signature line in the PDF.
</InputHelperMessage>
{errors.personAuthorizedToReceiveName && (
<ErrorMessage>
{errors.personAuthorizedToReceiveName.message}
</ErrorMessage>
)}
</div>
</fieldset>
<fieldset className="rounded-md border px-4 pb-4">
<legend className="text-base font-semibold lg:text-lg">
Person Authorized to Issue
</legend>
<div className="mb-2 flex items-center justify-end">
<div className="inline-flex items-center gap-2">
<Controller
name="personAuthorizedToIssueFieldIsVisible"
control={control}
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id="personAuthorizedToIssueFieldIsVisible"
checked={value}
onCheckedChange={onChange}
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
aria-label="Show Person Authorized to Issue in PDF"
/>
)}
/>
<CustomTooltip
trigger={
<Label htmlFor="personAuthorizedToIssueFieldIsVisible">
Show in PDF
</Label>
}
content="Show the Person Authorized to Issue signature field in the PDF"
/>
</div>
</div>
{/* Show/hide Person Authorized to Issue field in PDF switch */}
<div className="flex items-center justify-between gap-2">
<Label htmlFor={`personAuthorizedToIssueFieldIsVisible`}>
Show &quot;Person Authorized to Issue&quot; Signature Field in
the PDF
<div>
<Label
htmlFor="personAuthorizedToIssueName"
className="mb-2 block"
>
Name
</Label>
<Controller
name={`personAuthorizedToIssueFieldIsVisible`}
name="personAuthorizedToIssueName"
control={control}
render={({ field: { value, onChange, ...field } }) => (
<Switch
render={({ field }) => (
<Input
{...field}
id={`personAuthorizedToIssueFieldIsVisible`}
checked={value}
onCheckedChange={onChange}
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
id="personAuthorizedToIssueName"
type="text"
placeholder="Enter name of person authorized to issue"
data-testid="personAuthorizedToIssueName"
/>
)}
/>
<InputHelperMessage>
Name displayed above the signature line in the PDF.
</InputHelperMessage>
{errors.personAuthorizedToIssueName && (
<ErrorMessage>
{errors.personAuthorizedToIssueName.message}
</ErrorMessage>
)}
</div>
</div>
</div>
</fieldset>
</>
)}
</div>
</form>
@ -747,3 +892,68 @@ const calculateItemTotals = (item: InvoiceItemData | null) => {
preTaxAmount: formattedPreTaxAmount,
};
};
const formErrorsToToast = ({
errors,
isMobile,
}: {
errors: FieldErrors<InvoiceData>;
isMobile: boolean;
}) => {
// Return early if there are no errors
if (!errors || Object.keys(errors).length === 0) {
return;
}
toast.error(
<div>
<p className="font-semibold">Please fix the following errors:</p>
<ul className="mt-1 list-inside list-disc">
{Object.entries(errors)
.map(([key, error]) => {
// Handle nested errors (e.g., seller.name, items[0].name)
if (error && typeof error === "object" && "message" in error) {
return (
<li key={key} className="text-sm">
{error?.message || "Unknown error"}
</li>
);
}
// Handle array errors (e.g., items array)
if (Array.isArray(error)) {
return error.map((item, index) =>
Object.entries(
item as { [key: string]: { message?: string } },
).map(([fieldName, fieldError]) => (
<li key={`${key}.${index}.${fieldName}`} className="text-sm">
{fieldError?.message || "Unknown error"}
</li>
)),
);
}
// Handle nested object errors
if (error && typeof error === "object") {
return Object.entries(
error as { [key: string]: { message?: string } },
).map(([nestedKey, nestedError]) => {
return (
<li key={`${key}.${nestedKey}`} className="text-sm">
{nestedError?.message || "Unknown error"}
</li>
);
});
}
return null;
})
.flat(Infinity)}
</ul>
</div>,
{
id: "form-errors-error-toast",
duration: 15_000,
position: isMobile ? "top-center" : "bottom-right",
},
);
};

View file

@ -139,7 +139,7 @@ export const BuyerInformation = memo(function BuyerInformation({
</legend>
<div className="mb-2 flex items-center justify-end">
{/* Show/hide Buyer Tax Number field in PDF switch */}
{/* Show Buyer Tax Number field in PDF switch */}
<div
className="inline-flex items-center gap-2"
title={HTML_TITLE_CONTENT}
@ -156,6 +156,7 @@ export const BuyerInformation = memo(function BuyerInformation({
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
disabled={isBuyerSelected}
data-testid={`buyerVatNoFieldIsVisible`}
aria-label={`Show the 'Buyer Tax Number' Field in the PDF`}
/>
)}
/>
@ -168,7 +169,7 @@ export const BuyerInformation = memo(function BuyerInformation({
content={
isBuyerSelected
? null
: "Show/Hide the 'Buyer Tax Number' Field in the PDF"
: "Show the 'Buyer Tax Number' Field in the PDF"
}
/>
</div>
@ -298,7 +299,7 @@ export const BuyerInformation = memo(function BuyerInformation({
</Label>
)}
{/* Show/hide Notes field in PDF switch */}
{/* Show Notes field in PDF switch */}
<div
className="inline-flex items-center gap-2"
title={HTML_TITLE_CONTENT}
@ -315,6 +316,7 @@ export const BuyerInformation = memo(function BuyerInformation({
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
disabled={isBuyerSelected}
data-testid={`buyerNotesInvoiceFormFieldVisibilitySwitch`}
aria-label={`Show the 'Notes' field in the PDF`}
/>
)}
/>
@ -325,9 +327,7 @@ export const BuyerInformation = memo(function BuyerInformation({
</Label>
}
content={
isBuyerSelected
? null
: "Show/Hide the 'Notes' Field in the PDF"
isBuyerSelected ? null : "Show the 'Notes' field in the PDF"
}
/>
</div>

View file

@ -8,6 +8,7 @@ import {
import {
CURRENCY_SYMBOLS,
CURRENCY_TO_LABEL,
DEFAULT_DATE_FORMAT,
LANGUAGE_TO_LABEL,
STRIPE_DEFAULT_DATE_FORMAT,
SUPPORTED_TEMPLATES,
@ -183,9 +184,11 @@ export const GeneralInformation = memo(function GeneralInformation({
// Set date format to "MMMM D, YYYY" when template is Stripe
setValue("dateFormat", STRIPE_DEFAULT_DATE_FORMAT);
// Always enable VAT field visibility for Stripe template (because we don't show Switches for items in Stripe template and we want to make sure the Tax column is visible in the PDF)
setValue("items.0.vatFieldIsVisible", true);
// Set unit field to be hidden by default for Stripe template (backwards compatibility)
setValue("items.0.unitFieldIsVisible", false);
} else {
// DEFAULT TEMPLATE
// Clear Stripe-specific fields when not using Stripe template
if (errors.stripePayOnlineUrl) {
setValue("stripePayOnlineUrl", "");
@ -197,7 +200,7 @@ export const GeneralInformation = memo(function GeneralInformation({
}
// Set date format to "YYYY-MM-DD" when template is default
setValue("dateFormat", SUPPORTED_DATE_FORMATS[0]);
setValue("dateFormat", DEFAULT_DATE_FORMAT);
}
}}
>
@ -444,7 +447,7 @@ export const GeneralInformation = memo(function GeneralInformation({
<SelectNative {...field} id={`dateFormat`} className="block">
{SUPPORTED_DATE_FORMATS.map((format) => {
const preview = dayjs().locale(language).format(format);
const isDefault = format === SUPPORTED_DATE_FORMATS[0];
const isDefault = format === DEFAULT_DATE_FORMAT;
return (
<option key={format} value={format}>
@ -532,7 +535,7 @@ export const GeneralInformation = memo(function GeneralInformation({
{!isInvoiceNumberInCurrentMonth &&
!errors.invoiceNumberObject?.value && (
<div className="mt-1 flex flex-col items-start text-balance text-xs text-zinc-700/90">
<InputHelperMessage>
<span className="flex items-center text-amber-800">
<AlertIcon />
Invoice number does not match current month
@ -547,13 +550,13 @@ export const GeneralInformation = memo(function GeneralInformation({
}}
>
<span className="text-pretty">
Set Invoice No. as{" "}
Set invoice number as{" "}
<span className="font-bold">
current month ({`1/${CURRENT_MONTH_AND_YEAR}`})
</span>
</span>
</ButtonHelper>
</div>
</InputHelperMessage>
)}
</div>
</div>
@ -652,8 +655,9 @@ export const GeneralInformation = memo(function GeneralInformation({
<InfoIcon className="mt-0.5 inline-block size-3.5 shrink-0 text-blue-800" />
<div>
<span className="mb-2 inline-block">
Some dates are out of date. Click the button to update all
at once:
Some dates are out of date.{" "}
<span className="underline">Click the button below</span> to
update all dates at once:
</span>
<ul className="list-disc space-y-1 text-balance pl-5">
<li>
@ -733,59 +737,57 @@ export const GeneralInformation = memo(function GeneralInformation({
</div>
) : null}
{/* Invoice Type - We don't show this field for Stripe template */}
{/* TODO: rename to "Invoice Notes", probably "Invoice Type" is not the best name for this field ?*/}
{template !== "stripe" && (
<div>
<div className="relative mb-2 flex items-center justify-between">
<Label htmlFor={`invoiceType`} className="">
Invoice Type
</Label>
{/* Header Notes - Purpose is to add a custom text to the header of the invoice */}
<div>
<div className="relative mb-2 flex items-center justify-between">
<Label htmlFor={`invoiceType`} className="">
Header Notes
</Label>
{/* Show/hide Invoice Type field in PDF switch */}
<div className="inline-flex items-center gap-2">
<Controller
name={`invoiceTypeFieldIsVisible`}
control={control}
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`invoiceTypeFieldIsVisible`}
checked={value}
onCheckedChange={onChange}
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
/>
)}
/>
<CustomTooltip
trigger={
<Label htmlFor={`invoiceTypeFieldIsVisible`}>
Show in PDF
</Label>
}
content='Show/Hide the "Invoice Type" Field in the PDF'
/>
</div>
{/* Show Header Notes field in PDF switch */}
<div className="inline-flex items-center gap-2">
<Controller
name={`invoiceTypeFieldIsVisible`}
control={control}
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`invoiceTypeFieldIsVisible`}
checked={value}
onCheckedChange={onChange}
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
aria-label={`Show the "Header Notes" Field in the PDF`}
/>
)}
/>
<CustomTooltip
trigger={
<Label htmlFor={`invoiceTypeFieldIsVisible`}>
Show in PDF
</Label>
}
content='Show the "Header Notes" Field in the PDF'
/>
</div>
<Controller
name="invoiceType"
control={control}
render={({ field }) => (
<Textarea
{...field}
id={`invoiceType`}
rows={2}
className=""
placeholder="Enter invoice type"
/>
)}
/>
{errors.invoiceType && (
<ErrorMessage>{errors.invoiceType.message}</ErrorMessage>
)}
</div>
)}
<Controller
name="invoiceType"
control={control}
render={({ field }) => (
<Textarea
{...field}
id={`invoiceType`}
rows={2}
className=""
placeholder="Enter header notes"
/>
)}
/>
{errors.invoiceType && (
<ErrorMessage>{errors.invoiceType.message}</ErrorMessage>
)}
</div>
</div>
</div>
);

Some files were not shown because too many files have changed in this diff Show more