feat: add e2e tests with playwright and other improvements (#79)

* feat: Add language attribute to date input fields in invoice form components

- Include `lang="en"` attribute in date input fields of `InvoiceForm` and `GeneralInformation` components for improved accessibility and localization support.

* fix: Update language attribute for date input fields in invoice form components

* refactor: Improve layout and organization of invoice components

- Remove unnecessary margin from the main container in the Home component.
- Wrap the share invoice button and PDF download link in a fragment for better structure.
- Adjust margins for the ProjectInfo and action button container for improved spacing.
- Update the InvoicePDFViewer height to use full height for better responsiveness.
- Remove the deprecated RegenerateInvoiceButton component to streamline the codebase.
- Update the InvoiceClientPage to accept handleShareInvoice prop for better functionality.
- Clean up unused language attributes in date input fields across invoice form components.

* feat: Integrate Playwright for end-to-end testing and enhance invoice form components

- Add Playwright configuration and dependencies for E2E testing.
- Create GitHub Actions workflow for automated E2E tests on deployment.
- Implement initial E2E tests for the Invoice Generator Page, verifying UI elements and form functionality.
- Refactor invoice form components to include data-testid attributes for better testability.
- Update .gitignore to exclude Playwright-related files and directories.

* chore: Update GitHub Actions workflow for E2E testing and enhance test coverage

- Upgrade pnpm version from 8 to 10 in the E2E workflow for improved package management.
- Add new test case to verify header buttons and links on the Invoice Generator Page, ensuring UI elements are displayed correctly and have the expected attributes.

* chore: Enhance ESLint configuration for Playwright integration

- Add Playwright ESLint plugin to package.json for improved E2E testing support.
- Update .eslintrc.json to include overrides for E2E test files.
- Clean up GitHub Actions workflow by removing unnecessary pnpm version specification.

* chore: Update Playwright configuration and improve test assertions

- Increase timeout for expect assertions and test execution from 15 seconds to 30 seconds for better stability in E2E tests.
- Comment out mobile viewport tests to streamline configuration and focus on desktop testing.

* chore: Update configuration and refactor invoice form components

- Add compiler options to remove console logs in production and enhance logging for fetch requests in next.config.mjs.
- Update package.json to include new type definitions for ua-parser-js and add ua-parser-js as a dependency.
- Refactor invoice form components to remove form prefix IDs, simplifying data-testid attributes for better testability.
- Introduce DeviceContext for managing device type state and improve responsiveness in invoice form components.
- Implement server-side device detection using user agent parsing for better rendering on mobile and desktop views.
- Update media query hooks to streamline device type checks across components.

* chore: Update Playwright configuration and enhance invoice form tests

- Reduce timeout for expect assertions from 30 seconds to 15 seconds for improved test performance.
- Add new test for handling currency switching in the Invoice Generator Page, verifying correct currency display and calculations.
- Refactor buyer and seller information components to include tooltip messages and improve accessibility with aria attributes.
- Update BuyerDialog and BuyerManagement components to enhance user experience with better visibility and edit functionality for buyer details.

* chore: Update Playwright installation command in GitHub Actions workflow

- Modify Playwright installation command to remove explicit browser specification, allowing for default browser installation with dependencies.

* chore: Update GitHub Actions E2E workflow for Playwright report handling

- Change condition for uploading Playwright report to ensure it uploads regardless of test outcome.
- Reduce retention days for uploaded reports from 5 to 3 for better resource management.

* chore: Update Playwright installation command in GitHub Actions workflow

- Specify installation of Chromium and WebKit browsers along with dependencies for enhanced testing capabilities.

* chore: Enhance E2E tests for seller and buyer management functionality

- Add tests to verify the deletion process for sellers and buyers, including confirmation dialogs and success messages.
- Ensure localStorage data is correctly saved and parsed for both seller and buyer information.
- Introduce default data constants for sellers and buyers to streamline test setup.
- Improve accessibility by adding screen reader text for delete buttons in the seller management component.

* chore: Pin versions of GitHub Actions in E2E workflow for stability

- Update actions/checkout, pnpm/action-setup, actions/setup-node, and actions/upload-artifact to specific versions for improved reliability and security.
- Comment added to clarify the rationale for using pinned versions.

* chore: Add E2E test for accordion items visibility and localStorage state management

- Implement test to verify that accordion items are visible, collapsible, and their state is correctly saved in localStorage.
- Ensure state persistence across page reloads and validate updated states after toggling sections.
- Introduce ACCORDION_STATE_LOCAL_STORAGE_KEY and AccordionState type for better type safety and clarity.

* chore: Update Playwright configuration and add comprehensive E2E tests for seller and buyer management

- Increase timeout for expect assertions and test execution from 30 seconds to 60 seconds for improved stability in E2E tests.
- Introduce new E2E tests for seller and buyer management, covering creation, editing, and deletion processes, including confirmation dialogs and success messages.
- Ensure localStorage data is correctly saved and parsed for both seller and buyer information.
- Implement detailed validation for form fields and visibility toggles in seller and buyer management dialogs.
- Enhance accessibility by adding screen reader text for buttons and tooltips in the management components.

* chore: Refactor Playwright configuration and enhance invoice item validation tests

- Introduce a constant for timeout values in Playwright configuration for consistency and maintainability.
- Add comprehensive validation tests for amount, net price, and VAT fields in the invoice items section, ensuring proper error messages for invalid inputs.
- Update expected error messages in the schema to match the new formatting for better clarity.
- Improve test structure by utilizing descriptive variable names and modularizing input handling for better readability.

* chore: add pdf e2e tests

- Add `pdf-parse` and its type definitions to package.json for PDF handling capabilities.
- Increase Playwright timeout from 30 seconds to 60 seconds for improved test stability.
- Introduce comprehensive E2E tests for PDF generation, verifying content in both English and Polish.
- Implement cleanup procedures for test downloads to ensure a clean testing environment.
- Validate invoice data updates in the generated PDF, ensuring accurate content reflects user inputs.

* chore: add eslint, knip, lint-staged

* chore: run prettier

* chore: minor improvements

* chore: add more test and improved e2e config

* minor fixes

* minor fixes

* chore: add new test
This commit is contained in:
Vlad Sazonau 2025-03-27 21:41:55 +01:00 committed by GitHub
parent ccefb5e301
commit e6f54d5dfc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 9583 additions and 3764 deletions

View file

@ -1,6 +1,7 @@
---
description: Shared rules across the project
globs: **/*
alwaysApply: false
---
You are an expert in TypeScript, Node.js, Next.js App Router, React, Shadcn UI, Radix UI and Tailwind.
@ -45,5 +46,7 @@ Key Conventions
- Limit 'use client':
- Favor server components and Next.js SSR.
- Use only for Web API access in small components.
- Use pnpm
- Use `console.log({})` syntax for debugging
Follow Next.js docs for Data Fetching, Rendering, and Routing.

View file

@ -1,6 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
"no-console": ["warn", { "allow": ["error"] }]
}
}

47
.github/workflows/e2e.yml vendored Normal file
View file

@ -0,0 +1,47 @@
name: 🎭 Playwright E2E tests
on:
deployment_status:
jobs:
e2e:
# https://vercel.com/guides/how-can-i-run-end-to-end-tests-after-my-vercel-preview-deployment
if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success'
name: Run e2e tests via ${{ github.event_name == 'deployment_status' && 'Vercel deployment' }}
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- run: |
echo "Environment URL - ${{ github.event_name == 'deployment_status' && github.event.deployment_status.environment_url }}"
# 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: 📦 Install Playwright browser binaries & OS dependencies
run: pnpm exec playwright install chromium webkit --with-deps
- name: 🎭 Run Playwright tests
id: playwright
run: pnpm exec playwright test
env:
BASE_URL: ${{ github.event_name == 'deployment_status' && github.event.deployment_status.environment_url }}
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 #4.6.1
name: 🎲 Upload Playwright report
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 3

11
.gitignore vendored
View file

@ -37,3 +37,14 @@ next-env.d.ts
# Sentry Config File
.env.sentry-build-plugin
# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright-output/
# eslint
.eslintcache

1
.husky/pre-commit Normal file
View file

@ -0,0 +1 @@
pnpm run lint-stage --verbose

View file

@ -1,5 +1,6 @@
/** @type {import('prettier').Config} */
/** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig*/
const config = {
trailingComma: "es5",
plugins: [require.resolve("prettier-plugin-tailwindcss")],

View file

@ -6,7 +6,6 @@ If you found this project helpful, you can support my work here: [Buy Me a Coffe
<img width="1440" alt="image" src="https://github.com/user-attachments/assets/9bc70941-4c2b-4c13-b264-38e3483a82c4" />
## Technologies we use
- [React](https://react.dev/)

309
e2e/buyer.test.ts Normal file
View file

@ -0,0 +1,309 @@
import { DEFAULT_BUYER_DATA, type BuyerData } from "@/app/schema";
import { expect, test } from "@playwright/test";
test.describe("Buyer management", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("create/edit buyer", async ({ page }) => {
// Open buyer management dialog
await page.getByRole("button", { name: "New Buyer" }).click();
// Fill in all buyer details
const testData = {
name: "New Test Client",
address: "456 Client Avenue\nClient City, 54321\nClient Country",
vatNoFieldIsVisible: true,
vatNo: "987654321",
email: "client@example.com",
} as const satisfies BuyerData;
const manageBuyerDialog = page.getByTestId(`manage-buyer-dialog`);
// Fill in form fields
await manageBuyerDialog
.getByRole("textbox", { name: "Name" })
.fill(testData.name);
await manageBuyerDialog
.getByRole("textbox", { name: "Address" })
.fill(testData.address);
await manageBuyerDialog
.getByRole("textbox", { name: "VAT Number" })
.fill(testData.vatNo);
await manageBuyerDialog
.getByRole("textbox", { name: "Email" })
.fill(testData.email);
// Verify VAT visibility switch is checked by default
await expect(
manageBuyerDialog.getByRole("switch", { name: "Show in PDF" }).nth(0)
).toBeChecked();
// Toggle VAT visibility switch
await manageBuyerDialog
.getByRole("switch", { name: "Show in PDF" })
.nth(0)
.click(); // Toggle VAT Number visibility
// Verify "Apply to Current Invoice" switch is checked by default
await expect(
manageBuyerDialog.getByRole("switch", {
name: "Apply to Current Invoice",
})
).toBeChecked();
// Cancel button is shown
await expect(
manageBuyerDialog.getByRole("button", { name: "Cancel" })
).toBeVisible();
// Save buyer
await manageBuyerDialog.getByRole("button", { name: "Save Buyer" }).click();
// Verify success toast message is visible
await expect(
page.getByText("Buyer added successfully", { exact: true })
).toBeVisible();
// Verify buyer data is actually saved in localStorage
const storedData = (await page.evaluate(() => {
return localStorage.getItem("EASY_INVOICE_PDF_BUYERS");
})) as string;
expect(storedData).toBeTruthy();
const parsedData = JSON.parse(storedData) as BuyerData[];
expect(parsedData[0]).toMatchObject({
name: testData.name,
address: testData.address,
vatNo: testData.vatNo,
vatNoFieldIsVisible: false,
email: testData.email,
} satisfies BuyerData);
// Verify all saved details in the Buyer Information section form
const buyerForm = page.getByTestId(`buyer-information-section`);
// Try to find desktop tooltip icon first
const desktopTooltipExists =
(await buyerForm
.getByTestId("form-section-tooltip-info-icon-desktop")
.count()) > 0;
// If desktop tooltip exists, hover over it; otherwise find and click mobile tooltip
// eslint-disable-next-line playwright/no-conditional-in-test
if (desktopTooltipExists) {
// Get desktop tooltip icons and hover over the first one because we use tooltip
const desktopTooltips = buyerForm.getByTestId(
"form-section-tooltip-info-icon-desktop"
);
await desktopTooltips.first().hover();
} else {
// Get mobile tooltip icons and click the first one because we use popover
const mobileTooltips = buyerForm.getByTestId(
"form-section-tooltip-info-icon-mobile"
);
await mobileTooltips.first().click();
}
// Check that HTML title attributes contain the tooltip message on input fields
const nameInput = buyerForm.getByRole("textbox", { name: "Name" });
await expect(nameInput).toHaveAttribute(
"title",
"Buyer details are locked. Click the edit buyer button to modify."
);
// Buyer Name
await expect(nameInput).toHaveAttribute("aria-readonly", "true");
await expect(nameInput).toHaveValue(testData.name);
// Buyer Address
await expect(
buyerForm.getByRole("textbox", { name: "Address" })
).toHaveAttribute("aria-readonly", "true");
await expect(
buyerForm.getByRole("textbox", { name: "Address" })
).toHaveValue(testData.address);
// Buyer VAT Number
await expect(
buyerForm.getByRole("textbox", { name: "VAT Number" })
).toHaveAttribute("aria-readonly", "true");
await expect(
buyerForm.getByRole("textbox", { name: "VAT Number" })
).toHaveValue(testData.vatNo);
const vatNumberSwitch = buyerForm.getByTestId(`buyerVatNoFieldIsVisible`);
// Verify VAT Number switch is not checked as we toggled it off
await expect(vatNumberSwitch).not.toBeChecked();
await expect(vatNumberSwitch).toBeDisabled();
// Buyer Email
await expect(
buyerForm.getByRole("textbox", { name: "Email" })
).toHaveAttribute("aria-readonly", "true");
await expect(buyerForm.getByRole("textbox", { name: "Email" })).toHaveValue(
testData.email
);
// Verify the buyer appears in the dropdown
await expect(
buyerForm.getByRole("combobox", { name: "Select Buyer" })
).toContainText(testData.name);
// Test edit functionality
await buyerForm.getByRole("button", { name: "Edit buyer" }).click();
// Verify all fields are populated in edit dialog
await expect(
manageBuyerDialog.getByRole("textbox", { name: "Name" })
).toHaveValue(testData.name);
await expect(
manageBuyerDialog.getByRole("textbox", { name: "Address" })
).toHaveValue(testData.address);
await expect(
manageBuyerDialog.getByRole("textbox", { name: "VAT Number" })
).toHaveValue(testData.vatNo);
await expect(
manageBuyerDialog.getByRole("textbox", { name: "Email" })
).toHaveValue(testData.email);
// Verify visibility switch state persisted in edit dialog
await expect(
manageBuyerDialog.getByRole("switch", { name: "Show in PDF" }).nth(0)
).not.toBeChecked();
// Update some data in edit mode
const updatedName = "Updated Client Corp";
await manageBuyerDialog
.getByRole("textbox", { name: "Name" })
.fill(updatedName);
// Re-enable VAT visibility
await manageBuyerDialog
.getByRole("switch", { name: "Show in PDF" })
.nth(0)
.click();
// Save updated buyer
await manageBuyerDialog.getByRole("button", { name: "Save Buyer" }).click();
// Verify success toast for update
await expect(
page.getByText("Buyer updated successfully", { exact: true })
).toBeVisible();
// Verify updated information is displayed
await expect(buyerForm.getByRole("textbox", { name: "Name" })).toHaveValue(
updatedName
);
// Verify VAT visibility is now enabled
await expect(
buyerForm.getByTestId(`buyerVatNoFieldIsVisible`)
).toBeChecked();
});
test("delete buyer", async ({ page }) => {
// First add a buyer
await page.getByRole("button", { name: "New Buyer" }).click();
const testData = {
name: "Test Delete Buyer",
address: "456 Delete Avenue",
email: "delete@buyer.com",
vatNoFieldIsVisible: true,
vatNo: "123456789",
} as const satisfies BuyerData;
const manageBuyerDialog = page.getByTestId(`manage-buyer-dialog`);
// Fill in basic buyer details
await manageBuyerDialog
.getByRole("textbox", { name: "Name" })
.fill(testData.name);
await manageBuyerDialog
.getByRole("textbox", { name: "Address" })
.fill(testData.address);
await manageBuyerDialog
.getByRole("textbox", { name: "Email" })
.fill(testData.email);
// Save buyer
await manageBuyerDialog.getByRole("button", { name: "Save Buyer" }).click();
// Verify buyer was added
const buyerForm = page.getByTestId(`buyer-information-section`);
await expect(
buyerForm.getByRole("combobox", { name: "Select Buyer" })
).toContainText(testData.name);
// Click delete button
await buyerForm.getByRole("button", { name: "Delete buyer" }).click();
// Verify delete confirmation dialog appears
await expect(page.getByRole("alertdialog")).toBeVisible();
await expect(
page.getByText(
`Are you sure you want to delete "${testData.name}" buyer?`
)
).toBeVisible();
// Cancel button is shown
await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible();
// Click cancel button
await page.getByRole("button", { name: "Cancel" }).click();
// Verify dialog is closed
await expect(page.getByRole("alertdialog")).toBeHidden();
// Click delete button once again to open the dialog
await buyerForm.getByRole("button", { name: "Delete buyer" }).click();
// Verify delete confirmation dialog appears
await expect(page.getByRole("alertdialog")).toBeVisible();
await expect(
page.getByText(
`Are you sure you want to delete "${testData.name}" buyer?`
)
).toBeVisible();
// Confirm deletion
await page.getByRole("button", { name: "Delete" }).click();
// Verify success message
await expect(
page.getByText("Buyer deleted successfully", { exact: true })
).toBeVisible();
// Verify buyer is removed from dropdown
// because we have only one buyer, dropdown will be completely hidden
await expect(
buyerForm.getByRole("combobox", { name: "Select Buyer" })
).toBeHidden();
// Verify form is reset to default values
await expect(buyerForm.getByRole("textbox", { name: "Name" })).toHaveValue(
DEFAULT_BUYER_DATA.name
);
await expect(
buyerForm.getByRole("textbox", { name: "Address" })
).toHaveValue(DEFAULT_BUYER_DATA.address);
await expect(buyerForm.getByRole("textbox", { name: "Email" })).toHaveValue(
DEFAULT_BUYER_DATA.email
);
await expect(
buyerForm.getByRole("textbox", { name: "VAT Number" })
).toHaveValue(DEFAULT_BUYER_DATA.vatNo);
});
});

980
e2e/invoice-form.test.ts Normal file
View file

@ -0,0 +1,980 @@
import {
ACCORDION_STATE_LOCAL_STORAGE_KEY,
type AccordionState,
type InvoiceData,
} from "@/app/schema";
import { expect, test } from "@playwright/test";
import dayjs from "dayjs";
import { INITIAL_INVOICE_DATA } from "../src/app/constants";
test.describe("Invoice Generator Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("has correct title and branding", async ({ page }) => {
await expect(page).toHaveTitle(
"Invoice PDF Generator with Live Preview | No Sign-Up"
);
await expect(
page.getByRole("link", { name: "EasyInvoicePDF.com" })
).toBeVisible();
await expect(
page.getByText("Invoice PDF generator with live preview")
).toBeVisible();
});
test("displays correct buttons and links in header", async ({ page }) => {
// Check title and branding
await expect(page).toHaveTitle(
"Invoice PDF Generator with Live Preview | No Sign-Up"
);
await expect(
page.getByRole("link", { name: "EasyInvoicePDF.com" })
).toBeVisible();
await expect(
page.getByText("Invoice PDF generator with live preview")
).toBeVisible();
// Check main action buttons
await expect(
page.getByRole("button", { name: "Support Project" })
).toBeVisible();
await expect(
page.getByRole("button", { name: "Generate a link to invoice" })
).toBeVisible();
await expect(
page.getByRole("link", { name: "Download PDF in English" })
).toBeVisible();
// Check footer links
await expect(page.getByText("Made by")).toBeVisible();
await expect(
page.getByRole("link", { name: "Vlad Sazonau" })
).toBeVisible();
await expect(page.getByRole("link", { name: "Open Source" })).toBeVisible();
await expect(
page.getByRole("link", { name: "Share your feedback" })
).toBeVisible();
// Verify links have correct href attributes
await expect(
page.getByRole("link", { name: "Vlad Sazonau" })
).toHaveAttribute("href", "https://dub.sh/vldzn.me");
await expect(
page.getByRole("link", { name: "Open Source" })
).toHaveAttribute(
"href",
"https://github.com/VladSez/pdf-invoice-generator"
);
// Verify buttons are enabled
await expect(
page.getByRole("button", { name: "Generate a link to invoice" })
).toBeEnabled();
await expect(
page.getByRole("link", { name: "Download PDF in English" })
).toBeEnabled();
});
test("handles mobile/desktop views", async ({ page }) => {
// Test mobile view
await page.setViewportSize({ width: 375, height: 667 });
// check that tabs are visible in mobile view
await expect(page.getByRole("tab", { name: "Edit Invoice" })).toBeVisible();
await expect(page.getByRole("tab", { name: "Preview PDF" })).toBeVisible();
// Test desktop view
await page.setViewportSize({ width: 1280, height: 800 });
// check that tabs are not visible in desktop view
await expect(page.getByRole("tab", { name: "Edit Invoice" })).toBeHidden();
await expect(page.getByRole("tab", { name: "Preview PDF" })).toBeHidden();
});
test("displays initial form state correctly", async ({ page }) => {
// **CHECK GENERAL INFORMATION SECTION**
const generalInfoSection = page.getByTestId(`general-information-section`);
await expect(
generalInfoSection.getByText("General Information", { exact: true })
).toBeVisible();
// Language selection
await expect(
generalInfoSection.getByRole("combobox", { name: "Invoice PDF Language" })
).toHaveValue(INITIAL_INVOICE_DATA.language);
// Currency selection
await expect(
generalInfoSection.getByRole("combobox", { name: "Currency" })
).toHaveValue(INITIAL_INVOICE_DATA.currency);
// Date Format selection
await expect(
generalInfoSection.getByRole("combobox", { name: "Date Format" })
).toHaveValue(INITIAL_INVOICE_DATA.dateFormat);
// Invoice Number
await expect(
generalInfoSection.getByRole("textbox", { name: "Invoice Number" })
).toHaveValue(INITIAL_INVOICE_DATA.invoiceNumber);
// Date of Issue
await expect(
generalInfoSection.getByRole("textbox", { name: "Date of Issue" })
).toHaveValue(INITIAL_INVOICE_DATA.dateOfIssue);
// Date of Service
await expect(
generalInfoSection.getByRole("textbox", { name: "Date of Service" })
).toHaveValue(INITIAL_INVOICE_DATA.dateOfService);
// Invoice Type
await expect(
generalInfoSection.getByRole("textbox", { name: "Invoice Type" })
).toHaveValue(INITIAL_INVOICE_DATA.invoiceType);
// Visibility toggles
await expect(
generalInfoSection.getByRole("switch", { name: "Show in PDF" })
).toBeChecked();
// **CHECK SELLER INFORMATION SECTION**
const sellerSection = page.getByTestId(`seller-information-section`);
await expect(
sellerSection.getByText("Seller Information", { exact: true })
).toBeVisible();
// Name field
await expect(
sellerSection.getByRole("textbox", { name: "Name" })
).toHaveValue(INITIAL_INVOICE_DATA.seller.name);
// Address field
await expect(
sellerSection.getByRole("textbox", { name: "Address" })
).toHaveValue(INITIAL_INVOICE_DATA.seller.address);
// VAT Number field and visibility toggle
await expect(
sellerSection.getByRole("textbox", { name: "VAT Number" })
).toHaveValue(INITIAL_INVOICE_DATA.seller.vatNo);
await expect(
sellerSection.getByRole("switch", { name: /Show in PDF/i }).nth(0)
).toBeChecked();
// Email field
await expect(
sellerSection.getByRole("textbox", { name: "Email" })
).toHaveValue(INITIAL_INVOICE_DATA.seller.email);
// Account Number field and visibility toggle
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)
).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)
).toBeChecked();
// Verify Seller Management button is present
await expect(
sellerSection.getByRole("button", { name: "New Seller" })
).toBeVisible();
// **CHECK BUYER INFORMATION SECTION**
const buyerSection = page.getByTestId(`buyer-information-section`);
await expect(
buyerSection.getByText("Buyer Information", { exact: true })
).toBeVisible();
// Name field
await expect(
buyerSection.getByRole("textbox", { name: "Name" })
).toHaveValue(INITIAL_INVOICE_DATA.buyer.name);
// Address field
await expect(
buyerSection.getByRole("textbox", { name: "Address" })
).toHaveValue(INITIAL_INVOICE_DATA.buyer.address);
// VAT Number field and visibility toggle
await expect(
buyerSection.getByRole("textbox", { name: "VAT Number" })
).toHaveValue(INITIAL_INVOICE_DATA.buyer.vatNo);
await expect(
buyerSection.getByRole("switch", { name: /Show in PDF/i })
).toBeChecked();
// Email field
await expect(
buyerSection.getByRole("textbox", { name: "Email" })
).toHaveValue(INITIAL_INVOICE_DATA.buyer.email);
// Verify Buyer Management button is present
await expect(
buyerSection.getByRole("button", { name: "New Buyer" })
).toBeVisible();
// **Check INVOICE ITEMS section**
const invoiceItemsSection = page.getByTestId(`invoice-items-section`);
await expect(
invoiceItemsSection.getByText("Invoice Items", { exact: true })
).toBeVisible();
// Check visibility toggles in settings
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show "Number" Column/i })
).toBeChecked();
await expect(
invoiceItemsSection.getByRole("switch", {
name: /Show "VAT Table Summary"/i,
})
).toBeChecked();
// Check first invoice item fields
const firstItem = INITIAL_INVOICE_DATA.items[0];
// Name field and visibility toggle
await expect(
invoiceItemsSection.getByRole("textbox", { name: "Name" })
).toHaveValue(firstItem.name);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(0)
).toBeChecked();
// Type of GTU field and visibility toggle
await expect(
invoiceItemsSection.getByRole("textbox", { name: "Type of GTU" })
).toHaveValue(firstItem.typeOfGTU);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(1)
).toBeChecked();
// Amount field and visibility toggle
await expect(
invoiceItemsSection.getByRole("spinbutton", { name: "Amount" })
).toHaveValue(firstItem.amount.toString());
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(2)
).toBeChecked();
// Unit field and visibility toggle
await expect(
invoiceItemsSection.getByRole("textbox", { name: "Unit" })
).toHaveValue(firstItem.unit);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(3)
).toBeChecked();
// Net Price field and visibility toggle
await expect(
invoiceItemsSection.getByRole("spinbutton", { name: "Net Price" })
).toHaveValue(firstItem.netPrice.toString());
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(4)
).toBeChecked();
// VAT field and visibility toggle
await expect(
invoiceItemsSection.getByRole("textbox", { name: "VAT", exact: true })
).toHaveValue(firstItem.vat);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(5)
).toBeChecked();
// Net Amount field (read-only) and visibility toggle
await expect(
invoiceItemsSection.getByRole("textbox", {
name: "Net Amount",
exact: true,
})
).toHaveValue(
firstItem.netAmount.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(6)
).toBeChecked();
// VAT Amount field (read-only) and visibility toggle
await expect(
invoiceItemsSection.getByRole("textbox", {
name: "VAT Amount",
exact: true,
})
).toHaveValue(
firstItem.vatAmount.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(7)
).toBeChecked();
// Pre-tax Amount field (read-only) and visibility toggle
await expect(
invoiceItemsSection.getByRole("textbox", {
name: "Pre-tax Amount",
exact: true,
})
).toHaveValue(
firstItem.preTaxAmount.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(8)
).toBeChecked();
// Verify Add Invoice Item button is present
await expect(
invoiceItemsSection.getByRole("button", { name: "Add invoice item" })
).toBeVisible();
});
test("can add and remove invoice items", async ({ page }) => {
const invoiceItemsSection = page.getByTestId(`invoice-items-section`);
// Add new invoice item
await invoiceItemsSection
.getByRole("button", { name: "Add invoice item" })
.click();
await expect(
invoiceItemsSection.getByText("Item 2", { exact: true })
).toBeVisible();
// Fill in new item details
const itemNameInput = invoiceItemsSection
.getByRole("textbox", { name: "Name" })
.nth(1);
await itemNameInput.fill("TEST INVOICE ITEM");
await expect(itemNameInput).toHaveValue("TEST INVOICE ITEM");
// Remove the added item
await invoiceItemsSection
.getByRole("button", { name: "Delete Invoice Item 2" })
.click();
await expect(
invoiceItemsSection.getByText("Item 2", { exact: true })
).toBeHidden();
});
test("calculates totals correctly", async ({ page }) => {
const invoiceItemsSection = page.getByTestId(`invoice-items-section`);
// Fill in item details
await invoiceItemsSection
.getByRole("spinbutton", { name: "Amount", exact: true })
.fill("2");
await invoiceItemsSection
.getByRole("spinbutton", { name: "Net Price", exact: true })
.fill("100");
await invoiceItemsSection
.getByRole("textbox", { name: "VAT", exact: true })
.fill("23");
// Check calculated values
await expect(
invoiceItemsSection.getByRole("textbox", {
name: "Net Amount",
exact: true,
})
).toHaveValue("200.00");
await expect(
invoiceItemsSection.getByRole("textbox", {
name: "VAT Amount",
exact: true,
})
).toHaveValue("46.00");
const finalSection = page.getByTestId(`final-section`);
await expect(
finalSection.getByRole("textbox", {
name: "Total",
exact: true,
})
).toHaveValue("246.00");
});
test("handles form validation", async ({ page }) => {
// Clear required fields
await page.getByRole("textbox", { name: "Invoice Number" }).clear();
await page.getByRole("textbox", { name: "Date of Issue" }).clear();
// Try to generate PDF
await page.getByRole("link", { name: "Download PDF in English" }).click();
// Check for error messages
await expect(
page.getByText("Invoice number is required", { exact: true })
).toBeVisible();
await expect(
page.getByText("Date of issue is required", { exact: true })
).toBeVisible();
const dateOfIssue = dayjs().format("YYYY-MM-DD");
// Fill in required fields
await page
.getByRole("textbox", { name: "Invoice Number" })
.fill("1/03-2025");
await page
.getByRole("textbox", { name: "Date of Issue" })
.fill(dateOfIssue);
// Check if the date of issue is filled in correctly
await expect(
page.getByRole("textbox", { name: "Date of Issue" })
).toHaveValue(dateOfIssue);
// Try to generate PDF again
await page.getByRole("link", { name: "Download PDF in English" }).click();
// Check for error messages to be hidden
await expect(
page.getByText("Invoice number is required", { exact: true })
).toBeHidden();
await expect(
page.getByText("Date of issue is required", { exact: true })
).toBeHidden();
});
test("persists data in local storage", async ({ page }) => {
// Fill in some data
await page
.getByRole("textbox", { name: "Invoice Number" })
.fill("TEST/2024");
await page
.getByRole("textbox", { name: "Notes", exact: true })
.fill("Test note");
// Wait a moment for any debounced localStorage updates
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(500);
// Verify data is actually saved in localStorage
const storedData = (await page.evaluate(() => {
return localStorage.getItem("EASY_INVOICE_PDF_DATA");
})) as string;
expect(storedData).toBeTruthy();
const parsedData = JSON.parse(storedData) as InvoiceData;
expect(parsedData).toMatchObject({
invoiceNumber: "TEST/2024",
notes: "Test note",
});
// Reload page
await page.reload();
// Check if data persists in UI
await expect(
page.getByRole("textbox", { name: "Invoice Number" })
).toHaveValue("TEST/2024");
await expect(
page.getByRole("textbox", { name: "Notes", exact: true })
).toHaveValue("Test note");
});
test("handles currency switching", async ({ page }) => {
const invoiceItemsSection = page.getByTestId(`invoice-items-section`);
const netPriceFormElement =
invoiceItemsSection.getByTestId(`itemNetPrice0`);
const netAmountFormElement =
invoiceItemsSection.getByTestId(`itemNetAmount0`);
// Verify initial currency
await expect(netPriceFormElement).toHaveText("€EUR");
await expect(netAmountFormElement).toHaveText("€EUR");
await expect(
invoiceItemsSection.getByText("Preview: €0.00 (zero EUR 00/100)")
).toBeVisible();
const currencySelect = page.getByRole("combobox", { name: "Currency" });
// Switch currency
await currencySelect.selectOption("USD");
await expect(currencySelect).toHaveValue("USD");
// Verify calculations with new currency
await invoiceItemsSection
.getByRole("spinbutton", { name: "Amount", exact: true })
.fill("2");
await invoiceItemsSection
.getByRole("spinbutton", { name: "Net Price", exact: true })
.fill("100.75");
await expect(netPriceFormElement).toHaveText("$USD");
await expect(netAmountFormElement).toHaveText("$USD");
await expect(
invoiceItemsSection.getByText(
"Preview: $100.75 (one hundred USD 75/100)",
{
exact: true,
}
)
).toBeVisible();
const finalSection = page.getByTestId(`final-section`);
await expect(
finalSection.getByRole("textbox", {
name: "Total",
exact: true,
})
).toHaveValue("201.50");
});
test("accordion items are visible, collapsible and saved in the local storage", async ({
page,
}) => {
// Define sections with their labels
const sections = [
{ id: "general-information-section", label: "General Information" },
{ id: "seller-information-section", label: "Seller Information" },
{ id: "buyer-information-section", label: "Buyer Information" },
{ id: "invoice-items-section", label: "Invoice Items" },
] as const;
// Verify all sections are initially visible and expanded
for (const section of sections) {
const sectionElement = page.getByTestId(section.id);
await expect(sectionElement).toBeVisible();
await expect(
sectionElement.getByRole("region", { name: section.label })
).toBeVisible();
}
// Collapse specific sections to create mixed state
await page
.getByTestId("seller-information-section")
.getByRole("button", { name: "Seller Information" })
.click();
await page
.getByTestId("invoice-items-section")
.getByRole("button", { name: "Invoice Items" })
.click();
// Verify mixed state: general and buyer expanded, seller and items collapsed
await expect(
page
.getByTestId("general-information-section")
.getByRole("region", { name: "General Information" })
).toBeVisible();
await expect(
page
.getByTestId("seller-information-section")
.getByRole("region", { name: "Seller Information" })
).toBeHidden();
await expect(
page
.getByTestId("buyer-information-section")
.getByRole("region", { name: "Buyer Information" })
).toBeVisible();
await expect(
page
.getByTestId("invoice-items-section")
.getByRole("region", { name: "Invoice Items" })
).toBeHidden();
// Verify the state is saved in localStorage
const storedState = (await page.evaluate((key) => {
return localStorage.getItem(key);
}, ACCORDION_STATE_LOCAL_STORAGE_KEY)) as string;
expect(storedState).toBeTruthy();
const parsedState = JSON.parse(storedState) as AccordionState;
expect(parsedState).toEqual({
general: true,
seller: false,
buyer: true,
invoiceItems: false,
} as const satisfies AccordionState);
// Reload the page and verify state persistence
await page.reload();
// Verify state persists after reload
await expect(
page
.getByTestId("general-information-section")
.getByRole("region", { name: "General Information" })
).toBeVisible();
await expect(
page
.getByTestId("seller-information-section")
.getByRole("region", { name: "Seller Information" })
).toBeHidden();
await expect(
page
.getByTestId("buyer-information-section")
.getByRole("region", { name: "Buyer Information" })
).toBeVisible();
await expect(
page
.getByTestId("invoice-items-section")
.getByRole("region", { name: "Invoice Items" })
).toBeHidden();
// Toggle states after reload
await page
.getByTestId("general-information-section")
.getByRole("button", { name: "General Information" })
.click();
await page
.getByTestId("seller-information-section")
.getByRole("button", { name: "Seller Information" })
.click();
// Verify new toggled state
await expect(
page
.getByTestId("general-information-section")
.getByRole("region", { name: "General Information" })
).toBeHidden();
await expect(
page
.getByTestId("seller-information-section")
.getByRole("region", { name: "Seller Information" })
).toBeVisible();
await expect(
page
.getByTestId("buyer-information-section")
.getByRole("region", { name: "Buyer Information" })
).toBeVisible();
await expect(
page
.getByTestId("invoice-items-section")
.getByRole("region", { name: "Invoice Items" })
).toBeHidden();
// Verify updated state is saved in localStorage
const updatedStoredState = (await page.evaluate((key) => {
return localStorage.getItem(key);
}, ACCORDION_STATE_LOCAL_STORAGE_KEY)) as string;
expect(updatedStoredState).toBeTruthy();
const updatedParsedState = JSON.parse(updatedStoredState) as AccordionState;
expect(updatedParsedState).toEqual({
general: false,
seller: true,
buyer: true,
invoiceItems: false,
} as const satisfies AccordionState);
});
test("validates amount, net price and VAT fields in invoice items section", async ({
page,
}) => {
const invoiceItemsSection = page.getByTestId(`invoice-items-section`);
// **AMOUNT FIELD**
const amountInput = invoiceItemsSection.getByRole("spinbutton", {
name: "Amount",
});
// Test invalid values
await amountInput.fill("-1");
await expect(page.getByText("Amount must be positive")).toBeVisible();
await amountInput.fill("0");
await expect(page.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")
).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")
).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")
).toBeHidden();
// **NET PRICE FIELD**
const netPriceInput = invoiceItemsSection.getByRole("spinbutton", {
name: "Net Price",
});
// Test negative value
await netPriceInput.fill("-100");
await expect(page.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")
).toBeVisible();
// Test zero value
await netPriceInput.fill("0");
await expect(page.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")
).toBeHidden();
// **VAT FIELD**
const vatInput = invoiceItemsSection.getByRole("textbox", {
name: "VAT",
exact: true,
});
// Try invalid values
await vatInput.fill("101");
await expect(page.getByText("VAT must be between 0 and 100")).toBeVisible();
await vatInput.fill("-1");
await expect(page.getByText("VAT must be between 0 and 100")).toBeVisible();
await vatInput.fill("abc");
await expect(
page.getByText("Must be a valid number (0-100) or NP or OO")
).toBeVisible();
// Try valid values
await vatInput.fill("23");
await expect(page.getByText("VAT must be between 0 and 100")).toBeHidden();
await vatInput.fill("NP");
await expect(
page.getByText("Must be a valid number (0-100) or NP or OO")
).toBeHidden();
await vatInput.fill("OO");
await expect(
page.getByText("Must be a valid number (0-100) or NP or OO")
).toBeHidden();
});
test("handles VAT calculations for different rates", async ({ page }) => {
// Test with different VAT rates
const testCases = [
{
vat: "23",
amount: "100",
netPrice: "100",
expected: {
net: "10,000.00",
vatAmount: "2,300.00",
total: "12,300.00",
},
},
{
vat: "8",
amount: "100",
netPrice: "100",
expected: { net: "10,000.00", vatAmount: "800.00", total: "10,800.00" },
},
{
vat: "0",
amount: "100",
netPrice: "100",
expected: { net: "10,000.00", vatAmount: "0.00", total: "10,000.00" },
},
{
vat: "NP",
amount: "100",
netPrice: "100",
expected: { net: "10,000.00", vatAmount: "0.00", total: "10,000.00" },
},
{
vat: "OO",
amount: "3",
netPrice: "100",
expected: { net: "300.00", vatAmount: "0.00", total: "300.00" },
},
] as const satisfies {
vat: string;
amount: string;
netPrice: string;
expected: { net: string; vatAmount: string; total: string };
}[];
const invoiceItemsSection = page.getByTestId(`invoice-items-section`);
const amountInput = invoiceItemsSection.getByRole("spinbutton", {
name: "Amount",
exact: true,
});
const netPriceInput = invoiceItemsSection.getByRole("spinbutton", {
name: "Net Price",
exact: true,
});
const vatInput = invoiceItemsSection.getByRole("textbox", {
name: "VAT",
exact: true,
});
for (const testCase of testCases) {
// Fill in values
await amountInput.fill(testCase.amount);
await netPriceInput.fill(testCase.netPrice);
await vatInput.fill(testCase.vat);
// Check calculations
await expect(
invoiceItemsSection.getByRole("textbox", {
name: "Net Amount",
exact: true,
})
).toHaveValue(testCase.expected.net);
await expect(
invoiceItemsSection.getByRole("textbox", {
name: "VAT Amount",
exact: true,
})
).toHaveValue(testCase.expected.vatAmount);
await expect(
invoiceItemsSection.getByRole("textbox", {
name: "Pre-tax Amount",
exact: true,
})
).toHaveValue(testCase.expected.total);
await expect(
page.getByRole("textbox", {
name: "Total",
exact: true,
})
).toHaveValue(testCase.expected.total);
}
});
test("generates shareable URL that persists invoice data between tabs", async ({
page,
context,
}) => {
// Fill in some test data
await page
.getByRole("textbox", { name: "Invoice Number" })
.fill("SHARE-TEST-001");
await page
.getByRole("textbox", { name: "Notes", exact: true })
.fill("Test note for sharing");
// Fill in seller information
const sellerSection = page.getByTestId("seller-information-section");
await sellerSection
.getByRole("textbox", { name: "Name" })
.fill("Test Seller");
await sellerSection
.getByRole("textbox", { name: "Address" })
.fill("123 Test St");
await sellerSection
.getByRole("textbox", { name: "Email" })
.fill("seller@test.com");
// Fill in an invoice item
const invoiceItemsSection = page.getByTestId("invoice-items-section");
await invoiceItemsSection
.getByRole("spinbutton", { name: "Amount" })
.fill("5");
await invoiceItemsSection
.getByRole("spinbutton", { name: "Net Price" })
.fill("100");
await invoiceItemsSection
.getByRole("textbox", { name: "VAT", exact: true })
.fill("23");
// wait for debounce timeout
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(600);
// 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"));
// Get the current URL which should now contain the share data
const sharedUrl = page.url();
expect(sharedUrl).toContain("?data=");
// Open URL in new tab
const newPage = await context.newPage();
await newPage.goto(sharedUrl);
// Verify data is loaded in new tab
await expect(
newPage.getByRole("textbox", { name: "Invoice Number" })
).toHaveValue("SHARE-TEST-001");
await expect(
newPage.getByRole("textbox", { name: "Notes", exact: true })
).toHaveValue("Test note for sharing");
// Verify seller information
const newSellerSection = newPage.getByTestId("seller-information-section");
await expect(
newSellerSection.getByRole("textbox", { name: "Name" })
).toHaveValue("Test Seller");
await expect(
newSellerSection.getByRole("textbox", { name: "Address" })
).toHaveValue("123 Test St");
await expect(
newSellerSection.getByRole("textbox", { name: "Email" })
).toHaveValue("seller@test.com");
// Verify invoice item
const newInvoiceItemsSection = newPage.getByTestId("invoice-items-section");
await expect(
newInvoiceItemsSection.getByRole("spinbutton", { name: "Amount" })
).toHaveValue("5");
await expect(
newInvoiceItemsSection.getByRole("spinbutton", { name: "Net Price" })
).toHaveValue("100");
await expect(
newInvoiceItemsSection.getByRole("textbox", { name: "VAT", exact: true })
).toHaveValue("23");
// Close the new page
await newPage.close();
});
});

474
e2e/pdf.test.ts Normal file
View file

@ -0,0 +1,474 @@
import { expect, test } from "@playwright/test";
import dayjs from "dayjs";
import fs from "fs";
import path from "path";
import pdf from "pdf-parse";
const TEST_DOWNLOADS_DIR = "test-downloads";
/**
* We can't test the PDF preview because it's not supported in Playwright.
* https://github.com/microsoft/playwright/issues/7822
*
* we download pdf file and parse the content to verify the invoice data
*/
test.describe("PDF Preview", () => {
test.beforeAll(async () => {
// Ensure test-downloads directory exists
try {
await fs.promises.mkdir(TEST_DOWNLOADS_DIR);
} catch (error) {
// Handle specific error cases if needed
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
throw error;
}
}
});
test.afterEach(async () => {
// Clean up all files in test-downloads directory
try {
const files = await fs.promises.readdir(TEST_DOWNLOADS_DIR);
await Promise.all(
files.map((file) =>
fs.promises.unlink(path.join(TEST_DOWNLOADS_DIR, file))
)
);
} catch (error) {
// Handle specific error cases if needed
// ENOENT means "no such file or directory" - ignore this expected error
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
});
test.afterAll(async () => {
// Remove the test-downloads directory itself
try {
await fs.promises.rmdir(TEST_DOWNLOADS_DIR);
} catch (error) {
// Handle specific error cases if needed
// ENOENT means "no such file or directory" - ignore this expected error
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
});
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("downloads PDF in English and verifies content", async ({ page }) => {
// Set up download handler
const downloadPromise = page.waitForEvent("download");
const downloadButton = page.getByRole("link", {
name: "Download PDF in English",
});
// Wait for download button to be visible and enabled
await expect(downloadButton).toBeVisible();
await expect(downloadButton).toBeEnabled();
// Click the download button
await downloadButton.click();
// Wait for the download to start
const download = await downloadPromise;
// Get the suggested filename
const suggestedFilename = download.suggestedFilename();
// Save the file to a temporary location
const tmpPath = path.join(TEST_DOWNLOADS_DIR, suggestedFilename);
await download.saveAs(tmpPath);
// Read and verify PDF content using pdf-parse
const dataBuffer = await fs.promises.readFile(tmpPath);
const pdfData = await pdf(dataBuffer);
// Verify PDF content
expect(pdfData.text).toContain("Invoice No. of:");
expect(pdfData.text).toContain("Date of issue:");
expect(pdfData.text).toContain("Seller");
expect(pdfData.text).toContain("Buyer");
const lastDayOfCurrentMonth = dayjs().endOf("month").format("YYYY-MM-DD");
expect(pdfData.text).toContain(
`Date of sales/of executing the service: ${lastDayOfCurrentMonth}`
);
expect(pdfData.text).toContain(`To pay: 0.00 EUR
Paid: 0.00 EUR
Left to pay: 0.00 EUR
Amount in words: zero EUR 00/100`);
expect(pdfData.text).toContain(`Reverse charge
Created with https://easyinvoicepdf.com`);
});
test("downloads PDF in Polish and verifies translated content", async ({
page,
}) => {
// Switch to Polish
await page
.getByRole("combobox", { name: "Invoice PDF Language" })
.selectOption("pl");
// we wait until this button is visible and enabled, that means that the PDF preview has been regenerated
const downloadButton = page.getByRole("link", {
name: "Download PDF in Polish",
});
await expect(downloadButton).toBeVisible();
await expect(downloadButton).toBeEnabled();
// Set up download handler
const downloadPromise = page.waitForEvent("download");
// Click the download button
await downloadButton.click();
// Wait for the download to start
const download = await downloadPromise;
// Get the suggested filename
const suggestedFilename = download.suggestedFilename();
// Save the file to a temporary location
const tmpPath = path.join(TEST_DOWNLOADS_DIR, suggestedFilename);
await download.saveAs(tmpPath);
// Read and verify PDF content using pdf-parse
const dataBuffer = await fs.promises.readFile(tmpPath);
const pdfData = await pdf(dataBuffer);
// Verify PDF content
expect(pdfData.text).toContain("Faktura nr");
expect(pdfData.text).toContain("Data wystawienia");
expect(pdfData.text).toContain("Sprzedawca");
expect(pdfData.text).toContain("Nabywca");
expect(pdfData.text).toContain("Data wystawienia:");
const lastDayOfCurrentMonth = dayjs().endOf("month").format("YYYY-MM-DD");
expect(pdfData.text).toContain(
`Data sprzedaży / wykonania usługi: ${lastDayOfCurrentMonth}`
);
expect(pdfData.text).toContain(`Razem do zapłaty: 0.00 EUR
Wpłacono: 0.00 EUR
Pozostało do zapłaty: 0.00 EUR
Kwota słownie: zero EUR 00/100`);
expect(pdfData.text).toContain("Created with https://easyinvoicepdf.com");
});
test("update pdf when invoice data changes", async ({ page }) => {
// Switch to another currency
await page.getByRole("combobox", { name: "Currency" }).selectOption("GBP");
// Switch to another date format
await page
.getByRole("combobox", { name: "Date format" })
.selectOption("MMMM D, YYYY");
await page
.getByRole("textbox", { name: "Invoice Type" })
.fill("HELLO FROM PLAYWRIGHT TEST!");
const sellerSection = page.getByTestId(`seller-information-section`);
// Name field
await sellerSection
.getByRole("textbox", { name: "Name" })
.fill("PLAYWRIGHT SELLER TEST");
// Toggle VAT Number visibility off
await sellerSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(0)
.click();
// Toggle Account Number visibility off
await sellerSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(1)
.click();
// Toggle SWIFT visibility off
await sellerSection
.getByRole("switch", { name: /Show in PDF/i })
.nth(2)
.click();
const buyerSection = page.getByTestId(`buyer-information-section`);
// Name field
await buyerSection
.getByRole("textbox", { name: "Name" })
.fill("PLAYWRIGHT BUYER TEST");
// // Address field
await buyerSection
.getByRole("textbox", { name: "Address" })
.fill("PLAYWRIGHT BUYER ADDRESS TEST");
// // Email field
await buyerSection
.getByRole("textbox", { name: "Email" })
.fill("TEST_BUYER_EMAIL@mail.com");
const invoiceSection = page.getByTestId(`invoice-items-section`);
// Amount field
await invoiceSection.getByRole("spinbutton", { name: "Amount" }).fill("3");
// Net price field
await invoiceSection
.getByRole("spinbutton", { name: "Net price" })
.fill("1000");
// Toggle VAT Table Summary visibility off
await page
.getByRole("switch", { name: `Show "VAT Table Summary" in the PDF` })
.click();
// Wait for PDF preview to regenerate after language change (debounce timeout)
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(600);
// Set up download handler
const downloadPromise = page.waitForEvent("download");
const downloadButton = page.getByRole("link", {
name: "Download PDF in English",
});
await expect(downloadButton).toBeVisible();
await expect(downloadButton).toBeEnabled();
// Click the download button
await downloadButton.click();
// Wait for the download to start
const download = await downloadPromise;
// Get the suggested filename
const suggestedFilename = download.suggestedFilename();
// Save the file to a temporary location
const tmpPath = path.join(TEST_DOWNLOADS_DIR, suggestedFilename);
await download.saveAs(tmpPath);
// Read and verify PDF content using pdf-parse
const dataBuffer = await fs.promises.readFile(tmpPath);
const pdfData = await pdf(dataBuffer);
const lastDayOfCurrentMonth = dayjs().endOf("month").format("MMMM D, YYYY");
// Verify PDF content
expect(pdfData.text).toContain(
`Date of sales/of executing the service: ${lastDayOfCurrentMonth}`
);
expect(pdfData.text).toContain("HELLO FROM PLAYWRIGHT TEST!");
expect(pdfData.text).toContain("PLAYWRIGHT SELLER TEST");
expect(pdfData.text).toContain(`PLAYWRIGHT BUYER TEST`);
expect(pdfData.text).toContain(`PLAYWRIGHT BUYER ADDRESS TEST`);
expect(pdfData.text).toContain(`TEST_BUYER_EMAIL@mail.com`);
// Check that the PDF does NOT contain the seller's VAT number, account number, or SWIFT/BIC number
// because we toggled the visibility off
expect(pdfData.text).not.toContain(`VAT no: Seller vat number
Account Number - Seller account number
SWIFT/BIC number: Seller swift bic`);
// Check that the PDF does NOT contain the VAT table summary
// because we toggled the visibility off
expect(pdfData.text).not.toContain(`VAT rateNetVATPre-tax
NP3 000.000.003 000.00
Total3 000.000.003 000.00`);
expect(pdfData.text).toContain(`To pay: 3 000.00 GBP
Paid: 0.00 GBP
Left to pay: 3 000.00 GBP
Amount in words: three thousand GBP 00/100`);
expect(pdfData.text).toContain(`Reverse charge
Created with https://easyinvoicepdf.com`);
});
test("completes full invoice flow on mobile: tabs navigation, form editing and PDF download", async ({
page,
}) => {
// Set mobile viewport
await page.setViewportSize({ width: 375, height: 667 });
// Verify tabs are visible in mobile view
await expect(page.getByRole("tab", { name: "Edit Invoice" })).toBeVisible();
await expect(page.getByRole("tab", { name: "Preview PDF" })).toBeVisible();
// Download button in English is visible and enabled
const downloadButtonEnglish = page.getByRole("link", {
name: "Download PDF in English",
});
// Wait for download button to be visible
await expect(downloadButtonEnglish).toBeVisible();
// Wait for download button to be enabled
await expect(downloadButtonEnglish).toBeEnabled();
// Switch to Polish
await page
.getByRole("combobox", { name: "Invoice PDF Language" })
.selectOption("pl");
// Fill in some invoice data
await page
.getByRole("textbox", { name: "Invoice Number" })
.fill("MOBILE-TEST-001");
await page
.getByRole("textbox", { name: "Notes", exact: true })
.fill("Mobile test note");
// Fill in seller information
const sellerSection = page.getByTestId("seller-information-section");
await sellerSection
.getByRole("textbox", { name: "Name" })
.fill("Mobile Test Seller");
await sellerSection
.getByRole("textbox", { name: "Address" })
.fill("456 Mobile St");
// Fill in an invoice item
const invoiceItemsSection = page.getByTestId("invoice-items-section");
await invoiceItemsSection
.getByRole("spinbutton", { name: "Amount" })
.fill("3");
await invoiceItemsSection
.getByRole("spinbutton", { name: "Net Price" })
.fill("50");
await invoiceItemsSection
.getByRole("textbox", { name: "VAT", exact: true })
.fill("23");
// we wait until this button is visible and enabled, that means that the PDF preview has been regenerated
const downloadButtonPolish = page.getByRole("link", {
name: "Download PDF in Polish",
});
// Wait for download button to be visible and enabled
await expect(downloadButtonPolish).toBeVisible();
await expect(downloadButtonPolish).toBeEnabled();
// Switch to preview tab
await page.getByRole("tab", { name: "Preview PDF" }).click();
// Verify preview tab is selected
await expect(
page.getByRole("tabpanel", { name: "Preview PDF" })
).toBeVisible();
await expect(
page.getByRole("tabpanel", { name: "Edit Invoice" })
).toBeHidden();
// Set up download handler
const downloadPromise = page.waitForEvent("download");
// Click the download button
await downloadButtonPolish.click();
// Wait for the download to start
const download = await downloadPromise;
// Get the suggested filename
const suggestedFilename = download.suggestedFilename();
// Save the file to a temporary location
const tmpPath = path.join(TEST_DOWNLOADS_DIR, suggestedFilename);
await download.saveAs(tmpPath);
// Read and verify PDF content using pdf-parse
const dataBuffer = await fs.promises.readFile(tmpPath);
const pdfData = await pdf(dataBuffer);
// Verify PDF content in Polish
expect(pdfData.text).toContain("Faktura nr");
expect(pdfData.text).toContain("Data wystawienia");
expect(pdfData.text).toContain("Sprzedawca");
expect(pdfData.text).toContain("Nabywca");
expect(pdfData.text).toContain("Mobile Test Seller");
expect(pdfData.text).toContain("456 Mobile St");
const lastDayOfCurrentMonth = dayjs().endOf("month").format("YYYY-MM-DD");
expect(pdfData.text).toContain(
`Data sprzedaży / wykonania usługi: ${lastDayOfCurrentMonth}`
);
// Verify calculations in Polish
expect(pdfData.text).toContain(`Razem do zapłaty: 184.50 EUR
Wpłacono: 0.00 EUR
Pozostało do zapłaty: 184.50 EUR`);
// Switch back to form tab
await page.getByRole("tab", { name: "Edit Invoice" }).click();
// Verify form tab is selected and data persists
await expect(
page.getByRole("tabpanel", { name: "Edit Invoice" })
).toBeVisible();
await expect(
page.getByRole("tabpanel", { name: "Preview PDF" })
).toBeHidden();
// Verify form data persists
await expect(
page.getByRole("textbox", { name: "Invoice Number" })
).toHaveValue("MOBILE-TEST-001");
await expect(
page.getByRole("textbox", { name: "Notes", exact: true })
).toHaveValue("Mobile test note");
// Verify seller information persists
await expect(
sellerSection.getByRole("textbox", { name: "Name" })
).toHaveValue("Mobile Test Seller");
await expect(
sellerSection.getByRole("textbox", { name: "Address" })
).toHaveValue("456 Mobile St");
// Verify invoice item persists
await expect(
invoiceItemsSection.getByRole("spinbutton", { name: "Amount" })
).toHaveValue("3");
await expect(
invoiceItemsSection.getByRole("spinbutton", { name: "Net Price" })
).toHaveValue("50");
await expect(
invoiceItemsSection.getByRole("textbox", { name: "VAT", exact: true })
).toHaveValue("23");
// Verify calculations are correct
await expect(
invoiceItemsSection.getByRole("textbox", {
name: "Net Amount",
exact: true,
})
).toHaveValue("150.00");
await expect(
invoiceItemsSection.getByRole("textbox", {
name: "VAT Amount",
exact: true,
})
).toHaveValue("34.50");
await expect(
invoiceItemsSection.getByRole("textbox", {
name: "Pre-tax Amount",
exact: true,
})
).toHaveValue("184.50");
});
});

365
e2e/seller.test.ts Normal file
View file

@ -0,0 +1,365 @@
import { DEFAULT_SELLER_DATA, type SellerData } from "@/app/schema";
import { expect, test } from "@playwright/test";
test.describe("Seller management", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("create/edit seller", async ({ page }) => {
// Open seller management dialog
await page.getByRole("button", { name: "New Seller" }).click();
// Fill in all seller details
const testData = {
name: "New Test Company",
address: "123 Test Street\nTest City, 12345\nTest Country",
vatNoFieldIsVisible: true,
vatNo: "123456789",
email: "test@company.com",
accountNumberFieldIsVisible: true,
accountNumber: "1234-5678-9012-3456",
swiftBicFieldIsVisible: true,
swiftBic: "TESTBICX",
} as const satisfies SellerData;
const manageSellerDialog = page.getByTestId(`manage-seller-dialog`);
// Fill in form fields
await manageSellerDialog
.getByRole("textbox", { name: "Name" })
.fill(testData.name);
await manageSellerDialog
.getByRole("textbox", { name: "Address" })
.fill(testData.address);
await manageSellerDialog
.getByRole("textbox", { name: "VAT Number" })
.fill(testData.vatNo);
await manageSellerDialog
.getByRole("textbox", { name: "Email" })
.fill(testData.email);
await manageSellerDialog
.getByRole("textbox", { name: "Account Number" })
.fill(testData.accountNumber);
await manageSellerDialog
.getByRole("textbox", { name: "SWIFT/BIC" })
.fill(testData.swiftBic);
// Verify all switches are checked by default
await expect(
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(0)
).toBeChecked();
await expect(
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(1)
).toBeChecked();
await expect(
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(2)
).toBeChecked();
// Toggle some visibility switches
await manageSellerDialog
.getByRole("switch", { name: "Show in PDF" })
.nth(1)
.click(); // Toggle Account Number visibility
await manageSellerDialog
.getByRole("switch", { name: "Show in PDF" })
.nth(2)
.click(); // Toggle SWIFT/BIC visibility
// Verify "Apply to Current Invoice" switch is checked by default
await expect(
manageSellerDialog.getByRole("switch", {
name: "Apply to Current Invoice",
})
).toBeChecked();
// Cancel button is shown
await expect(
manageSellerDialog.getByRole("button", { name: "Cancel" })
).toBeVisible();
// Save seller
await manageSellerDialog
.getByRole("button", { name: "Save Seller" })
.click();
// Verify seller data is actually saved in localStorage
const storedData = (await page.evaluate(() => {
return localStorage.getItem("EASY_INVOICE_PDF_SELLERS");
})) as string;
expect(storedData).toBeTruthy();
const parsedData = JSON.parse(storedData) as SellerData[];
expect(parsedData[0]).toMatchObject({
name: testData.name,
address: testData.address,
vatNo: testData.vatNo,
vatNoFieldIsVisible: testData.vatNoFieldIsVisible,
email: testData.email,
accountNumber: testData.accountNumber,
accountNumberFieldIsVisible: false,
swiftBic: testData.swiftBic,
swiftBicFieldIsVisible: false,
} satisfies SellerData);
// Verify success toast message is visible
await expect(
page.getByText("Seller added successfully", { exact: true })
).toBeVisible();
// Verify all saved details in the Seller Information section form
const sellerForm = page.getByTestId(`seller-information-section`);
// Try to find desktop tooltip icon first
const desktopTooltipExists =
(await sellerForm
.getByTestId("form-section-tooltip-info-icon-desktop")
.count()) > 0;
// If desktop tooltip exists, hover over it; otherwise find and click mobile tooltip
// eslint-disable-next-line playwright/no-conditional-in-test
if (desktopTooltipExists) {
// Get desktop tooltip icons and hover over the first one because we use tooltip
const desktopTooltips = sellerForm.getByTestId(
"form-section-tooltip-info-icon-desktop"
);
await desktopTooltips.first().hover();
} else {
// Get mobile tooltip icons and click the first one because we use popover
const mobileTooltips = sellerForm.getByTestId(
"form-section-tooltip-info-icon-mobile"
);
await mobileTooltips.first().click();
}
// Verify the tooltip content appears
await expect(
page.getByText(
"Seller details are locked. Click the edit button next to the 'Select Seller' dropdown to modify seller details. Any changes will be automatically saved.",
{ exact: true }
)
).toBeVisible();
// Check that HTML title attributes contain the tooltip message on input fields
const nameInput = sellerForm.getByRole("textbox", { name: "Name" });
await expect(nameInput).toHaveAttribute(
"title",
"Seller details are locked. Click the edit seller button to modify."
);
// Seller Name
await expect(nameInput).toHaveAttribute("aria-readonly", "true");
await expect(nameInput).toHaveValue(testData.name);
// Seller Address
await expect(
sellerForm.getByRole("textbox", { name: "Address" })
).toHaveAttribute("aria-readonly", "true");
await expect(
sellerForm.getByRole("textbox", { name: "Address" })
).toHaveValue(testData.address);
// Seller VAT Number
await expect(
sellerForm.getByRole("textbox", { name: "VAT Number" })
).toHaveAttribute("aria-readonly", "true");
await expect(
sellerForm.getByRole("textbox", { name: "VAT Number" })
).toHaveValue(testData.vatNo);
const vatNumberSwitch = sellerForm.getByTestId(`sellerVatNoFieldIsVisible`);
// Verify VAT Number switch is visible
await expect(vatNumberSwitch).toBeChecked();
await expect(vatNumberSwitch).toBeDisabled();
// Seller Email
await expect(
sellerForm.getByRole("textbox", { name: "Email" })
).toHaveAttribute("aria-readonly", "true");
await expect(
sellerForm.getByRole("textbox", { name: "Email" })
).toHaveValue(testData.email);
// Seller Account Number
await expect(
sellerForm.getByRole("textbox", { name: "Account Number" })
).toHaveAttribute("aria-readonly", "true");
await expect(
sellerForm.getByRole("textbox", { name: "Account Number" })
).toHaveValue(testData.accountNumber);
const accountNumberSwitch = sellerForm.getByTestId(
`sellerAccountNumberFieldIsVisible`
);
// Verify Account Number switch is visible
await expect(accountNumberSwitch).not.toBeChecked();
await expect(accountNumberSwitch).toBeDisabled();
// Seller SWIFT/BIC
await expect(
sellerForm.getByRole("textbox", { name: "SWIFT/BIC" })
).toHaveAttribute("aria-readonly", "true");
await expect(
sellerForm.getByRole("textbox", { name: "SWIFT/BIC" })
).toHaveValue(testData.swiftBic);
const swiftBicSwitch = sellerForm.getByTestId(
`sellerSwiftBicFieldIsVisible`
);
// Verify SWIFT/BIC switch is visible
await expect(swiftBicSwitch).not.toBeChecked();
await expect(swiftBicSwitch).toBeDisabled();
// Verify the seller appears in the dropdown
await expect(
sellerForm.getByRole("combobox", { name: "Select Seller" })
).toContainText(testData.name);
// Test edit functionality
await sellerForm.getByRole("button", { name: "Edit seller" }).click();
// Verify all fields are populated in edit dialog
await expect(
manageSellerDialog.getByRole("textbox", { name: "Name" })
).toHaveValue(testData.name);
await expect(
manageSellerDialog.getByRole("textbox", { name: "Address" })
).toHaveValue(testData.address);
await expect(
manageSellerDialog.getByRole("textbox", { name: "VAT Number" })
).toHaveValue(testData.vatNo);
await expect(
manageSellerDialog.getByRole("textbox", { name: "Email" })
).toHaveValue(testData.email);
await expect(
manageSellerDialog.getByRole("textbox", { name: "Account Number" })
).toHaveValue(testData.accountNumber);
await expect(
manageSellerDialog.getByRole("textbox", { name: "SWIFT/BIC" })
).toHaveValue(testData.swiftBic);
// Verify visibility switches state persisted in edit dialog
await expect(
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(0)
).toBeChecked();
await expect(
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(1)
).not.toBeChecked();
await expect(
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(2)
).not.toBeChecked();
});
test("delete seller", async ({ page }) => {
// First add a seller
await page.getByRole("button", { name: "New Seller" }).click();
const testData = {
name: "Test Delete Seller",
address: "123 Delete Street",
email: "delete@test.com",
vatNoFieldIsVisible: true,
vatNo: "123456789",
accountNumberFieldIsVisible: true,
accountNumber: "123456789",
swiftBicFieldIsVisible: true,
swiftBic: "123456789",
} as const satisfies SellerData;
const manageSellerDialog = page.getByTestId(`manage-seller-dialog`);
// Fill in basic seller details
await manageSellerDialog
.getByRole("textbox", { name: "Name" })
.fill(testData.name);
await manageSellerDialog
.getByRole("textbox", { name: "Address" })
.fill(testData.address);
await manageSellerDialog
.getByRole("textbox", { name: "Email" })
.fill(testData.email);
// Save seller
await manageSellerDialog
.getByRole("button", { name: "Save Seller" })
.click();
// Verify seller was added
const sellerForm = page.getByTestId(`seller-information-section`);
await expect(
sellerForm.getByRole("combobox", { name: "Select Seller" })
).toContainText(testData.name);
// Click delete button
await sellerForm.getByRole("button", { name: "Delete seller" }).click();
// Verify delete confirmation dialog appears
await expect(page.getByRole("alertdialog")).toBeVisible();
// Cancel button is shown
await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible();
// Click cancel button
await page.getByRole("button", { name: "Cancel" }).click();
// Verify dialog is closed
await expect(page.getByRole("alertdialog")).toBeHidden();
// Click delete button once again to open the dialog
await sellerForm.getByRole("button", { name: "Delete seller" }).click();
// Verify delete confirmation dialog appears
await expect(page.getByRole("alertdialog")).toBeVisible();
await expect(
page.getByText(
`Are you sure you want to delete "${testData.name}" seller?`
)
).toBeVisible();
// Confirm deletion
await page.getByRole("button", { name: "Delete" }).click();
// Verify success message
await expect(
page.getByText("Seller deleted successfully", { exact: true })
).toBeVisible();
// Verify seller is removed from dropdown
// because we have only one seller, dropdown will be completely hidden
await expect(
sellerForm.getByRole("combobox", { name: "Select Seller" })
).toBeHidden();
// Verify form is reset to default values
await expect(sellerForm.getByRole("textbox", { name: "Name" })).toHaveValue(
DEFAULT_SELLER_DATA.name
);
await expect(
sellerForm.getByRole("textbox", { name: "Address" })
).toHaveValue(DEFAULT_SELLER_DATA.address);
await expect(
sellerForm.getByRole("textbox", { name: "Email" })
).toHaveValue(DEFAULT_SELLER_DATA.email);
await expect(
sellerForm.getByRole("textbox", { name: "VAT Number" })
).toHaveValue(DEFAULT_SELLER_DATA.vatNo);
await expect(
sellerForm.getByRole("textbox", { name: "Account Number" })
).toHaveValue(DEFAULT_SELLER_DATA.accountNumber);
await expect(
sellerForm.getByRole("textbox", { name: "SWIFT/BIC" })
).toHaveValue(DEFAULT_SELLER_DATA.swiftBic);
});
});

70
eslint.config.mjs Normal file
View file

@ -0,0 +1,70 @@
// @ts-check
import { FlatCompat } from "@eslint/eslintrc";
import tseslint from "typescript-eslint";
import playwright from "eslint-plugin-playwright";
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});
export default tseslint.config(
{
ignores: [".next", "playwright-output"],
},
// next config
...compat.extends("next/core-web-vitals"),
{
files: ["**/*.ts", "**/*.tsx"],
extends: [
...tseslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
],
rules: {
"@typescript-eslint/array-type": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/consistent-type-imports": [
"warn",
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
],
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_" },
],
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false } },
],
"@typescript-eslint/prefer-nullish-coalescing": "off",
"@typescript-eslint/unbound-method": "off",
"@typescript-eslint/consistent-indexed-object-style": "off",
"@typescript-eslint/non-nullable-type-assertion-style": "off",
},
},
{
rules: {
"no-console": ["warn", { allow: ["error"] }],
},
},
// Playwright config for e2e tests only
{
...playwright.configs["flat/recommended"],
files: ["e2e/**"],
rules: {
...playwright.configs["flat/recommended"].rules,
// Customize Playwright rules
// ...
},
},
{
linterOptions: {
reportUnusedDisableDirectives: true,
},
languageOptions: {
parserOptions: {
projectService: true,
},
},
}
);

28
knip.ts Normal file
View file

@ -0,0 +1,28 @@
import type { KnipConfig } from "knip";
// https://knip.dev/reference/configuration#_top
const config: KnipConfig = {
ignoreDependencies: [
"@radix-ui/react-separator",
"@types/ua-parser-js",
"cmdk",
"eslint-plugin-react-hooks",
"file-saver",
"jszip",
"@next/eslint-plugin-next",
"@types/file-saver",
"eslint-config-next",
"@ianvs/prettier-plugin-sort-imports",
],
ignore: [
"lint-staged.config.js",
"src/app/components/invoice-pdf-download-multiple-languages.tsx",
"src/components/ui/**/*.tsx",
],
includeEntryExports: true,
// ignore tags
// https://knip.dev/reference/configuration#tags
tags: ["-@lintignore"],
};
export default config;

10
lint-staged.config.js Normal file
View file

@ -0,0 +1,10 @@
module.exports = {
// Run type-check on all changes to files
// https://github.com/okonet/lint-staged
"*": () => [
`pnpm run type-check`,
`pnpm run lint`,
`pnpm run knip`,
`pnpm run prettify --write`,
],
};

View file

@ -8,6 +8,14 @@ const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
compiler: {
removeConsole: process.env.VERCEL_ENV === "production",
},
logging: {
fetches: {
fullUrl: true,
},
},
async rewrites() {
return [
{

View file

@ -2,7 +2,7 @@
"name": "pdf-invoice-generator",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.5.0",
"packageManager": "pnpm@10.6.5",
"engines": {
"node": ">=20.0.0"
},
@ -10,11 +10,17 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"lint": "pnpm eslint . --cache",
"lint:fix": "pnpm eslint . --fix",
"prettify": "prettier --write --cache '**/*.{ts?(x),json,js,mjs,yml,yaml,md}'",
"knip": "knip",
"update-deps": "pnpm upgrade --interactive --latest",
"dedupe": "pnpm dedupe"
"e2e": "pnpm exec playwright test",
"e2e:ui": "pnpm exec playwright test --ui",
"dedupe": "pnpm dedupe",
"lint-stage": "npx lint-staged --verbose",
"prepare": "husky"
},
"dependencies": {
"@hookform/resolvers": "3.9.0",
@ -31,11 +37,13 @@
"@radix-ui/react-tooltip": "1.1.8",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "9.3.0",
"@types/ua-parser-js": "0.7.39",
"@vercel/speed-insights": "1.2.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.0.0",
"dayjs": "1.11.13",
"eslint-plugin-react-hooks": "5.2.0",
"file-saver": "2.0.5",
"jszip": "3.10.1",
"lucide-react": "0.477.0",
@ -43,26 +51,38 @@
"n2words": "1.21.0",
"next": "14.2.15",
"nuqs": "2.1.0",
"pdf-parse": "1.1.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hook-form": "7.53.1",
"sonner": "1.7.4",
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7",
"ua-parser-js": "2.0.3",
"use-debounce": "10.0.4",
"zod": "3.24.2"
},
"devDependencies": {
"@eslint/eslintrc": "3.3.1",
"@next/eslint-plugin-next": "15.2.3",
"@playwright/test": "1.51.1",
"@types/file-saver": "2.0.7",
"@types/node": "22.8.1",
"@types/pdf-parse": "1.1.4",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"eslint": "^8",
"eslint-config-next": "14.2.15",
"autoprefixer": "10.4.21",
"eslint": "9.23.0",
"eslint-config-next": "15.2.3",
"eslint-plugin-playwright": "2.2.0",
"husky": "9.1.7",
"knip": "5.46.0",
"lint-staged": "15.5.0",
"postcss": "^8",
"prettier": "3.5.3",
"prettier-plugin-tailwindcss": "0.6.11",
"tailwindcss": "3.4.14",
"typescript": "5.8.2"
"typescript": "5.8.2",
"typescript-eslint": "8.28.0"
}
}

94
playwright.config.ts Normal file
View file

@ -0,0 +1,94 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
// Use process.env.PORT by default and fallback to port 3000
const PORT = process.env.PORT ?? 3000;
// Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port
const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`;
const TIMEOUT = 70 * 1000;
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./e2e",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* timeout for expect assertions */
expect: {
timeout: TIMEOUT,
},
// /* timeout for test execution */
timeout: TIMEOUT,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [["html", { outputFolder: "playwright-output/report" }]],
/* Output directory for test artifacts */
outputDir: "playwright-output/test-results",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
// Use baseURL so to make navigations relative.
// More information: https://playwright.dev/docs/api/class-testoptions#test-options-base-url
baseURL: BASE_URL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
// {
// name: "webkit",
// use: { ...devices["Desktop Safari"] },
// },
// /* Test against mobile viewports. */
{
name: "Mobile Chrome",
use: { ...devices["Pixel 5"] },
},
{
name: "Mobile Safari",
use: { ...devices["iPhone 12"] },
},
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://127.0.0.1:3000',
// reuseExistingServer: !process.env.CI,
// },
});

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -14,7 +14,7 @@ Sentry.init({
// Recommended production settings
debug: false,
enabled: process.env.NODE_ENV === "production",
enabled: process.env.VERCEL_ENV === "production",
// Performance settings
replaysSessionSampleRate: 0.1, // Sample 10% of sessions

View file

@ -15,7 +15,7 @@ Sentry.init({
// Recommended production settings
debug: false,
enabled: process.env.NODE_ENV === "production",
enabled: process.env.VERCEL_ENV === "production",
// Performance settings
replaysSessionSampleRate: 0.1, // Sample 10% of sessions

View file

@ -14,7 +14,7 @@ Sentry.init({
// Recommended production settings
debug: false,
enabled: process.env.NODE_ENV === "production",
enabled: process.env.VERCEL_ENV === "production",
// Performance settings
replaysSessionSampleRate: 0.1, // Sample 10% of sessions

View file

@ -6,17 +6,9 @@ import dynamic from "next/dynamic";
import { InvoiceForm } from "./invoice-form";
import { InvoicePDFDownloadLink } from "./invoice-pdf-download-link";
import { InvoicePdfTemplate } from "./invoice-pdf-template";
import { RegenerateInvoiceButton } from "./regenerate-invoice-button";
import type { InvoiceData } from "../schema";
import { useState } from "react";
export const FORM_PREFIX_IDS = {
MOBILE: "mobile-invoice-form",
DESKTOP: "desktop-invoice-form",
} as const satisfies Record<string, string>;
export type FormPrefixId =
(typeof FORM_PREFIX_IDS)[keyof typeof FORM_PREFIX_IDS];
import { CustomTooltip } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
const InvoicePDFViewer = dynamic(
() => import("./invoice-pdf-viewer").then((mod) => mod.InvoicePDFViewer),
@ -35,7 +27,6 @@ const InvoicePDFViewer = dynamic(
);
const TABS_VALUES = ["invoice-form", "invoice-preview"] as const;
type TabValue = (typeof TABS_VALUES)[number];
const TAB_INVOICE_FORM = TABS_VALUES[0];
const TAB_INVOICE_PREVIEW = TABS_VALUES[1];
@ -43,90 +34,83 @@ const TAB_INVOICE_PREVIEW = TABS_VALUES[1];
export function InvoiceClientPage({
invoiceDataState,
handleInvoiceDataChange,
handleShareInvoice,
isMobile,
}: {
invoiceDataState: InvoiceData;
handleInvoiceDataChange: (invoiceData: InvoiceData) => void;
handleShareInvoice: () => void;
isMobile: boolean;
}) {
const [activeTab, setActiveTab] = useState<TabValue>(TAB_INVOICE_FORM);
return (
<>
{/* Mobile View with Tabs START */}
<div className="block w-full lg:hidden">
<Tabs
defaultValue={TAB_INVOICE_FORM}
className="w-full"
onValueChange={(value) => setActiveTab(value as TabValue)}
>
<TabsList className="w-full">
<TabsTrigger value={TAB_INVOICE_FORM} className="flex-1">
<span className="flex items-center gap-1">
<PencilIcon className="h-4 w-4" />
Edit Invoice
</span>
</TabsTrigger>
<TabsTrigger value={TAB_INVOICE_PREVIEW} className="flex-1">
<span className="flex items-center gap-1">
<FileTextIcon className="h-4 w-4" />
Preview PDF
</span>
</TabsTrigger>
</TabsList>
<TabsContent value={TAB_INVOICE_FORM} className="mt-4">
<div className="h-[400px] overflow-auto rounded-lg border-b px-3 shadow-sm">
<InvoiceForm
invoiceData={invoiceDataState}
onInvoiceDataChange={handleInvoiceDataChange}
formPrefixId={FORM_PREFIX_IDS.MOBILE}
/>
</div>
</TabsContent>
<TabsContent value={TAB_INVOICE_PREVIEW} className="mt-4">
<div className="h-[580px] w-full">
<InvoicePDFViewer>
<InvoicePdfTemplate invoiceData={invoiceDataState} />
</InvoicePDFViewer>
</div>
</TabsContent>
{/* Action buttons visible based on active tab */}
<div className="sticky bottom-0 mt-4 flex flex-col gap-3 rounded-lg border border-t border-gray-200 bg-white px-3 pt-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1),0_-2px_4px_-2px_rgba(0,0,0,0.05)]">
<RegenerateInvoiceButton
invoiceData={invoiceDataState}
formPrefixId={
// we need to pass the correct form prefix id based on the active tab, because on invoice preview tab, the mobile form is not rendered
activeTab === TAB_INVOICE_FORM
? FORM_PREFIX_IDS.MOBILE
: FORM_PREFIX_IDS.DESKTOP
{isMobile ? (
<div>
<Tabs defaultValue={TAB_INVOICE_FORM} className="w-full">
<TabsList className="w-full">
<TabsTrigger value={TAB_INVOICE_FORM} className="flex-1">
<span className="flex items-center gap-1">
<PencilIcon className="h-4 w-4" />
Edit Invoice
</span>
</TabsTrigger>
<TabsTrigger value={TAB_INVOICE_PREVIEW} className="flex-1">
<span className="flex items-center gap-1">
<FileTextIcon className="h-4 w-4" />
Preview PDF
</span>
</TabsTrigger>
</TabsList>
<TabsContent value={TAB_INVOICE_FORM} className="mt-1">
<div className="h-[460px] overflow-auto rounded-lg border-b px-3 shadow-sm">
<InvoiceForm
invoiceData={invoiceDataState}
onInvoiceDataChange={handleInvoiceDataChange}
/>
</div>
</TabsContent>
<TabsContent value={TAB_INVOICE_PREVIEW} className="mt-1">
<div className="h-[445px] w-full">
<InvoicePDFViewer>
<InvoicePdfTemplate invoiceData={invoiceDataState} />
</InvoicePDFViewer>
</div>
</TabsContent>
</Tabs>
<div className="sticky bottom-0 mt-2 flex flex-col items-center justify-center gap-3 rounded-lg border border-t border-gray-200 bg-white px-3 py-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1),0_-2px_4px_-2px_rgba(0,0,0,0.05)]">
<CustomTooltip
trigger={
<Button
onClick={handleShareInvoice}
_variant="outline"
className="mx-2 w-full"
>
Generate a link to invoice
</Button>
}
content="Generate a shareable link to this invoice. Share it with your clients to allow them to view the invoice online."
/>
<InvoicePDFDownloadLink invoiceData={invoiceDataState} />
</div>
</Tabs>
</div>
{/* Mobile View with Tabs END */}
{/* Desktop View - Side by Side START */}
<div className="hidden lg:col-span-4 lg:block">
<div className="h-[580px] overflow-auto px-3 pl-0">
<InvoiceForm
invoiceData={invoiceDataState}
onInvoiceDataChange={handleInvoiceDataChange}
formPrefixId={FORM_PREFIX_IDS.DESKTOP}
/>
</div>
<div className="flex flex-col gap-3 border-t border-gray-200 bg-white">
<RegenerateInvoiceButton
invoiceData={invoiceDataState}
formPrefixId={FORM_PREFIX_IDS.DESKTOP}
/>
</div>
</div>
<div className="hidden h-[580px] w-full max-w-full lg:col-span-8 lg:block">
<InvoicePDFViewer>
<InvoicePdfTemplate invoiceData={invoiceDataState} />
</InvoicePDFViewer>
</div>
{/* Desktop View - Side by Side END */}
) : (
// Desktop View
<>
<div className="col-span-4">
<div className="h-[620px] overflow-auto border-b px-3 pl-0">
<InvoiceForm
invoiceData={invoiceDataState}
onInvoiceDataChange={handleInvoiceDataChange}
/>
</div>
</div>
<div className="col-span-8 h-[620px] w-full max-w-full">
<InvoicePDFViewer>
<InvoicePdfTemplate invoiceData={invoiceDataState} />
</InvoicePDFViewer>
</div>
</>
)}
</>
);
}

View file

@ -5,6 +5,7 @@ import {
accordionSchema,
invoiceItemSchema,
invoiceSchema,
type AccordionState,
type InvoiceData,
type InvoiceItemData,
} from "@/app/schema";
@ -33,17 +34,15 @@ import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form";
import { toast } from "sonner";
import { useDebouncedCallback } from "use-debounce";
import { z } from "zod";
import type { FormPrefixId } from "..";
import { BuyerInformation } from "./sections/buyer-information";
import { GeneralInformation } from "./sections/general-information";
import { InvoiceItems } from "./sections/invoice-items";
import { SellerInformation } from "./sections/seller-information";
export const PDF_DATA_LOCAL_STORAGE_KEY = "EASY_INVOICE_PDF_DATA";
export const INVOICE_PDF_HTML_FORM_ID = "pdfInvoiceForm";
export const DEBOUNCE_TIMEOUT = 500;
export const LOADING_BUTTON_TIMEOUT = 400;
export const LOADING_BUTTON_TEXT = "Generating Document...";
const DEBOUNCE_TIMEOUT = 500;
const DEFAULT_ACCORDION_VALUES = [
"general",
@ -91,9 +90,14 @@ const ErrorMessage = ({ children }: { children: React.ReactNode }) => {
return <p className="mt-1 text-xs text-red-600">{children}</p>;
};
const Legend = ({ children }: { children: React.ReactNode }) => {
const Legend = ({
children,
...props
}: React.HTMLAttributes<HTMLLegendElement>) => {
return (
<legend className="text-lg font-semibold text-gray-900">{children}</legend>
<legend className="text-lg font-semibold text-gray-900" {...props}>
{children}
</legend>
);
};
@ -110,17 +114,11 @@ type Prettify<T> = {
interface InvoiceFormProps {
invoiceData: InvoiceData;
onInvoiceDataChange: (updatedData: InvoiceData) => void;
/**
* we need this to generate unique ids for the form fields for mobile and desktop views
* otherwise the ids will be the same and the form will not work correctly + accessibility issues
*/
formPrefixId: FormPrefixId;
}
export const InvoiceForm = memo(function InvoiceForm({
invoiceData,
onInvoiceDataChange,
formPrefixId,
}: InvoiceFormProps) {
const openPanel = useOpenPanel();
@ -211,9 +209,9 @@ export const InvoiceForm = memo(function InvoiceForm({
// regenerate pdf on every input change with debounce
const debouncedRegeneratePdfOnFormChange = useDebouncedCallback(
(data) => {
(data: InvoiceData) => {
// submit form e.g. regenerates pdf and run form validations
handleSubmit(onSubmit)(data);
void handleSubmit(onSubmit)(data as unknown as React.BaseSyntheticEvent);
// data should be already validated
const stringifiedData = JSON.stringify(data);
@ -233,7 +231,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);
debouncedRegeneratePdfOnFormChange(value as unknown as InvoiceData);
});
return () => subscription.unsubscribe();
@ -275,13 +273,12 @@ export const InvoiceForm = memo(function InvoiceForm({
);
if (savedState) {
const parsedState = JSON.parse(savedState);
const parsedState = JSON.parse(savedState) as AccordionState;
const validatedState = accordionSchema.safeParse(parsedState);
if (validatedState.success) {
const arrayOfOpenSections = Object.entries(validatedState.data)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([_, isOpen]) => isOpen)
.map(([section]) => section) as Prettify<AccordionKeys>;
@ -326,7 +323,6 @@ export const InvoiceForm = memo(function InvoiceForm({
return (
<form
id={formPrefixId}
className="mb-4 space-y-3.5"
onSubmit={handleSubmit(onSubmit, (errors) => {
console.error("Form validation errors:", errors);
@ -353,7 +349,7 @@ export const InvoiceForm = memo(function InvoiceForm({
if (Array.isArray(error)) {
return error.map((item, index) =>
Object.entries(
item as Record<string, { message?: string }>
item as { [key: string]: { message?: string } }
).map(([fieldName, fieldError]) => (
<li
key={`${key}.${index}.${fieldName}`}
@ -367,13 +363,15 @@ export const InvoiceForm = memo(function InvoiceForm({
// Handle nested object errors
if (error && typeof error === "object") {
return Object.entries(error).map(
([nestedKey, nestedError]) => (
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;
@ -397,6 +395,7 @@ export const InvoiceForm = memo(function InvoiceForm({
<AccordionItem
value={ACCORDION_GENERAL}
className="rounded-lg border shadow"
data-testid={`general-information-section`}
>
<AccordionTrigger className="px-4 py-3">
<Legend>General Information</Legend>
@ -406,7 +405,6 @@ export const InvoiceForm = memo(function InvoiceForm({
control={control}
errors={errors}
setValue={setValue}
formPrefixId={formPrefixId}
dateOfIssue={dateOfIssue}
/>
</AccordionContent>
@ -416,6 +414,7 @@ export const InvoiceForm = memo(function InvoiceForm({
<AccordionItem
value={ACCORDION_SELLER}
className="rounded-lg border shadow"
data-testid={`seller-information-section`}
>
<AccordionTrigger className="px-4 py-3">
<Legend>Seller Information</Legend>
@ -425,7 +424,6 @@ export const InvoiceForm = memo(function InvoiceForm({
control={control}
errors={errors}
setValue={setValue}
formPrefixId={formPrefixId}
invoiceData={invoiceData}
/>
</AccordionContent>
@ -435,6 +433,7 @@ export const InvoiceForm = memo(function InvoiceForm({
<AccordionItem
value={ACCORDION_BUYER}
className="rounded-lg border shadow"
data-testid={`buyer-information-section`}
>
<AccordionTrigger className="px-4 py-3">
<Legend>Buyer Information</Legend>
@ -444,7 +443,6 @@ export const InvoiceForm = memo(function InvoiceForm({
control={control}
errors={errors}
setValue={setValue}
formPrefixId={formPrefixId}
invoiceData={invoiceData}
/>
</AccordionContent>
@ -454,6 +452,7 @@ export const InvoiceForm = memo(function InvoiceForm({
<AccordionItem
value={ACCORDION_ITEMS}
className="rounded-lg border shadow"
data-testid={`invoice-items-section`}
>
<AccordionTrigger className="px-4 py-3">
<Legend>Invoice Items</Legend>
@ -461,7 +460,6 @@ export const InvoiceForm = memo(function InvoiceForm({
<AccordionContent className="px-4 pb-4">
<InvoiceItems
control={control}
formPrefixId={formPrefixId}
fields={fields}
handleRemoveItem={handleRemoveItem}
errors={errors}
@ -474,11 +472,11 @@ export const InvoiceForm = memo(function InvoiceForm({
</Accordion>
{/* Final section */}
<div className="space-y-4">
<div className="space-y-4" data-testid={`final-section`}>
<div className="">
{/* Total field (with currency) */}
<div className="mt-5" />
<Label htmlFor={`${formPrefixId}-total`} className="mb-1">
<Label htmlFor={`total`} className="mb-1">
Total
</Label>
<div className="relative mt-1 rounded-md shadow-sm">
@ -488,7 +486,7 @@ export const InvoiceForm = memo(function InvoiceForm({
render={({ field }) => (
<ReadOnlyMoneyInput
{...field}
id={`${formPrefixId}-total`}
id={`total`}
currency={currency}
value={field.value.toLocaleString("en-US", {
minimumFractionDigits: 2,
@ -511,7 +509,7 @@ export const InvoiceForm = memo(function InvoiceForm({
{/* Payment Method */}
<div>
<div className="relative mb-2 mt-6 flex items-center justify-between">
<Label htmlFor={`${formPrefixId}-paymentMethod`} className="">
<Label htmlFor={`paymentMethod`} className="">
Payment Method
</Label>
@ -523,7 +521,7 @@ export const InvoiceForm = memo(function InvoiceForm({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-paymentMethodFieldIsVisible`}
id={`paymentMethodFieldIsVisible`}
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"
@ -532,9 +530,7 @@ export const InvoiceForm = memo(function InvoiceForm({
/>
<CustomTooltip
trigger={
<Label
htmlFor={`${formPrefixId}-paymentMethodFieldIsVisible`}
>
<Label htmlFor={`paymentMethodFieldIsVisible`}>
Show in PDF
</Label>
}
@ -549,7 +545,7 @@ export const InvoiceForm = memo(function InvoiceForm({
render={({ field }) => (
<Input
{...field}
id={`${formPrefixId}-paymentMethod`}
id={`paymentMethod`}
type="text"
className="mt-1"
/>
@ -563,19 +559,14 @@ export const InvoiceForm = memo(function InvoiceForm({
{/* Payment Due */}
<div>
<div className="mb-6">
<Label htmlFor={`${formPrefixId}-paymentDue`} className="mb-1">
<Label htmlFor={`paymentDue`} className="mb-1">
Payment Due
</Label>
<Controller
name="paymentDue"
control={control}
render={({ field }) => (
<Input
{...field}
id={`${formPrefixId}-paymentDue`}
type="date"
className=""
/>
<Input {...field} id={`paymentDue`} type="date" className="" />
)}
/>
{errors.paymentDue && (
@ -626,7 +617,7 @@ export const InvoiceForm = memo(function InvoiceForm({
{/* Notes */}
<div className="">
<div className="relative mb-2 flex items-center justify-between">
<Label htmlFor={`${formPrefixId}-notes`} className="">
<Label htmlFor={`notes`} className="">
Notes
</Label>
@ -638,7 +629,7 @@ export const InvoiceForm = memo(function InvoiceForm({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-notesFieldIsVisible`}
id={`notesFieldIsVisible`}
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"
@ -647,9 +638,7 @@ export const InvoiceForm = memo(function InvoiceForm({
/>
<CustomTooltip
trigger={
<Label htmlFor={`${formPrefixId}-notesFieldIsVisible`}>
Show in PDF
</Label>
<Label htmlFor={`notesFieldIsVisible`}>Show in PDF</Label>
}
content='Show/Hide the "Notes" Field in the PDF'
/>
@ -661,9 +650,10 @@ export const InvoiceForm = memo(function InvoiceForm({
render={({ field }) => (
<Textarea
{...field}
id={`${formPrefixId}-notes`}
id={`notes`}
rows={3}
className=""
data-testid="notes"
/>
)}
/>
@ -676,9 +666,7 @@ export const InvoiceForm = memo(function InvoiceForm({
<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">
<Label
htmlFor={`${formPrefixId}-personAuthorizedToReceiveFieldIsVisible`}
>
<Label htmlFor={`personAuthorizedToReceiveFieldIsVisible`}>
Show &quot;Person Authorized to Receive&quot; Signature Field in
the PDF
</Label>
@ -689,7 +677,7 @@ export const InvoiceForm = memo(function InvoiceForm({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-personAuthorizedToReceiveFieldIsVisible`}
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"
@ -700,9 +688,7 @@ export const InvoiceForm = memo(function InvoiceForm({
{/* Show/hide Person Authorized to Issue field in PDF switch */}
<div className="flex items-center justify-between">
<Label
htmlFor={`${formPrefixId}-personAuthorizedToIssueFieldIsVisible`}
>
<Label htmlFor={`personAuthorizedToIssueFieldIsVisible`}>
Show &quot;Person Authorized to Issue&quot; Signature Field in
the PDF
</Label>
@ -713,7 +699,7 @@ export const InvoiceForm = memo(function InvoiceForm({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-personAuthorizedToIssueFieldIsVisible`}
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"

View file

@ -1,10 +1,10 @@
import {
Control,
type Control,
Controller,
FieldErrors,
UseFormSetValue,
type FieldErrors,
type UseFormSetValue,
} from "react-hook-form";
import { InvoiceData, type BuyerData } from "@/app/schema";
import { type InvoiceData, type BuyerData } from "@/app/schema";
import { BuyerManagement } from "@/components/buyer-management";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -12,7 +12,6 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { CustomTooltip } from "@/components/ui/tooltip";
import { memo, useState } from "react";
import type { FormPrefixId } from "../..";
import { LabelWithEditIcon } from "@/components/label-with-edit-icon";
const ErrorMessage = ({ children }: { children: React.ReactNode }) => {
@ -20,13 +19,12 @@ const ErrorMessage = ({ children }: { children: React.ReactNode }) => {
};
const BUYER_TOOLTIP_CONTENT =
"Click the edit button next to the 'Select Buyer' dropdown to modify buyer details. Any changes will be automatically saved.";
"Buyer details are locked. Click the edit button next to the 'Select Buyer' dropdown to modify buyer details. Any changes will be automatically saved.";
interface BuyerInformationProps {
control: Control<InvoiceData>;
errors: FieldErrors<InvoiceData>;
setValue: UseFormSetValue<InvoiceData>;
formPrefixId: FormPrefixId;
invoiceData: InvoiceData;
}
@ -34,12 +32,15 @@ export const BuyerInformation = memo(function BuyerInformation({
control,
errors,
setValue,
formPrefixId,
invoiceData,
}: BuyerInformationProps) {
const [selectedBuyerId, setSelectedBuyerId] = useState("");
const isBuyerSelected = !!selectedBuyerId;
const HTML_TITLE_CONTENT = isBuyerSelected
? "Buyer details are locked. Click the edit buyer button to modify."
: "";
// Get current form values to pass to BuyerManagement
const currentFormValues = {
name: invoiceData.buyer.name,
@ -64,13 +65,13 @@ export const BuyerInformation = memo(function BuyerInformation({
<div>
{isBuyerSelected ? (
<LabelWithEditIcon
htmlFor={`${formPrefixId}-buyerName`}
htmlFor={`buyerName`}
content={BUYER_TOOLTIP_CONTENT}
>
Name
</LabelWithEditIcon>
) : (
<Label htmlFor={`${formPrefixId}-buyerName`} className="mb-1">
<Label htmlFor={`buyerName`} className="mb-1">
Name
</Label>
)}
@ -80,9 +81,12 @@ export const BuyerInformation = memo(function BuyerInformation({
render={({ field }) => (
<Textarea
{...field}
id={`${formPrefixId}-buyerName`}
id={`buyerName`}
rows={3}
className=""
readOnly={isBuyerSelected}
aria-readonly={isBuyerSelected}
title={HTML_TITLE_CONTENT}
/>
)}
/>
@ -94,13 +98,13 @@ export const BuyerInformation = memo(function BuyerInformation({
<div>
{isBuyerSelected ? (
<LabelWithEditIcon
htmlFor={`${formPrefixId}-buyerAddress`}
htmlFor={`buyerAddress`}
content={BUYER_TOOLTIP_CONTENT}
>
Address
</LabelWithEditIcon>
) : (
<Label htmlFor={`${formPrefixId}-buyerAddress`} className="mb-1">
<Label htmlFor={`buyerAddress`} className="mb-1">
Address
</Label>
)}
@ -110,9 +114,12 @@ export const BuyerInformation = memo(function BuyerInformation({
render={({ field }) => (
<Textarea
{...field}
id={`${formPrefixId}-buyerAddress`}
id={`buyerAddress`}
rows={3}
className=""
readOnly={isBuyerSelected}
aria-readonly={isBuyerSelected}
title={HTML_TITLE_CONTENT}
/>
)}
/>
@ -125,34 +132,39 @@ export const BuyerInformation = memo(function BuyerInformation({
<div className="relative mb-2 flex items-center justify-between">
{isBuyerSelected ? (
<LabelWithEditIcon
htmlFor={`${formPrefixId}-buyerVatNo`}
htmlFor={`buyerVatNo`}
content={BUYER_TOOLTIP_CONTENT}
>
VAT Number
</LabelWithEditIcon>
) : (
<Label htmlFor={`${formPrefixId}-buyerVatNo`} className="">
<Label htmlFor={`buyerVatNo`} className="">
VAT Number
</Label>
)}
<div className="inline-flex items-center gap-2">
<div
className="inline-flex items-center gap-2"
title={HTML_TITLE_CONTENT}
>
<Controller
name={`buyer.vatNoFieldIsVisible`}
control={control}
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-buyerVatNoFieldIsVisible`}
id={`buyerVatNoFieldIsVisible`}
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"
disabled={isBuyerSelected}
data-testid={`buyerVatNoFieldIsVisible`}
/>
)}
/>
<CustomTooltip
trigger={
<Label htmlFor={`${formPrefixId}-buyerVatNoFieldIsVisible`}>
<Label htmlFor={`buyerVatNoFieldIsVisible`}>
Show in PDF
</Label>
}
@ -170,9 +182,12 @@ export const BuyerInformation = memo(function BuyerInformation({
render={({ field }) => (
<Input
{...field}
id={`${formPrefixId}-buyerVatNo`}
id={`buyerVatNo`}
type="text"
className=""
readOnly={isBuyerSelected}
aria-readonly={isBuyerSelected}
title={HTML_TITLE_CONTENT}
/>
)}
/>
@ -184,13 +199,13 @@ export const BuyerInformation = memo(function BuyerInformation({
<div>
{isBuyerSelected ? (
<LabelWithEditIcon
htmlFor={`${formPrefixId}-buyerEmail`}
htmlFor={`buyerEmail`}
content={BUYER_TOOLTIP_CONTENT}
>
Email
</LabelWithEditIcon>
) : (
<Label htmlFor={`${formPrefixId}-buyerEmail`} className="mb-1">
<Label htmlFor={`buyerEmail`} className="mb-1">
Email
</Label>
)}
@ -200,9 +215,12 @@ export const BuyerInformation = memo(function BuyerInformation({
render={({ field }) => (
<Input
{...field}
id={`${formPrefixId}-buyerEmail`}
id={`buyerEmail`}
type="email"
className=""
readOnly={isBuyerSelected}
aria-readonly={isBuyerSelected}
title={HTML_TITLE_CONTENT}
/>
)}
/>

View file

@ -1,11 +1,11 @@
import {
Control,
type Control,
Controller,
FieldErrors,
UseFormSetValue,
type FieldErrors,
type UseFormSetValue,
useWatch,
} from "react-hook-form";
import { InvoiceData } from "@/app/schema";
import { type InvoiceData } from "@/app/schema";
import {
SUPPORTED_CURRENCIES,
SUPPORTED_DATE_FORMATS,
@ -23,7 +23,6 @@ import { CustomTooltip } from "@/components/ui/tooltip";
import dayjs from "dayjs";
import { AlertTriangle } from "lucide-react";
import { memo } from "react";
import type { FormPrefixId } from "../..";
const AlertIcon = () => {
return <AlertTriangle className="mr-1 inline-block h-3 w-3 text-amber-500" />;
@ -37,7 +36,6 @@ interface GeneralInformationProps {
control: Control<InvoiceData>;
errors: FieldErrors<InvoiceData>;
setValue: UseFormSetValue<InvoiceData>;
formPrefixId: FormPrefixId;
dateOfIssue: string;
}
@ -45,7 +43,6 @@ export const GeneralInformation = memo(function GeneralInformation({
control,
errors,
setValue,
formPrefixId,
dateOfIssue,
}: GeneralInformationProps) {
const invoiceNumber = useWatch({ control, name: "invoiceNumber" });
@ -67,18 +64,14 @@ export const GeneralInformation = memo(function GeneralInformation({
<div className="space-y-4">
{/* Language PDF Select */}
<div>
<Label htmlFor={`${formPrefixId}-language`} className="mb-1">
<Label htmlFor={`language`} className="mb-1">
Invoice PDF Language
</Label>
<Controller
name="language"
control={control}
render={({ field }) => (
<SelectNative
{...field}
id={`${formPrefixId}-language`}
className="block"
>
<SelectNative {...field} id={`language`} className="block">
{SUPPORTED_LANGUAGES.map((lang) => (
<option key={lang} value={lang}>
{lang === "en" ? "English" : "Polish"}
@ -98,7 +91,7 @@ export const GeneralInformation = memo(function GeneralInformation({
{/* Currency Select */}
<div>
<Label htmlFor={`${formPrefixId}-currency`} className="mb-1">
<Label htmlFor={`currency`} className="mb-1">
Currency
</Label>
<Controller
@ -106,11 +99,7 @@ export const GeneralInformation = memo(function GeneralInformation({
control={control}
render={({ field }) => {
return (
<SelectNative
{...field}
id={`${formPrefixId}-currency`}
className="block"
>
<SelectNative {...field} id={`currency`} className="block">
{SUPPORTED_CURRENCIES.map((currency) => {
const currencySymbol = CURRENCY_SYMBOLS[currency] || null;
@ -140,18 +129,14 @@ export const GeneralInformation = memo(function GeneralInformation({
{/* Date Format */}
<div>
<Label htmlFor={`${formPrefixId}-dateFormat`} className="mb-1">
<Label htmlFor={`dateFormat`} className="mb-1">
Date Format
</Label>
<Controller
name="dateFormat"
control={control}
render={({ field }) => (
<SelectNative
{...field}
id={`${formPrefixId}-dateFormat`}
className="block"
>
<SelectNative {...field} id={`dateFormat`} className="block">
{SUPPORTED_DATE_FORMATS.map((format) => {
const preview = dayjs().format(format);
const isDefault = format === SUPPORTED_DATE_FORMATS[0];
@ -177,7 +162,7 @@ export const GeneralInformation = memo(function GeneralInformation({
{/* Invoice Number */}
<div>
<Label htmlFor={`${formPrefixId}-invoiceNumber`} className="mb-1">
<Label htmlFor={`invoiceNumber`} className="mb-1">
Invoice Number
</Label>
<Controller
@ -187,7 +172,7 @@ export const GeneralInformation = memo(function GeneralInformation({
<Input
{...field}
type="text"
id={`${formPrefixId}-invoiceNumber`}
id={`invoiceNumber`}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm"
/>
)}
@ -217,19 +202,14 @@ export const GeneralInformation = memo(function GeneralInformation({
{/* Date of Issue */}
<div>
<Label htmlFor={`${formPrefixId}-dateOfIssue`} className="mb-1">
<Label htmlFor={`dateOfIssue`} className="mb-1">
Date of Issue
</Label>
<Controller
name="dateOfIssue"
control={control}
render={({ field }) => (
<Input
{...field}
type="date"
id={`${formPrefixId}-dateOfIssue`}
className=""
/>
<Input {...field} type="date" id={`dateOfIssue`} className="" />
)}
/>
{errors.dateOfIssue && (
@ -257,19 +237,14 @@ export const GeneralInformation = memo(function GeneralInformation({
{/* Date of Service */}
<div>
<Label htmlFor={`${formPrefixId}-dateOfService`} className="mb-1">
<Label htmlFor={`dateOfService`} className="mb-1">
Date of Service
</Label>
<Controller
name="dateOfService"
control={control}
render={({ field }) => (
<Input
{...field}
type="date"
id={`${formPrefixId}-dateOfService`}
className=""
/>
<Input {...field} type="date" id={`dateOfService`} className="" />
)}
/>
{errors.dateOfService && (
@ -302,7 +277,7 @@ export const GeneralInformation = memo(function GeneralInformation({
{/* Invoice Type */}
<div>
<div className="relative mb-2 flex items-center justify-between">
<Label htmlFor={`${formPrefixId}-invoiceType`} className="">
<Label htmlFor={`invoiceType`} className="">
Invoice Type
</Label>
@ -314,7 +289,7 @@ export const GeneralInformation = memo(function GeneralInformation({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-invoiceTypeFieldIsVisible`}
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"
@ -323,9 +298,7 @@ export const GeneralInformation = memo(function GeneralInformation({
/>
<CustomTooltip
trigger={
<Label htmlFor={`${formPrefixId}-invoiceTypeFieldIsVisible`}>
Show in PDF
</Label>
<Label htmlFor={`invoiceTypeFieldIsVisible`}>Show in PDF</Label>
}
content='Show/Hide the "Invoice Type" Field in the PDF'
/>
@ -338,7 +311,7 @@ export const GeneralInformation = memo(function GeneralInformation({
render={({ field }) => (
<Textarea
{...field}
id={`${formPrefixId}-invoiceType`}
id={`invoiceType`}
rows={2}
className=""
placeholder="Enter invoice type"

View file

@ -1,5 +1,5 @@
import {
InvoiceData,
type InvoiceData,
type SupportedCurrencies,
type SupportedLanguages,
} from "@/app/schema";
@ -7,13 +7,12 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { memo } from "react";
import {
Control,
type Control,
Controller,
type FieldArrayWithId,
type FieldErrors,
type UseFieldArrayAppend,
} from "react-hook-form";
import type { FormPrefixId } from "../..";
import { Input } from "@/components/ui/input";
import { InputHelperMessage } from "@/components/ui/input-helper-message";
@ -37,7 +36,6 @@ const ErrorMessage = ({ children }: { children: React.ReactNode }) => {
interface InvoiceItemsSettingsProps {
control: Control<InvoiceData>;
formPrefixId: FormPrefixId;
fields: FieldArrayWithId<InvoiceData, "items", "id">[];
handleRemoveItem: (index: number) => void;
append: UseFieldArrayAppend<InvoiceData, "items">;
@ -48,7 +46,6 @@ interface InvoiceItemsSettingsProps {
export const InvoiceItems = memo(function InvoiceItems({
control,
formPrefixId,
fields,
handleRemoveItem,
errors,
@ -57,12 +54,13 @@ export const InvoiceItems = memo(function InvoiceItems({
append,
}: InvoiceItemsSettingsProps) {
const openPanel = useOpenPanel();
return (
<>
<div className="mb-3 space-y-4">
{/* Show Number column on PDF switch */}
<div className="relative flex items-center justify-between">
<Label htmlFor={`${formPrefixId}-itemInvoiceItemNumberIsVisible0`}>
<Label htmlFor={`itemInvoiceItemNumberIsVisible0`}>
Show &quot;Number&quot; Column in the Invoice Items Table
</Label>
@ -72,7 +70,7 @@ export const InvoiceItems = memo(function InvoiceItems({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-itemInvoiceItemNumberIsVisible0`}
id={`itemInvoiceItemNumberIsVisible0`}
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"
@ -83,7 +81,7 @@ export const InvoiceItems = memo(function InvoiceItems({
{/* Show VAT Table Summary in PDF switch */}
<div className="relative flex items-center justify-between">
<Label htmlFor={`${formPrefixId}-vatTableSummaryIsVisible`}>
<Label htmlFor={`vatTableSummaryIsVisible`}>
Show &quot;VAT Table Summary&quot; in the PDF
</Label>
@ -93,7 +91,7 @@ export const InvoiceItems = memo(function InvoiceItems({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-vatTableSummaryIsVisible`}
id={`vatTableSummaryIsVisible`}
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"
@ -121,6 +119,9 @@ export const InvoiceItems = memo(function InvoiceItems({
onClick={() => handleRemoveItem(index)}
className="flex items-center justify-center rounded-full bg-red-600 p-2 transition-colors hover:bg-red-700"
>
<span className="sr-only">
Delete Invoice Item {index + 1}
</span>
<Trash2 className="h-4 w-4 text-white" />
</button>
}
@ -133,10 +134,7 @@ export const InvoiceItems = memo(function InvoiceItems({
<div>
{/* Invoice Item Name */}
<div className="mb-2 flex items-center justify-between">
<Label
htmlFor={`${formPrefixId}-itemName${index}`}
className=""
>
<Label htmlFor={`itemName${index}`} className="">
Name
</Label>
@ -149,7 +147,7 @@ export const InvoiceItems = memo(function InvoiceItems({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-itemNameFieldIsVisible${index}`}
id={`itemNameFieldIsVisible${index}`}
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"
@ -158,9 +156,7 @@ export const InvoiceItems = memo(function InvoiceItems({
/>
<CustomTooltip
trigger={
<Label
htmlFor={`${formPrefixId}-itemNameFieldIsVisible${index}`}
>
<Label htmlFor={`itemNameFieldIsVisible${index}`}>
Show in PDF
</Label>
}
@ -178,7 +174,7 @@ export const InvoiceItems = memo(function InvoiceItems({
<Textarea
{...field}
rows={4}
id={`${formPrefixId}-itemName${index}`}
id={`itemName${index}`}
className=""
/>
)}
@ -193,10 +189,7 @@ export const InvoiceItems = memo(function InvoiceItems({
{/* Invoice Item Type of GTU */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label
htmlFor={`${formPrefixId}-itemTypeOfGTU${index}`}
className=""
>
<Label htmlFor={`itemTypeOfGTU${index}`} className="">
Type of GTU
</Label>
@ -209,7 +202,7 @@ export const InvoiceItems = memo(function InvoiceItems({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-itemTypeOfGTUFieldIsVisible${index}`}
id={`itemTypeOfGTUFieldIsVisible${index}`}
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"
@ -219,7 +212,7 @@ export const InvoiceItems = memo(function InvoiceItems({
<CustomTooltip
trigger={
<Label
htmlFor={`${formPrefixId}-itemTypeOfGTUFieldIsVisible${index}`}
htmlFor={`itemTypeOfGTUFieldIsVisible${index}`}
>
Show in PDF
</Label>
@ -237,7 +230,7 @@ export const InvoiceItems = memo(function InvoiceItems({
render={({ field }) => (
<Input
{...field}
id={`${formPrefixId}-itemTypeOfGTU${index}`}
id={`itemTypeOfGTU${index}`}
className=""
type="text"
/>
@ -253,10 +246,7 @@ export const InvoiceItems = memo(function InvoiceItems({
{/* Invoice Item Amount */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label
htmlFor={`${formPrefixId}-itemAmount${index}`}
className=""
>
<Label htmlFor={`itemAmount${index}`} className="">
Amount
</Label>
@ -269,7 +259,7 @@ export const InvoiceItems = memo(function InvoiceItems({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-itemAmountFieldIsVisible${index}`}
id={`itemAmountFieldIsVisible${index}`}
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"
@ -278,9 +268,7 @@ export const InvoiceItems = memo(function InvoiceItems({
/>
<CustomTooltip
trigger={
<Label
htmlFor={`${formPrefixId}-itemAmountFieldIsVisible${index}`}
>
<Label htmlFor={`itemAmountFieldIsVisible${index}`}>
Show in PDF
</Label>
}
@ -306,7 +294,7 @@ export const InvoiceItems = memo(function InvoiceItems({
<>
<Input
{...field}
id={`${formPrefixId}-itemAmount${index}`}
id={`itemAmount${index}`}
type="number"
step="0.01"
min="0"
@ -331,10 +319,7 @@ export const InvoiceItems = memo(function InvoiceItems({
{/* Invoice Item Unit */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label
htmlFor={`${formPrefixId}-itemUnit${index}`}
className=""
>
<Label htmlFor={`itemUnit${index}`} className="">
Unit
</Label>
@ -347,7 +332,7 @@ export const InvoiceItems = memo(function InvoiceItems({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-itemUnitFieldIsVisible${index}`}
id={`itemUnitFieldIsVisible${index}`}
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"
@ -356,9 +341,7 @@ export const InvoiceItems = memo(function InvoiceItems({
/>
<CustomTooltip
trigger={
<Label
htmlFor={`${formPrefixId}-itemUnitFieldIsVisible${index}`}
>
<Label htmlFor={`itemUnitFieldIsVisible${index}`}>
Show in PDF
</Label>
}
@ -373,11 +356,7 @@ export const InvoiceItems = memo(function InvoiceItems({
name={`items.${index}.unit`}
control={control}
render={({ field }) => (
<Input
{...field}
id={`${formPrefixId}-itemUnit${index}`}
type="text"
/>
<Input {...field} id={`itemUnit${index}`} type="text" />
)}
/>
{errors.items?.[index]?.unit && (
@ -390,10 +369,7 @@ export const InvoiceItems = memo(function InvoiceItems({
{/* Invoice Item Net Price */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label
htmlFor={`${formPrefixId}-itemNetPrice${index}`}
className=""
>
<Label htmlFor={`itemNetPrice${index}`} className="">
Net Price
</Label>
@ -406,7 +382,7 @@ export const InvoiceItems = memo(function InvoiceItems({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-itemNetPriceFieldIsVisible${index}`}
id={`itemNetPriceFieldIsVisible${index}`}
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"
@ -415,9 +391,7 @@ export const InvoiceItems = memo(function InvoiceItems({
/>
<CustomTooltip
trigger={
<Label
htmlFor={`${formPrefixId}-itemNetPriceFieldIsVisible${index}`}
>
<Label htmlFor={`itemNetPriceFieldIsVisible${index}`}>
Show in PDF
</Label>
}
@ -455,12 +429,13 @@ export const InvoiceItems = memo(function InvoiceItems({
<div className="flex w-full flex-col">
<MoneyInput
{...field}
id={`${formPrefixId}-itemNetPrice${index}`}
id={`itemNetPrice${index}`}
currency={currency}
type="number"
step="0.01"
min="0"
className="w-full"
dataTestId={`itemNetPrice${index}`}
/>
{!errors.items?.[index]?.netPrice && (
<InputHelperMessage>
@ -474,6 +449,7 @@ export const InvoiceItems = memo(function InvoiceItems({
}}
/>
</div>
{errors.items?.[index]?.netPrice && (
<ErrorMessage>
{errors.items[index].netPrice.message}
@ -484,10 +460,7 @@ export const InvoiceItems = memo(function InvoiceItems({
{/* Invoice Item VAT */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label
htmlFor={`${formPrefixId}-itemVat${index}`}
className=""
>
<Label htmlFor={`itemVat${index}`} className="">
VAT
</Label>
@ -500,7 +473,7 @@ export const InvoiceItems = memo(function InvoiceItems({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-itemVatFieldIsVisible${index}`}
id={`itemVatFieldIsVisible${index}`}
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"
@ -509,9 +482,7 @@ export const InvoiceItems = memo(function InvoiceItems({
/>
<CustomTooltip
trigger={
<Label
htmlFor={`${formPrefixId}-itemVatFieldIsVisible${index}`}
>
<Label htmlFor={`itemVatFieldIsVisible${index}`}>
Show in PDF
</Label>
}
@ -528,7 +499,7 @@ export const InvoiceItems = memo(function InvoiceItems({
render={({ field }) => (
<Input
{...field}
id={`${formPrefixId}-itemVat${index}`}
id={`itemVat${index}`}
type="text"
className=""
/>
@ -547,10 +518,7 @@ export const InvoiceItems = memo(function InvoiceItems({
{/* Invoice Item Net Amount */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label
htmlFor={`${formPrefixId}-itemNetAmount${index}`}
className=""
>
<Label htmlFor={`itemNetAmount${index}`} className="">
Net Amount
</Label>
@ -563,7 +531,7 @@ export const InvoiceItems = memo(function InvoiceItems({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-itemNetAmountFieldIsVisible${index}`}
id={`itemNetAmountFieldIsVisible${index}`}
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"
@ -573,7 +541,7 @@ export const InvoiceItems = memo(function InvoiceItems({
<CustomTooltip
trigger={
<Label
htmlFor={`${formPrefixId}-itemNetAmountFieldIsVisible${index}`}
htmlFor={`itemNetAmountFieldIsVisible${index}`}
>
Show in PDF
</Label>
@ -592,12 +560,13 @@ export const InvoiceItems = memo(function InvoiceItems({
return (
<ReadOnlyMoneyInput
{...field}
id={`${formPrefixId}-itemNetAmount${index}`}
id={`itemNetAmount${index}`}
currency={currency}
value={field.value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
dataTestId={`itemNetAmount${index}`}
/>
);
}}
@ -617,10 +586,7 @@ export const InvoiceItems = memo(function InvoiceItems({
{/* Invoice Item VAT Amount (calculated automatically) */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label
htmlFor={`${formPrefixId}-itemVatAmount${index}`}
className=""
>
<Label htmlFor={`itemVatAmount${index}`} className="">
VAT Amount
</Label>
@ -633,7 +599,7 @@ export const InvoiceItems = memo(function InvoiceItems({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-itemVatAmountFieldIsVisible${index}`}
id={`itemVatAmountFieldIsVisible${index}`}
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"
@ -643,7 +609,7 @@ export const InvoiceItems = memo(function InvoiceItems({
<CustomTooltip
trigger={
<Label
htmlFor={`${formPrefixId}-itemVatAmountFieldIsVisible${index}`}
htmlFor={`itemVatAmountFieldIsVisible${index}`}
>
Show in PDF
</Label>
@ -658,17 +624,20 @@ export const InvoiceItems = memo(function InvoiceItems({
<Controller
name={`items.${index}.vatAmount`}
control={control}
render={({ field }) => (
<ReadOnlyMoneyInput
{...field}
id={`${formPrefixId}-itemVatAmount${index}`}
currency={currency}
value={field.value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
/>
)}
render={({ field }) => {
return (
<ReadOnlyMoneyInput
{...field}
id={`itemVatAmount${index}`}
currency={currency}
value={field.value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
data-testid="vat-amount"
/>
);
}}
/>
{errors.items?.[index]?.vatAmount ? (
@ -685,10 +654,7 @@ export const InvoiceItems = memo(function InvoiceItems({
{/* Pre-tax Amount field */}
<div>
<div className="mb-2 flex items-center justify-between">
<Label
htmlFor={`${formPrefixId}-itemPreTaxAmount${index}`}
className=""
>
<Label htmlFor={`itemPreTaxAmount${index}`} className="">
Pre-tax Amount
</Label>
@ -701,7 +667,7 @@ export const InvoiceItems = memo(function InvoiceItems({
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-itemPreTaxAmountFieldIsVisible${index}`}
id={`itemPreTaxAmountFieldIsVisible${index}`}
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"
@ -711,7 +677,7 @@ export const InvoiceItems = memo(function InvoiceItems({
<CustomTooltip
trigger={
<Label
htmlFor={`${formPrefixId}-itemPreTaxAmountFieldIsVisible${index}`}
htmlFor={`itemPreTaxAmountFieldIsVisible${index}`}
>
Show in PDF
</Label>
@ -726,17 +692,19 @@ export const InvoiceItems = memo(function InvoiceItems({
<Controller
name={`items.${index}.preTaxAmount`}
control={control}
render={({ field }) => (
<ReadOnlyMoneyInput
{...field}
id={`${formPrefixId}-itemPreTaxAmount${index}`}
currency={currency}
value={field.value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
/>
)}
render={({ field }) => {
return (
<ReadOnlyMoneyInput
{...field}
id={`itemPreTaxAmount${index}`}
currency={currency}
value={field.value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
/>
);
}}
/>
{errors.items?.[index]?.preTaxAmount ? (

View file

@ -1,10 +1,10 @@
import {
Control,
type Control,
Controller,
FieldErrors,
UseFormSetValue,
type FieldErrors,
type UseFormSetValue,
} from "react-hook-form";
import { InvoiceData, type SellerData } from "@/app/schema";
import { type InvoiceData, type SellerData } from "@/app/schema";
import { SellerManagement } from "@/components/seller-management";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -12,7 +12,6 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { CustomTooltip } from "@/components/ui/tooltip";
import { memo, useState } from "react";
import type { FormPrefixId } from "../..";
import { LabelWithEditIcon } from "@/components/label-with-edit-icon";
const ErrorMessage = ({ children }: { children: React.ReactNode }) => {
@ -20,13 +19,12 @@ const ErrorMessage = ({ children }: { children: React.ReactNode }) => {
};
const SELLER_TOOLTIP_CONTENT =
"Click the edit button next to the 'Select Seller' dropdown to modify seller details. Any changes will be automatically saved.";
"Seller details are locked. Click the edit button next to the 'Select Seller' dropdown to modify seller details. Any changes will be automatically saved.";
interface SellerInformationProps {
control: Control<InvoiceData>;
errors: FieldErrors<InvoiceData>;
setValue: UseFormSetValue<InvoiceData>;
formPrefixId: FormPrefixId;
invoiceData: InvoiceData;
}
@ -34,12 +32,15 @@ export const SellerInformation = memo(function SellerInformation({
control,
errors,
setValue,
formPrefixId,
invoiceData,
}: SellerInformationProps) {
const [selectedSellerId, setSelectedSellerId] = useState("");
const isSellerSelected = !!selectedSellerId;
const HTML_TITLE_CONTENT = isSellerSelected
? "Seller details are locked. Click the edit seller button to modify."
: "";
// Get current form values to pass to SellerManagement
const currentFormValues = {
name: invoiceData.seller.name,
@ -64,17 +65,17 @@ export const SellerInformation = memo(function SellerInformation({
formValues={currentFormValues}
/>
</div>
<fieldset className="mt-5 space-y-4" disabled={isSellerSelected}>
<fieldset className="mt-5 space-y-4">
<div>
{isSellerSelected ? (
<LabelWithEditIcon
htmlFor={`${formPrefixId}-sellerName`}
htmlFor={`sellerName`}
content={SELLER_TOOLTIP_CONTENT}
>
Name
</LabelWithEditIcon>
) : (
<Label htmlFor={`${formPrefixId}-sellerName`} className="mb-1">
<Label htmlFor={`sellerName`} className="mb-1">
Name
</Label>
)}
@ -84,9 +85,12 @@ export const SellerInformation = memo(function SellerInformation({
render={({ field }) => (
<Textarea
{...field}
id={`${formPrefixId}-sellerName`}
id={`sellerName`}
rows={3}
className=""
readOnly={isSellerSelected}
aria-readonly={isSellerSelected}
title={HTML_TITLE_CONTENT}
/>
)}
/>
@ -98,13 +102,13 @@ export const SellerInformation = memo(function SellerInformation({
<div>
{isSellerSelected ? (
<LabelWithEditIcon
htmlFor={`${formPrefixId}-sellerAddress`}
htmlFor={`sellerAddress`}
content={SELLER_TOOLTIP_CONTENT}
>
Address
</LabelWithEditIcon>
) : (
<Label htmlFor={`${formPrefixId}-sellerAddress`} className="mb-1">
<Label htmlFor={`sellerAddress`} className="mb-1">
Address
</Label>
)}
@ -114,10 +118,12 @@ export const SellerInformation = memo(function SellerInformation({
render={({ field }) => (
<Textarea
{...field}
id={`${formPrefixId}-sellerAddress`}
id={`sellerAddress`}
rows={3}
className=""
disabled={!!selectedSellerId}
readOnly={isSellerSelected}
aria-readonly={isSellerSelected}
title={HTML_TITLE_CONTENT}
/>
)}
/>
@ -130,35 +136,40 @@ export const SellerInformation = memo(function SellerInformation({
<div className="relative mb-2 flex items-center justify-between">
{isSellerSelected ? (
<LabelWithEditIcon
htmlFor={`${formPrefixId}-sellerVatNo`}
htmlFor={`sellerVatNo`}
content={SELLER_TOOLTIP_CONTENT}
>
VAT Number
</LabelWithEditIcon>
) : (
<Label htmlFor={`${formPrefixId}-sellerVatNo`} className="">
<Label htmlFor={`sellerVatNo`} className="">
VAT Number
</Label>
)}
{/* Show/hide Seller VAT Number field in PDF switch */}
<div className="inline-flex items-center gap-2">
<div
className="inline-flex items-center gap-2"
title={HTML_TITLE_CONTENT}
>
<Controller
name={`seller.vatNoFieldIsVisible`}
control={control}
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-sellerVatNoFieldIsVisible`}
id={`sellerVatNoFieldIsVisible`}
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"
disabled={isSellerSelected}
data-testid={`sellerVatNoFieldIsVisible`}
/>
)}
/>
<CustomTooltip
trigger={
<Label htmlFor={`${formPrefixId}-sellerVatNoFieldIsVisible`}>
<Label htmlFor={`sellerVatNoFieldIsVisible`}>
Show in PDF
</Label>
}
@ -176,9 +187,12 @@ export const SellerInformation = memo(function SellerInformation({
render={({ field }) => (
<Input
{...field}
id={`${formPrefixId}-sellerVatNo`}
id={`sellerVatNo`}
type="text"
className=""
readOnly={isSellerSelected}
aria-readonly={isSellerSelected}
title={HTML_TITLE_CONTENT}
/>
)}
/>
@ -190,13 +204,13 @@ export const SellerInformation = memo(function SellerInformation({
<div>
{isSellerSelected ? (
<LabelWithEditIcon
htmlFor={`${formPrefixId}-sellerEmail`}
htmlFor={`sellerEmail`}
content={SELLER_TOOLTIP_CONTENT}
>
Email
</LabelWithEditIcon>
) : (
<Label htmlFor={`${formPrefixId}-sellerEmail`} className="mb-1">
<Label htmlFor={`sellerEmail`} className="mb-1">
Email
</Label>
)}
@ -206,9 +220,12 @@ export const SellerInformation = memo(function SellerInformation({
render={({ field }) => (
<Input
{...field}
id={`${formPrefixId}-sellerEmail`}
id={`sellerEmail`}
type="email"
className=""
readOnly={isSellerSelected}
aria-readonly={isSellerSelected}
title={HTML_TITLE_CONTENT}
/>
)}
/>
@ -222,40 +239,40 @@ export const SellerInformation = memo(function SellerInformation({
<div className="relative mb-2 flex items-center justify-between">
{isSellerSelected ? (
<LabelWithEditIcon
htmlFor={`${formPrefixId}-sellerAccountNumber`}
htmlFor={`sellerAccountNumber`}
content={SELLER_TOOLTIP_CONTENT}
>
Account Number
</LabelWithEditIcon>
) : (
<Label
htmlFor={`${formPrefixId}-sellerAccountNumber`}
className=""
>
<Label htmlFor={`sellerAccountNumber`} className="">
Account Number
</Label>
)}
{/* Show/hide Account Number field in PDF switch */}
<div className="inline-flex items-center gap-2">
<div
className="inline-flex items-center gap-2"
title={HTML_TITLE_CONTENT}
>
<Controller
name={`seller.accountNumberFieldIsVisible`}
control={control}
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-sellerAccountNumberFieldIsVisible`}
id={`sellerAccountNumberFieldIsVisible`}
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"
disabled={isSellerSelected}
data-testid={`sellerAccountNumberFieldIsVisible`}
/>
)}
/>
<CustomTooltip
trigger={
<Label
htmlFor={`${formPrefixId}-sellerAccountNumberFieldIsVisible`}
>
<Label htmlFor={`sellerAccountNumberFieldIsVisible`}>
Show in PDF
</Label>
}
@ -270,14 +287,19 @@ export const SellerInformation = memo(function SellerInformation({
<Controller
name="seller.accountNumber"
control={control}
render={({ field }) => (
<Textarea
{...field}
id="sellerAccountNumber"
rows={3}
className=""
/>
)}
render={({ field }) => {
return (
<Textarea
{...field}
id={`sellerAccountNumber`}
rows={3}
className=""
readOnly={isSellerSelected}
aria-readonly={isSellerSelected}
title={HTML_TITLE_CONTENT}
/>
);
}}
/>
{errors.seller?.accountNumber && (
<ErrorMessage>{errors.seller.accountNumber.message}</ErrorMessage>
@ -289,37 +311,40 @@ export const SellerInformation = memo(function SellerInformation({
<div className="relative mb-2 flex items-center justify-between">
{isSellerSelected ? (
<LabelWithEditIcon
htmlFor={`${formPrefixId}-sellerSwiftBic`}
htmlFor={`sellerSwiftBic`}
content={SELLER_TOOLTIP_CONTENT}
>
SWIFT/BIC
</LabelWithEditIcon>
) : (
<Label htmlFor={`${formPrefixId}-sellerSwiftBic`} className="">
<Label htmlFor={`sellerSwiftBic`} className="">
SWIFT/BIC
</Label>
)}
{/* Show/hide SWIFT/BIC field in PDF switch */}
<div className="inline-flex items-center gap-2">
<div
className="inline-flex items-center gap-2"
title={HTML_TITLE_CONTENT}
>
<Controller
name={`seller.swiftBicFieldIsVisible`}
control={control}
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`${formPrefixId}-sellerSwiftBicFieldIsVisible`}
id={`sellerSwiftBicFieldIsVisible`}
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"
disabled={isSellerSelected}
data-testid={`sellerSwiftBicFieldIsVisible`}
/>
)}
/>
<CustomTooltip
trigger={
<Label
htmlFor={`${formPrefixId}-sellerSwiftBicFieldIsVisible`}
>
<Label htmlFor={`sellerSwiftBicFieldIsVisible`}>
Show in PDF
</Label>
}
@ -335,9 +360,19 @@ export const SellerInformation = memo(function SellerInformation({
<Controller
name="seller.swiftBic"
control={control}
render={({ field }) => (
<Textarea {...field} id="sellerSwiftBic" rows={3} className="" />
)}
render={({ field }) => {
return (
<Textarea
{...field}
id={`sellerSwiftBic`}
rows={3}
className=""
readOnly={isSellerSelected}
aria-readonly={isSellerSelected}
title={HTML_TITLE_CONTENT}
/>
);
}}
/>
{errors.seller?.swiftBic && (
<ErrorMessage>{errors.seller.swiftBic.message}</ErrorMessage>

View file

@ -26,7 +26,7 @@ const DONATION_TOAST_CONFIG = {
closeButton: true,
} as const;
const DONATION_URL = "https://dub.sh/easyinvoice-donate" as const;
const DONATION_URL = "https://dub.sh/easyinvoice-donate";
// Separate button states into a memoized component
const ButtonContent = ({
@ -138,7 +138,7 @@ export function InvoicePDFDownloadLink({
download={filename}
onClick={handleClick}
className={cn(
"mb-4 h-[36px] w-full rounded-lg bg-slate-900 px-4 py-2 text-center text-sm font-medium text-slate-50",
"h-[36px] w-full rounded-lg bg-slate-900 px-4 py-2 text-center text-sm font-medium text-slate-50",
"shadow-sm shadow-black/5 outline-offset-2 hover:bg-slate-900/90",
"focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50",
"dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90 lg:mb-0 lg:w-[210px]",

View file

@ -4,8 +4,9 @@ export function InvoicePDFViewer({ children }: { children: React.ReactNode }) {
return (
<PDFViewer
width="100%"
className="mb-4 h-[580px] w-full lg:h-[620px]"
className="mb-4 h-full w-full lg:h-[620px]"
title="Invoice PDF Viewer"
data-testid="pdf-preview"
>
{/* @ts-expect-error PR with fix?: https://github.com/diegomura/react-pdf/pull/2888 */}
{children}

View file

@ -1,89 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { CustomTooltip } from "@/components/ui/tooltip";
import { LOADING_BUTTON_TEXT, LOADING_BUTTON_TIMEOUT } from "./invoice-form";
import { InvoicePdfTemplate } from "./invoice-pdf-template";
import { usePDF } from "@react-pdf/renderer";
import type { InvoiceData } from "../schema";
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { umamiTrackEvent } from "@/lib/umami-analytics-track-event";
import { useOpenPanel } from "@openpanel/nextjs";
import { ErrorGeneratingPdfToast } from "@/components/ui/toasts/error-generating-pdf-toast";
import * as Sentry from "@sentry/nextjs";
import type { FormPrefixId } from ".";
export function RegenerateInvoiceButton({
invoiceData,
formPrefixId,
}: {
invoiceData: InvoiceData;
formPrefixId: FormPrefixId;
}) {
const [{ loading: pdfLoading, error }] = usePDF({
document: <InvoicePdfTemplate invoiceData={invoiceData} />,
});
const [isLoading, setIsLoading] = useState(false);
const openPanel = useOpenPanel();
useEffect(() => {
if (pdfLoading) {
setIsLoading(true);
} else {
// When PDF loading completes, wait for 0.5 second before hiding the loader
const timer = setTimeout(() => {
setIsLoading(false);
}, LOADING_BUTTON_TIMEOUT);
return () => clearTimeout(timer);
}
}, [pdfLoading]);
useEffect(() => {
if (error) {
ErrorGeneratingPdfToast();
openPanel.track("error_generating_pdf_regenerate_button", {
data: {
error: error,
},
});
umamiTrackEvent("error_generating_pdf_regenerate_button", {
data: {
error: error,
},
});
Sentry.captureException(error);
}
}, [error, openPanel]);
return (
<CustomTooltip
trigger={
<Button
type="submit"
form={formPrefixId}
_variant="outline"
className="mt-2 w-full"
disabled={isLoading}
onClick={() => {
// analytics events
openPanel.track("regenerate_invoice");
umamiTrackEvent("regenerate_invoice");
}}
>
{isLoading ? (
<span className="inline-flex items-center">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span className="animate-pulse">{LOADING_BUTTON_TEXT}</span>
</span>
) : (
"Regenerate Invoice"
)}
</Button>
}
content={isLoading ? null : "Click to regenerate the invoice PDF preview"}
/>
);
}

View file

@ -6,6 +6,8 @@ import { SpeedInsights } from "@vercel/speed-insights/next";
import "./globals.css";
import { Toaster } from "sonner";
import { checkDeviceUserAgent } from "@/lib/check-device.server";
import { DeviceContextProvider } from "@/contexts/device-context";
export const viewport: Viewport = {
initialScale: 1, // Sets the default zoom level to 1 (100%)
@ -51,46 +53,53 @@ export const metadata: Metadata = {
},
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const { isDesktop: isDesktopServer } = await checkDeviceUserAgent();
return (
<html lang="en">
{process.env.VERCEL_ENV === "development" && (
{/* React-scan is a tool for detecting and fixing issues with React components
https://github.com/aidenybai/react-scan#readme
Uncomment if needed
*/}
{/* {process.env.VERCEL_ENV === "development" && (
<head>
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
<script
crossOrigin="anonymous"
src="//unpkg.com/react-scan/dist/auto.global.js"
/>
</head>
)}
)} */}
<body className={`antialiased`}>
<NuqsAdapter>{children}</NuqsAdapter>
{/* https://vercel.com/vladsazon27s-projects/pdf-invoice-generator/speed-insights */}
{process.env.VERCEL_ENV === "production" && <SpeedInsights />}
<DeviceContextProvider isDesktop={isDesktopServer}>
<NuqsAdapter>{children}</NuqsAdapter>
</DeviceContextProvider>
{/* https://sonner.emilkowal.ski/ */}
<Toaster visibleToasts={1} richColors />
{/* https://openpanel.dev/docs */}
{/* should only be enabled in production */}
{process.env.VERCEL_ENV === "production" && (
<OpenPanelComponent
clientId="34cab0b1-c372-4d2d-9646-9a4cea67faf9"
trackScreenViews={true}
/>
)}
{process.env.VERCEL_ENV === "production" && (
<Script
// we proxy umami check next.config.mjs rewrites
src="/stats/script.js"
data-website-id="1914352c-5ebb-4806-bfc3-f494712bb4a4"
defer
/>
<>
{/* https://vercel.com/vladsazon27s-projects/pdf-invoice-generator/speed-insights */}
<SpeedInsights />
{/* https://openpanel.dev/docs */}
<OpenPanelComponent
clientId="34cab0b1-c372-4d2d-9646-9a4cea67faf9"
trackScreenViews={true}
/>
{/* https://eu.umami.is/dashboard */}
<Script
// we proxy umami check next.config.mjs rewrites
src="/stats/script.js"
data-website-id="1914352c-5ebb-4806-bfc3-f494712bb4a4"
defer
/>
</>
)}
</body>
</html>

View file

@ -8,7 +8,7 @@ export default function Loading() {
{/* Form fields skeleton */}
<div className="space-y-4">
{[...Array(6)].map((_, i) => (
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="h-4 w-32 animate-pulse rounded bg-gray-200" />
<div className="h-10 animate-pulse rounded bg-gray-200" />

View file

@ -3,7 +3,6 @@
import { invoiceSchema, type InvoiceData } from "@/app/schema";
import { Button } from "@/components/ui/button";
import { CustomTooltip, TooltipProvider } from "@/components/ui/tooltip";
import { useMediaQuery } from "@/hooks/use-media-query";
import { isLocalStorageAvailable } from "@/lib/check-local-storage";
import { umamiTrackEvent } from "@/lib/umami-analytics-track-event";
import { useOpenPanel } from "@openpanel/nextjs";
@ -20,13 +19,16 @@ import { PDF_DATA_LOCAL_STORAGE_KEY } from "./components/invoice-form";
import { InvoicePDFDownloadLink } from "./components/invoice-pdf-download-link";
import { INITIAL_INVOICE_DATA } from "./constants";
import { cn } from "@/lib/utils";
import { InvoicePDFDownloadMultipleLanguages } from "./components/invoice-pdf-download-multiple-languages";
import { useDeviceContext } from "@/contexts/device-context";
// import { InvoicePDFDownloadMultipleLanguages } from "./components/invoice-pdf-download-multiple-languages";
export default function Home() {
const router = useRouter();
const searchParams = useSearchParams();
const isDesktop = useMediaQuery("(min-width: 1024px)");
const openPanel = useOpenPanel();
const { isDesktop } = useDeviceContext();
const isMobile = !isDesktop;
const [invoiceDataState, setInvoiceDataState] = useState<InvoiceData | null>(
null
@ -42,7 +44,7 @@ export default function Home() {
const decompressed = decompressFromEncodedURIComponent(
compressedInvoiceDataInUrl
);
const parsed = JSON.parse(decompressed);
const parsed: unknown = JSON.parse(decompressed);
const validated = invoiceSchema.parse(parsed);
setInvoiceDataState(validated);
@ -64,7 +66,7 @@ export default function Home() {
try {
const savedData = localStorage.getItem(PDF_DATA_LOCAL_STORAGE_KEY);
if (savedData) {
const json = JSON.parse(savedData);
const json: unknown = JSON.parse(savedData);
const parsedData = invoiceSchema.parse(json);
setInvoiceDataState(parsedData);
@ -99,7 +101,7 @@ export default function Home() {
if (urlData) {
try {
const decompressed = decompressFromEncodedURIComponent(urlData);
const urlParsed = JSON.parse(decompressed);
const urlParsed: unknown = JSON.parse(decompressed);
const urlValidated = invoiceSchema.parse(urlParsed);
@ -186,7 +188,7 @@ export default function Home() {
return (
<TooltipProvider delayDuration={0}>
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 sm:p-4">
<div className="mb-4 w-full max-w-7xl bg-white p-3 shadow-lg sm:mb-0 sm:rounded-lg sm:p-6">
<div className="w-full max-w-7xl bg-white p-3 shadow-lg sm:mb-0 sm:rounded-lg sm:p-6">
<div className="flex w-full flex-row flex-wrap items-center justify-between lg:flex-nowrap">
<div className="relative bottom-3 mt-3 flex flex-col items-center justify-center sm:mt-0">
<div className="flex items-center">
@ -227,20 +229,23 @@ export default function Home() {
<span>Support Project</span>
</span>
</Button>
<CustomTooltip
trigger={
<Button
onClick={handleShareInvoice}
_variant="outline"
className="mx-2 mb-2 w-full lg:mx-0 lg:mb-0 lg:w-auto"
>
Generate a link to invoice
</Button>
}
content="Generate a shareable link to this invoice. Share it with your clients to allow them to view the invoice online."
/>
{isDesktop ? (
<InvoicePDFDownloadLink invoiceData={invoiceDataState} />
<>
<CustomTooltip
trigger={
<Button
onClick={handleShareInvoice}
_variant="outline"
className="mx-2 mb-2 w-full lg:mx-0 lg:mb-0 lg:w-auto"
>
Generate a link to invoice
</Button>
}
content="Generate a shareable link to this invoice. Share it with your clients to allow them to view the invoice online."
/>
<InvoicePDFDownloadLink invoiceData={invoiceDataState} />
</>
) : null}
{/* TODO: add later when PRO version is released, this is PRO FEATURE =) */}
@ -251,7 +256,7 @@ export default function Home() {
) : null} */}
</div>
</div>
<div className="mb-4 flex flex-row items-center justify-center lg:mb-0 lg:mt-4 lg:justify-start xl:mt-0">
<div className="mb-3 mt-2 flex flex-row items-center justify-center lg:mb-0 lg:mt-4 lg:justify-start xl:mt-0">
<ProjectInfo />
</div>
@ -259,6 +264,8 @@ export default function Home() {
<InvoiceClientPage
invoiceDataState={invoiceDataState}
handleInvoiceDataChange={handleInvoiceDataChange}
handleShareInvoice={handleShareInvoice}
isMobile={isMobile}
/>
</div>
</div>

View file

@ -22,6 +22,14 @@ export const SUPPORTED_DATE_FORMATS = [
"YYYY.MM.DD", // 2024.03.20
] as const;
/**
* Supported date formats
*
* This is the list of date formats that are supported by the invoice form
*
*
* @lintignore ignore for now in knip
*/
export type SupportedDateFormat = (typeof SUPPORTED_DATE_FORMATS)[number];
export const invoiceItemSchema = z
@ -54,7 +62,7 @@ export const invoiceItemSchema = z
message: "Amount must be positive",
})
.refine((val) => val <= 9_999_999_999.99, {
message: "Amount must not exceed 9.999.999.999",
message: "Amount must not exceed 9 999 999 999.99",
}),
amountFieldIsVisible: z.boolean().default(true),
@ -223,18 +231,11 @@ export const invoiceSchema = z.object({
export type InvoiceData = z.infer<typeof invoiceSchema>;
// https://github.com/colinhacks/zod/discussions/2814#discussioncomment-7121769
// const zodInputStringPipe = (zodPipe: ZodTypeAny) =>
// z
// .string()
// .transform((value) => (value === "" ? null : value))
// .nullable()
// .refine((value) => value === null || !isNaN(Number(value)), {
// message: "Nombre Invalide",
// })
// .transform((value) => (value === null ? 0 : Number(value)))
// .pipe(zodPipe);
/**
* Default seller data
*
* This is the default data that will be used if the user doesn't provide their own data
*/
export const DEFAULT_SELLER_DATA = {
name: "Seller name",
address: "Seller address",
@ -251,6 +252,11 @@ export const DEFAULT_SELLER_DATA = {
swiftBicFieldIsVisible: true,
} as const satisfies Omit<SellerData, "id">;
/**
* Default buyer data
*
* This is the default data that will be used if the user doesn't provide their own data
*/
export const DEFAULT_BUYER_DATA = {
name: "Buyer name",
address: "Buyer address",

View file

@ -105,7 +105,7 @@ export function BuyerDialog({
// Get existing buyers or initialize empty array
const buyers = localStorage.getItem(BUYERS_LOCAL_STORAGE_KEY);
const existingBuyers = buyers ? JSON.parse(buyers) : [];
const existingBuyers: unknown = buyers ? JSON.parse(buyers) : [];
// Validate existing buyers array with Zod
const existingBuyersValidationResult = z
@ -131,7 +131,7 @@ export function BuyerDialog({
}
// Validate buyer data against existing buyers
const isDuplicateName = existingBuyers.some(
const isDuplicateName = existingBuyersValidationResult.data.some(
(buyer: BuyerData) =>
buyer.name === formValues.name && buyer.id !== formValues.id
);
@ -188,7 +188,10 @@ export function BuyerDialog({
}
}}
>
<DialogContent className="flex flex-col gap-0 overflow-y-visible p-0 sm:max-w-lg [&>button:last-child]:top-3.5">
<DialogContent
className="flex flex-col gap-0 overflow-y-visible p-0 sm:max-w-lg [&>button:last-child]:top-3.5"
data-testid={`manage-buyer-dialog`}
>
<DialogHeader className="border-b border-slate-200 px-6 py-4 dark:border-slate-800">
<DialogTitle className="text-base">
{isEditMode ? "Edit Buyer" : "Add New Buyer"}

View file

@ -67,7 +67,7 @@ export function BuyerManagement({
useEffect(() => {
try {
const savedBuyers = localStorage.getItem(BUYERS_LOCAL_STORAGE_KEY);
const parsedBuyers = savedBuyers ? JSON.parse(savedBuyers) : [];
const parsedBuyers: unknown = savedBuyers ? JSON.parse(savedBuyers) : [];
// Validate buyers array with Zod
const buyersSchema = z.array(buyerSchema);
@ -284,6 +284,7 @@ export function BuyerManagement({
}}
className="h-8 px-2"
>
<span className="sr-only">Edit buyer</span>
<Pencil className="h-3 w-3" />
</Button>
}
@ -302,6 +303,7 @@ export function BuyerManagement({
}}
className="h-8 px-2"
>
<span className="sr-only">Delete buyer</span>
<Trash2 className="h-3 w-3" />
</Button>
}

View file

@ -6,6 +6,7 @@ import {
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
import { CustomTooltip } from "@/components/ui/tooltip";
import { useIsDesktop } from "@/hooks/use-media-query";
/**
* A component that displays a label with an edit icon and a tooltip.
@ -19,29 +20,37 @@ export const LabelWithEditIcon = ({
children: React.ReactNode;
content: string;
}) => {
const isDesktop = useIsDesktop();
return (
<div className="flex items-center gap-1">
<Label htmlFor={htmlFor} className="mb-1">
{children}
</Label>
<div className="hidden md:block">
{isDesktop ? (
<CustomTooltip
trigger={
<Info className="mb-1 h-3.5 w-3.5 cursor-pointer text-gray-800" />
<Info
className="mb-1 h-3.5 w-3.5 cursor-pointer text-gray-800"
data-testid="form-section-tooltip-info-icon-desktop"
/>
}
content={content}
/>
</div>
<div className="block md:hidden">
) : (
// Mobile view
<Popover>
<PopoverTrigger asChild>
<Info className="mb-1 h-3.5 w-3.5 cursor-pointer text-gray-800" />
<Info
className="mb-1 h-3.5 w-3.5 cursor-pointer text-gray-800"
data-testid="form-section-tooltip-info-icon-mobile"
/>
</PopoverTrigger>
<PopoverContent className="w-[200px] text-sm">
{content}
</PopoverContent>
</Popover>
</div>
)}
</div>
);
};

View file

@ -117,7 +117,7 @@ export function SellerDialog({
// Get existing sellers or initialize empty array
const sellers = localStorage.getItem(SELLERS_LOCAL_STORAGE_KEY);
const existingSellers = sellers ? JSON.parse(sellers) : [];
const existingSellers: unknown = sellers ? JSON.parse(sellers) : [];
// Validate existing sellers array with Zod
const existingSellersValidationResult = z
@ -145,7 +145,7 @@ export function SellerDialog({
// we don't need to validate the name if we are editing an existing seller
// Validate seller data against existing sellers
const isDuplicateName = existingSellers.some(
const isDuplicateName = existingSellersValidationResult.data.some(
(seller: SellerData) =>
seller.name === formValues.name && seller.id !== formValues.id
);
@ -202,7 +202,10 @@ export function SellerDialog({
}
}}
>
<DialogContent className="flex flex-col gap-0 overflow-y-visible p-0 sm:max-w-lg [&>button:last-child]:top-3.5">
<DialogContent
className="flex flex-col gap-0 overflow-y-visible p-0 sm:max-w-lg [&>button:last-child]:top-3.5"
data-testid={`manage-seller-dialog`}
>
<DialogHeader className="border-b border-slate-200 px-6 py-4 dark:border-slate-800">
<DialogTitle className="text-base">
{isEditMode ? "Edit Seller" : "Add New Seller"}

View file

@ -72,7 +72,9 @@ export function SellerManagement({
useEffect(() => {
try {
const savedSellers = localStorage.getItem(SELLERS_LOCAL_STORAGE_KEY);
const parsedSellers = savedSellers ? JSON.parse(savedSellers) : [];
const parsedSellers: unknown = savedSellers
? JSON.parse(savedSellers)
: [];
// Validate sellers array with Zod
const sellersSchema = z.array(sellerSchema);
@ -296,6 +298,7 @@ export function SellerManagement({
}}
className="h-8 px-2"
>
<span className="sr-only">Edit seller</span>
<Pencil className="h-3 w-3" />
</Button>
}
@ -314,6 +317,7 @@ export function SellerManagement({
}}
className="h-8 px-2"
>
<span className="sr-only">Delete seller</span>
<Trash2 className="h-3 w-3" />
</Button>
}

View file

@ -1,7 +1,7 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border border-slate-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 dark:border-slate-800 dark:focus:ring-slate-300",
@ -21,7 +21,7 @@ const badgeVariants = cva(
variant: "default",
},
}
)
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
@ -30,7 +30,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View file

@ -1,13 +1,13 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import type * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
type ControllerProps,
type FieldPath,
type FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form";

View file

@ -9,7 +9,7 @@ const Input = React.memo(
type={type}
autoComplete="off"
className={cn(
"h-8 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-slate-950 shadow-sm shadow-black/5 transition-shadow placeholder:text-slate-500/70 focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:placeholder:text-slate-400/70 dark:focus-visible:border-slate-300 dark:focus-visible:ring-slate-300/20",
"h-8 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-slate-950 shadow-sm shadow-black/5 transition-shadow placeholder:text-slate-500/70 focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:placeholder:text-slate-400/70 dark:focus-visible:border-slate-300 dark:focus-visible:ring-slate-300/20 [&[aria-readonly=true]]:cursor-not-allowed [&[aria-readonly=true]]:bg-slate-50 [&[aria-readonly=true]]:opacity-50 [&[aria-readonly=true]]:dark:bg-slate-900",
type === "search" &&
"[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none",
type === "file" &&

View file

@ -13,14 +13,20 @@ export const CURRENCY_SYMBOLS = {
const MoneyInput = React.memo(
React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input"> & { currency: InvoiceData["currency"] }
>(({ currency, ...props }, ref) => {
React.ComponentProps<"input"> & {
currency: InvoiceData["currency"];
dataTestId?: string;
}
>(({ currency, dataTestId, ...props }, ref) => {
const shownCurrencyText = currency || SUPPORTED_CURRENCIES[0];
const currencySymbol = CURRENCY_SYMBOLS[shownCurrencyText] || null;
return (
<div>
<div className="relative flex rounded-lg shadow-sm shadow-black/5">
<div
className="relative flex rounded-lg shadow-sm shadow-black/5"
data-testid={dataTestId}
>
{currencySymbol ? (
<span className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-sm">
{currencySymbol}
@ -49,14 +55,20 @@ MoneyInput.displayName = "MoneyInput";
const ReadOnlyMoneyInput = React.memo(
React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input"> & { currency: InvoiceData["currency"] }
>(({ currency, ...props }, ref) => {
React.ComponentProps<"input"> & {
currency: InvoiceData["currency"];
dataTestId?: string;
}
>(({ currency, dataTestId, ...props }, ref) => {
const shownCurrencyText = currency || SUPPORTED_CURRENCIES[0];
const currencySymbol = CURRENCY_SYMBOLS[shownCurrencyText] || null;
return (
<div>
<div className="relative flex rounded-lg shadow-sm shadow-black/5">
<div
className="relative flex rounded-lg shadow-sm shadow-black/5"
data-testid={dataTestId}
>
{currencySymbol ? (
<span className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-sm">
{currencySymbol}

View file

@ -2,7 +2,7 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { CheckIcon, XCircle, ChevronDown, XIcon } from "lucide-react";
import { CheckIcon, XCircle, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
@ -236,8 +236,7 @@ export const MultiSelect = React.forwardRef<
.slice(0, maxCount)
.map((value, index) => {
const isLast = index === selectedLanguages.length - 1;
const label =
LANGUAGE_TO_LABEL[value as SupportedLanguages];
const label = LANGUAGE_TO_LABEL[value];
return (
<React.Fragment key={value}>

View file

@ -1,13 +1,13 @@
"use client"
"use client";
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
@ -25,7 +25,7 @@ const PopoverContent = React.forwardRef<
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent }
export { Popover, PopoverTrigger, PopoverContent };

View file

@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
@ -25,7 +25,7 @@ const Separator = React.forwardRef<
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator }
export { Separator };

View file

@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-slate-950 shadow-sm shadow-black/5 transition-shadow placeholder:text-slate-500/70 focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:placeholder:text-slate-400/70 dark:focus-visible:border-slate-300 dark:focus-visible:ring-slate-300/20",
"flex min-h-[80px] w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-slate-950 shadow-sm shadow-black/5 transition-shadow placeholder:text-slate-500/70 focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:placeholder:text-slate-400/70 dark:focus-visible:border-slate-300 dark:focus-visible:ring-slate-300/20 [&[aria-readonly=true]]:cursor-not-allowed [&[aria-readonly=true]]:bg-slate-50 [&[aria-readonly=true]]:opacity-50 [&[aria-readonly=true]]:dark:bg-slate-900",
className
)}
ref={ref}

View file

@ -0,0 +1,44 @@
"use client";
import { useIsDesktop } from "@/hooks/use-media-query";
import { createContext, useContext, useEffect, useState } from "react";
interface DeviceContextType {
isDesktop: boolean;
}
const DeviceContext = createContext<DeviceContextType | null>(null);
export function useDeviceContext() {
const context = useContext(DeviceContext);
if (!context) {
throw new Error("useDeviceContext must be used within a DeviceProvider");
}
return context;
}
export function DeviceContextProvider({
children,
isDesktop,
}: DeviceContextType & { children: React.ReactNode }) {
const [isDesktopClient, setIsDesktopClient] = useState(isDesktop);
// Check media query on client side as an additional check
const isMediaQueryDesktop = useIsDesktop();
/**
* Update the client state if the media query is defined
* This is to ensure that the client state is always up to date
*/
useEffect(() => {
if (isMediaQueryDesktop !== undefined) {
setIsDesktopClient(isMediaQueryDesktop);
}
}, [isMediaQueryDesktop]);
return (
<DeviceContext.Provider value={{ isDesktop: isDesktopClient }}>
{children}
</DeviceContext.Provider>
);
}

View file

@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
export interface UseMediaQueryOptions {
interface UseMediaQueryOptions {
getInitialValueInEffect: boolean;
}
@ -36,7 +36,7 @@ function getInitialValue(query: string, initialValue?: boolean) {
return false;
}
export function useMediaQuery(
function useMediaQuery(
query: string,
initialValue?: boolean,
{ getInitialValueInEffect }: UseMediaQueryOptions = {
@ -62,3 +62,7 @@ export function useMediaQuery(
return matches;
}
export const useIsDesktop = () => {
return useMediaQuery("(min-width: 1024px)");
};

View file

@ -3,14 +3,14 @@ import * as Sentry from "@sentry/nextjs";
export async function register() {
if (
process.env.NEXT_RUNTIME === "nodejs" &&
process.env.NODE_ENV === "production"
process.env.VERCEL_ENV === "production"
) {
await import("../sentry.server.config");
}
if (
process.env.NEXT_RUNTIME === "edge" &&
process.env.NODE_ENV === "production"
process.env.VERCEL_ENV === "production"
) {
await import("../sentry.edge.config");
}

View file

@ -0,0 +1,38 @@
"use server";
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
/**
* Check the device type based on the user agent
* @warning **Should only be used on server side**
*/
export const checkDeviceUserAgent = async () => {
if (typeof process === "undefined") {
throw new Error(
"[Server method] you are importing a server-only module outside of server"
);
}
const { get } = headers();
const ua = get("user-agent");
const parser = new UAParser(ua || "");
const device = parser.getDevice();
const os = parser.getOS();
// Detect tablets specifically
const isTablet =
device.type === "tablet" ||
// iPad on iOS 13+ reports as desktop
(os.name === "iOS" && !ua?.includes("iPhone") && !ua?.includes("iPod"));
// Detect mobile phones
const isMobile = device.type === "mobile";
// Desktop is when it's neither tablet nor mobile
const isDesktop = !isTablet && !isMobile;
return { isDesktop };
};

View file

@ -65,7 +65,7 @@ export function getAmountInWords({
}
// Get the fractional part of the total
export function getNumberFractionalPart(total: number = 0) {
export function getNumberFractionalPart(total = 0) {
const schema = z.number().finite().nonnegative("Amount must be non-negative");
const parsedTotal = schema.safeParse(total);