feat: add /?template=stripe|default to url, implement URL compression logic (#130)

* feat: add debug local storage UI and update README; include new template parameter handling in invoice form

* feat: add URL compression logic when generating link to invoice to reduce url length + add unit tests + improved existing e2e tests

* ci: remove type check step from unit tests workflow to streamline CI process

* test: update e2e tests for Stripe invoice sharing logic and template; increase timeout for visibility checks

* test: refactor e2e tests for invoice generation and sharing; update element selectors and enhance URL disallow rules in robots.txt

* chore: enhance README with detailed features and update about page references; add GitHub star CTA component

* chore: update configuration files for Prettier, run prettify across the project

* chore: run dedupe

* test: add e2e tests for Open Graph meta tags in invoice templates; verify correct rendering for default and Stripe templates

* chore: remove @stagewise/toolbar-next package and related development toolbar component from the project
This commit is contained in:
Vlad Sazonau 2025-08-20 01:15:48 +02:00 committed by GitHub
parent 7f16ec9938
commit 5a4e9debc1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 4470 additions and 2559 deletions

View file

@ -0,0 +1,13 @@
- You are a playwright test generator.
- You are given a scenario and you need to generate a playwright test for it.
- DO NOT generate test code based on the scenario alone.
- DO run steps one by one using the tools provided by the Playwright MCP.
- When asked to explore a website:
1. Navigate to the specified URL
2. Explore 1 key functionality of the site and when finished close the browser.
3. Implement a Playwright TypeScript test that uses @playwright/test based on message history using Playwright's best practices including role based locators, auto retrying assertions and with no added timeouts unless necessary as Playwright has built in retries and autowaiting if the correct locators and assertions are used.
- Save generated test file in the e2e directory
- Execute the test file and iterate until the test passes
- Include appropriate assertions to verify the expected behavior
- Structure tests properly with descriptive test titles and comments

View file

@ -7,6 +7,9 @@ NEXT_PUBLIC_SENTRY_ENABLED="false"
NEXT_PUBLIC_SENTRY_DSN=""
# dev mode only
NEXT_PUBLIC_DEBUG_LOCAL_STORAGE_UI="false"
# Resend (For emails) Create account and paste API keys in .env.local
RESEND_API_KEY=""
RESEND_AUDIENCE_ID=""

BIN
.github/screenshots/stripe-tmp.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

52
.github/workflows/unit-tests.yml vendored Normal file
View file

@ -0,0 +1,52 @@
name: 🧪 Vitest Unit Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
unit-tests:
name: Run unit tests
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
# we use pinned versions because there are safer to use: https://x.com/paulmillr/status/1900948425325031448
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
name: 🛎️ Checkout repository
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
name: 📦 Setup pnpm
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # 4.3.0
name: 📚 Setup Node.js
with:
node-version: lts/*
cache: "pnpm"
- name: 🚚 Install dependencies
run: pnpm install
- name: 🧪 Run Vitest tests
id: vitest
run: pnpm vitest run --reporter=verbose
- name: 📧 Send email on failure
if: failure()
uses: dawidd6/action-send-mail@611879133a9569642c41be66f4a323286e9b8a3b # v4
with:
server_address: smtp.gmail.com
server_port: 587
from: GitHub Actions
to: ${{ secrets.EMAIL_USERNAME }}
username: ${{ secrets.EMAIL_USERNAME }}
password: ${{ secrets.EMAIL_PASSWORD }}
subject: ❌ Unit Tests Failed for ${{ github.repository }}
body: |
Unit tests for ${{ github.repository }} have failed.
Branch: ${{ github.ref_name }}
Commit: ${{ github.sha }}
For more details, please check:
- GitHub Actions run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

View file

@ -2,7 +2,15 @@
/** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig*/
const config = {
trailingComma: "es5",
trailingComma: "all",
singleQuote: false,
semi: true,
arrowParens: "always",
bracketSpacing: true,
endOfLine: "lf",
printWidth: 80,
tabWidth: 2,
useTabs: false,
plugins: [require.resolve("prettier-plugin-tailwindcss")],
};

View file

@ -1,8 +1,19 @@
# [EasyInvoicePDF](https://easyinvoicepdf.com)
# 🧾 [EasyInvoicePDF](https://easyinvoicepdf.com)
[EasyInvoicePDF](https://easyinvoicepdf.com) Free & Open-Source Invoice Generator | Live Preview, No Sign-Up, Runs in Your Browser.
> Free & Open-Source Invoice Generator. Create professional invoices instantly in your browser with live preview, multiple templates, and no sign-up required. **[Try it now → easyinvoicepdf.com](https://easyinvoicepdf.com)**
❤️ Love EasyInvoicePDF? Help keep it free and open-source! [Buy me a coffee](https://buymeacoffee.com/vladsazon) to support new features, better templates, and continued maintenance. Even a small contribution makes a big difference. Thank you for being part of this journey! ✨
## Features
- ⚡ **Live Preview**: See changes in real-time as you type
- 🔗 **Shareable Links**: Send invoices directly to clients without attachments
- ⭐ **No Sign-Up Required**: Start creating invoices immediately without any registration
- 📱 **Browser Only**: No server uploads, your data stays private
- 🌍 **Multi-Language**: Support for 10+ languages and all major currencies
- 🧮 **European VAT**: Automatic VAT calculation and formatting
- 🎨 **Multiple Templates**: Including modern **Stripe-style design**
- 📄 **Instant PDF**: One-click download ready for printing or sending
**❤️ Support the project**: [Buy me a coffee](https://buymeacoffee.com/vladsazon) to help keep EasyInvoicePDF free and open-source!
#### Default Invoice Template
@ -10,16 +21,7 @@
#### Stripe Invoice Template
<img width="1440" height="769" alt="stripe template" src=".github/screenshots/stripe.png" />
## Features
- **Live Preview**: See your invoice update in real-time as you make changes, ensuring it looks exactly how you want.
- **Shareable Links**: Generate links to share your invoices directly with clients without sending attachments.
- **Instant Download**: Download your invoice as a PDF file with one click, ready to be sent or printed.
- **Browser Only**: Runs entirely in your browser—no server-side processing or data storage. Your data stays private and secure.
- **Multiple Languages & Currencies**: Create invoices in multiple languages with support for all major currencies and automatic formatting.
- **European VAT Support**: Automatically calculate European VAT rates and totals for your invoices.
<img width="1440" height="769" alt="stripe template" src=".github/screenshots/stripe-tmp.png" />
## Demo Video 🎥

View file

@ -14,7 +14,7 @@ test.describe("About page", () => {
await expect(page).toHaveURL("/en/about");
await expect(page).toHaveTitle(
"About | Free Invoice Generator Live Preview, No Sign-Up"
"About | Free Invoice Generator Live Preview, No Sign-Up",
);
const header = page.getByRole("banner");
@ -35,19 +35,19 @@ test.describe("About page", () => {
level: 1,
name: "Create professional invoices in seconds",
exact: true,
})
}),
).toBeVisible();
await expect(
heroSection.getByText(
"EasyInvoicePDF is a free, open-source tool that lets you create, customize, and download professional invoices with real-time preview."
)
"EasyInvoicePDF is a free, open-source tool that lets you create, customize, and download professional invoices with real-time preview.",
),
).toBeVisible();
await expect(
heroSection
.getByText("No sign-up required. 100% free and open-source.")
.filter({ visible: true })
.filter({ visible: true }),
).toBeVisible();
const video = heroSection.getByTestId("hero-about-page-video");
@ -55,7 +55,7 @@ test.describe("About page", () => {
await expect(video).toBeVisible();
await expect(video).toHaveAttribute(
"poster",
`${STATIC_ASSETS_URL}/easy-invoice-video-placeholder.webp`
`${STATIC_ASSETS_URL}/easy-invoice-video-placeholder.webp`,
);
await expect(video).toHaveAttribute("muted");
await expect(video).toHaveAttribute("loop");
@ -66,7 +66,7 @@ test.describe("About page", () => {
const videoSource = video.locator("source");
await expect(videoSource).toHaveAttribute(
"src",
`${VIDEO_DEMO_URL}#t=0.001`
`${VIDEO_DEMO_URL}#t=0.001`,
);
await expect(videoSource).toHaveAttribute("type", "video/mp4");
@ -75,25 +75,25 @@ test.describe("About page", () => {
await expect(featuresSection).toBeVisible();
await expect(
featuresSection.getByTestId("features-coming-soon")
).toHaveText("Pro version and API coming soon");
featuresSection.getByTestId("features-coming-soon"),
).toHaveText("E-invoices support coming soon");
await expect(
featuresSection.getByRole("heading", {
level: 2,
name: "Everything you need for professional invoicing",
exact: true,
})
}),
).toBeVisible();
await expect(
featuresSection.getByText(
"Our simple yet powerful invoice generator includes all the features you need to create professional invoices quickly."
)
"Our simple yet powerful invoice generator includes all the features you need to create professional invoices quickly.",
),
).toBeVisible();
await expect(
featuresSection.getByText("Pro version and API coming soon")
featuresSection.getByText("E-invoices support coming soon"),
).toBeVisible();
// check FAQ section
@ -105,7 +105,7 @@ test.describe("About page", () => {
level: 2,
name: "Frequently Asked Questions",
exact: true,
})
}),
).toBeVisible();
await expect(faqSection.getByText("What is EasyInvoicePDF?")).toBeVisible();
@ -120,13 +120,13 @@ test.describe("About page", () => {
level: 2,
name: "Subscribe to our newsletter",
exact: true,
})
}),
).toBeVisible();
await expect(
subscribeFormSection.getByText(
"Get updates on new features and improvements from EasyInvoicePDF.com"
)
"Get updates on new features and improvements from EasyInvoicePDF.com",
),
).toBeVisible();
const subscribeForm = subscribeFormSection.getByTestId("subscribe-form");
@ -140,7 +140,7 @@ test.describe("About page", () => {
await expect(subscribeFormEmailInput).toHaveAttribute("required");
await expect(subscribeFormEmailInput).toHaveAttribute(
"autocomplete",
"email"
"email",
);
const subscribeFormButton = subscribeForm.getByRole("button", {
@ -180,18 +180,18 @@ test.describe("About page", () => {
await expect(footer.getByText("Subscribe to our newsletter")).toBeVisible();
await expect(
footer.getByText("All emails will be sent in English")
footer.getByText("All emails will be sent in English"),
).toBeVisible();
const newsletterForm = footer.getByTestId("subscribe-form");
await expect(newsletterForm).toBeVisible();
await expect(
newsletterForm.getByPlaceholder("Enter your email")
newsletterForm.getByPlaceholder("Enter your email"),
).toBeVisible();
await expect(
newsletterForm.getByRole("button", { name: "Subscribe" })
newsletterForm.getByRole("button", { name: "Subscribe" }),
).toBeVisible();
// now check all the rest of the footer links
@ -238,7 +238,7 @@ test.describe("About page", () => {
await expect(shareFeedbackLink).toBeVisible();
await expect(shareFeedbackLink).toHaveAttribute(
"href",
"https://pdfinvoicegenerator.userjot.com/?cursor=1&order=top&limit=10"
"https://pdfinvoicegenerator.userjot.com/?cursor=1&order=top&limit=10",
);
await expect(shareFeedbackLink).toHaveAttribute("target", "_blank");
@ -276,19 +276,19 @@ test.describe("About page", () => {
level: 1,
name: "Créez des factures professionnelles en quelques secondes",
exact: true,
})
}),
).toBeVisible();
await expect(
heroSection.getByText(
"EasyInvoicePDF est un outil gratuit et open-source qui vous permet de créer, personnaliser et télécharger des factures professionnelles avec aperçu en temps réel. Fonctionne entièrement dans votre navigateur."
)
"EasyInvoicePDF est un outil gratuit et open-source qui vous permet de créer, personnaliser et télécharger des factures professionnelles avec aperçu en temps réel. Fonctionne entièrement dans votre navigateur.",
),
).toBeVisible();
await expect(
heroSection
.getByText("Aucune inscription requise. 100% gratuit et open-source.")
.filter({ visible: true })
.filter({ visible: true }),
).toBeVisible();
// Check Features section in French
@ -296,11 +296,11 @@ test.describe("About page", () => {
await expect(featuresSection).toBeVisible();
await expect(featuresSection.getByTestId("features-badge")).toHaveText(
"Fonctionnalités"
"Fonctionnalités",
);
await expect(
featuresSection.getByTestId("features-coming-soon")
featuresSection.getByTestId("features-coming-soon"),
).toHaveText("Version Pro et API bientôt disponibles");
await expect(
@ -308,7 +308,7 @@ test.describe("About page", () => {
level: 2,
name: "Tout ce dont vous avez besoin pour une facturation professionnelle",
exact: true,
})
}),
).toBeVisible();
// check subscribe form section in French
@ -320,13 +320,13 @@ test.describe("About page", () => {
level: 2,
name: "Abonnez-vous à notre newsletter",
exact: true,
})
}),
).toBeVisible();
await expect(
subscribeFormSection.getByText(
"Recevez des mises à jour sur les nouvelles fonctionnalités et améliorations de EasyInvoicePDF.com"
)
"Recevez des mises à jour sur les nouvelles fonctionnalités et améliorations de EasyInvoicePDF.com",
),
).toBeVisible();
// Check footer in French
@ -335,19 +335,19 @@ test.describe("About page", () => {
// Check newsletter subscription form in French
await expect(
footer.getByText("Abonnez-vous à notre newsletter")
footer.getByText("Abonnez-vous à notre newsletter"),
).toBeVisible();
await expect(
footer.getByText("Tous les emails seront envoyés en anglais")
footer.getByText("Tous les emails seront envoyés en anglais"),
).toBeVisible();
const newsletterForm = footer.getByTestId("subscribe-form");
await expect(newsletterForm).toBeVisible();
await expect(
newsletterForm.getByPlaceholder("Entrez votre email")
newsletterForm.getByPlaceholder("Entrez votre email"),
).toBeVisible();
await expect(
newsletterForm.getByRole("button", { name: "S'abonner", exact: true })
newsletterForm.getByRole("button", { name: "S'abonner", exact: true }),
).toBeVisible();
const footerLinks = footer.getByTestId("footer-social-links");
@ -395,21 +395,21 @@ test.describe("About page", () => {
level: 1,
name: "Erstellen Sie professionelle Rechnungen in Sekunden",
exact: true,
})
}),
).toBeVisible();
await expect(
heroSection.getByText(
"EasyInvoicePDF ist ein kostenloses Open-Source-Tool, mit dem Sie professionelle Rechnungen mit Echtzeit-Vorschau erstellen, anpassen und herunterladen können."
)
"EasyInvoicePDF ist ein kostenloses Open-Source-Tool, mit dem Sie professionelle Rechnungen mit Echtzeit-Vorschau erstellen, anpassen und herunterladen können.",
),
).toBeVisible();
await expect(
heroSection
.getByText(
"Keine Anmeldung erforderlich. 100% kostenlos und Open-Source."
"Keine Anmeldung erforderlich. 100% kostenlos und Open-Source.",
)
.filter({ visible: true })
.filter({ visible: true }),
).toBeVisible();
// Check Features section in German
@ -417,11 +417,11 @@ test.describe("About page", () => {
await expect(featuresSection).toBeVisible();
await expect(featuresSection.getByTestId("features-badge")).toHaveText(
"Funktionen"
"Funktionen",
);
await expect(
featuresSection.getByTestId("features-coming-soon")
featuresSection.getByTestId("features-coming-soon"),
).toHaveText("Pro-Version und API in Kürze verfügbar");
await expect(
@ -429,7 +429,7 @@ test.describe("About page", () => {
level: 2,
name: "Alles, was Sie für professionelle Rechnungsstellung brauchen",
exact: true,
})
}),
).toBeVisible();
// Check footer in German
@ -439,21 +439,21 @@ test.describe("About page", () => {
// Check newsletter subscription form in German
await expect(footer.getByText("Newsletter abonnieren")).toBeVisible();
await expect(
footer.getByText("Alle E-Mails werden in englischer Sprache versendet")
footer.getByText("Alle E-Mails werden in englischer Sprache versendet"),
).toBeVisible();
const newsletterForm = footer.getByTestId("subscribe-form");
await expect(newsletterForm).toBeVisible();
await expect(
newsletterForm.getByPlaceholder("E-Mail eingeben")
newsletterForm.getByPlaceholder("E-Mail eingeben"),
).toBeVisible();
await expect(
newsletterForm.getByRole("button", { name: "Abonnieren", exact: true })
newsletterForm.getByRole("button", { name: "Abonnieren", exact: true }),
).toBeVisible();
const footerLinks = footer.getByTestId("footer-social-links");
await expect(
footerLinks.getByRole("link", { name: "Funktionen", exact: true })
footerLinks.getByRole("link", { name: "Funktionen", exact: true }),
).toBeVisible();
});
@ -473,7 +473,7 @@ test.describe("About page", () => {
header.getByRole("link", {
name: "Aller à l'application",
exact: true,
})
}),
).toBeVisible();
await expect(page).toHaveURL("/fr/about");
});

View file

@ -44,7 +44,7 @@ test.describe("Buyer management", () => {
// Verify VAT visibility switch is checked by default
await expect(
manageBuyerDialog.getByRole("switch", { name: "Show in PDF" }).nth(0)
manageBuyerDialog.getByRole("switch", { name: "Show in PDF" }).nth(0),
).toBeChecked();
// Toggle VAT visibility switch
@ -59,7 +59,7 @@ test.describe("Buyer management", () => {
.fill(testData.notes);
const notesSwitch = manageBuyerDialog.getByTestId(
`buyerNotesDialogFieldVisibilitySwitch`
`buyerNotesDialogFieldVisibilitySwitch`,
);
await expect(notesSwitch).toHaveRole("switch");
@ -71,12 +71,12 @@ test.describe("Buyer management", () => {
await expect(
manageBuyerDialog.getByRole("switch", {
name: "Apply to Current Invoice",
})
}),
).toBeChecked();
// Cancel button is shown
await expect(
manageBuyerDialog.getByRole("button", { name: "Cancel" })
manageBuyerDialog.getByRole("button", { name: "Cancel" }),
).toBeVisible();
// Save buyer
@ -84,7 +84,7 @@ test.describe("Buyer management", () => {
// Verify success toast message is visible
await expect(
page.getByText("Buyer added successfully", { exact: true })
page.getByText("Buyer added successfully", { exact: true }),
).toBeVisible();
// Verify buyer data is actually saved in localStorage
@ -123,13 +123,13 @@ test.describe("Buyer management", () => {
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"
"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"
"form-section-tooltip-info-icon-mobile",
);
await mobileTooltips.first().click();
}
@ -138,7 +138,7 @@ test.describe("Buyer management", () => {
const nameInput = buyerForm.getByRole("textbox", { name: "Name" });
await expect(nameInput).toHaveAttribute(
"title",
"Buyer details are locked. Click the Edit Buyer button (Pencil icon) to modify."
"Buyer details are locked. Click the Edit Buyer button (Pencil icon) to modify.",
);
// Buyer Name
@ -147,18 +147,18 @@ test.describe("Buyer management", () => {
// Buyer Address
await expect(
buyerForm.getByRole("textbox", { name: "Address" })
buyerForm.getByRole("textbox", { name: "Address" }),
).toHaveAttribute("aria-readonly", "true");
await expect(
buyerForm.getByRole("textbox", { name: "Address" })
buyerForm.getByRole("textbox", { name: "Address" }),
).toHaveValue(testData.address);
// Buyer VAT Number
await expect(
buyerForm.getByRole("textbox", { name: "VAT Number" })
buyerForm.getByRole("textbox", { name: "VAT Number" }),
).toHaveAttribute("aria-readonly", "true");
await expect(
buyerForm.getByRole("textbox", { name: "VAT Number" })
buyerForm.getByRole("textbox", { name: "VAT Number" }),
).toHaveValue(testData.vatNo);
const vatNumberSwitch = buyerForm.getByTestId(`buyerVatNoFieldIsVisible`);
@ -168,30 +168,30 @@ test.describe("Buyer management", () => {
// Buyer Email
await expect(
buyerForm.getByRole("textbox", { name: "Email" })
buyerForm.getByRole("textbox", { name: "Email" }),
).toHaveAttribute("aria-readonly", "true");
await expect(buyerForm.getByRole("textbox", { name: "Email" })).toHaveValue(
testData.email
testData.email,
);
// Buyer Notes
await expect(
buyerForm.getByRole("textbox", { name: "Notes" })
buyerForm.getByRole("textbox", { name: "Notes" }),
).toHaveAttribute("aria-readonly", "true");
await expect(buyerForm.getByRole("textbox", { name: "Notes" })).toHaveValue(
testData.notes
testData.notes,
);
const notesInvoiceFormSwitch = buyerForm.getByTestId(
`buyerNotesInvoiceFormFieldVisibilitySwitch`
`buyerNotesInvoiceFormFieldVisibilitySwitch`,
);
await expect(notesInvoiceFormSwitch).toBeChecked();
await expect(notesInvoiceFormSwitch).toBeDisabled();
// Verify the buyer appears in the dropdown
await expect(
buyerForm.getByRole("combobox", { name: "Select Buyer" })
buyerForm.getByRole("combobox", { name: "Select Buyer" }),
).toContainText(testData.name);
// ------- TEST EDIT FUNCTIONALITY -------
@ -199,31 +199,31 @@ test.describe("Buyer management", () => {
// Verify all fields are populated in edit dialog
await expect(
manageBuyerDialog.getByRole("textbox", { name: "Name" })
manageBuyerDialog.getByRole("textbox", { name: "Name" }),
).toHaveValue(testData.name);
await expect(
manageBuyerDialog.getByRole("textbox", { name: "Address" })
manageBuyerDialog.getByRole("textbox", { name: "Address" }),
).toHaveValue(testData.address);
await expect(
manageBuyerDialog.getByRole("textbox", { name: "VAT Number" })
manageBuyerDialog.getByRole("textbox", { name: "VAT Number" }),
).toHaveValue(testData.vatNo);
await expect(
manageBuyerDialog.getByRole("textbox", { name: "Email" })
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)
manageBuyerDialog.getByRole("switch", { name: "Show in PDF" }).nth(0),
).not.toBeChecked();
// Verify notes text
await expect(
manageBuyerDialog.getByRole("textbox", { name: "Notes" })
manageBuyerDialog.getByRole("textbox", { name: "Notes" }),
).toHaveValue(testData.notes);
// Verify notes visibility switch is checked
const notesDialogFormSwitch = manageBuyerDialog.getByTestId(
`buyerNotesDialogFieldVisibilitySwitch`
`buyerNotesDialogFieldVisibilitySwitch`,
);
await expect(notesDialogFormSwitch).toHaveRole("switch");
@ -252,18 +252,18 @@ test.describe("Buyer management", () => {
// Verify success toast for update
await expect(
page.getByText("Buyer updated successfully", { exact: true })
page.getByText("Buyer updated successfully", { exact: true }),
).toBeVisible();
// ------- TEST UPDATED INFORMATION IN INVOICE FORM -------
// Verify updated information is displayed
await expect(buyerForm.getByRole("textbox", { name: "Name" })).toHaveValue(
updatedName
updatedName,
);
// Verify VAT visibility is now enabled
await expect(
buyerForm.getByTestId(`buyerVatNoFieldIsVisible`)
buyerForm.getByTestId(`buyerVatNoFieldIsVisible`),
).toBeChecked();
// Verify notes visibility switch is unchecked
@ -305,7 +305,7 @@ test.describe("Buyer management", () => {
// Verify buyer was added
const buyerForm = page.getByTestId(`buyer-information-section`);
await expect(
buyerForm.getByRole("combobox", { name: "Select Buyer" })
buyerForm.getByRole("combobox", { name: "Select Buyer" }),
).toContainText(testData.name);
// Click delete button
@ -315,8 +315,8 @@ test.describe("Buyer management", () => {
await expect(page.getByRole("alertdialog")).toBeVisible();
await expect(
page.getByText(
`Are you sure you want to delete "${testData.name}" buyer?`
)
`Are you sure you want to delete "${testData.name}" buyer?`,
),
).toBeVisible();
// Cancel button is shown
@ -335,8 +335,8 @@ test.describe("Buyer management", () => {
await expect(page.getByRole("alertdialog")).toBeVisible();
await expect(
page.getByText(
`Are you sure you want to delete "${testData.name}" buyer?`
)
`Are you sure you want to delete "${testData.name}" buyer?`,
),
).toBeVisible();
// Confirm deletion
@ -344,30 +344,30 @@ test.describe("Buyer management", () => {
// Verify success message
await expect(
page.getByText("Buyer deleted successfully", { exact: true })
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" })
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
DEFAULT_BUYER_DATA.name,
);
await expect(
buyerForm.getByRole("textbox", { name: "Address" })
buyerForm.getByRole("textbox", { name: "Address" }),
).toHaveValue(DEFAULT_BUYER_DATA.address);
await expect(buyerForm.getByRole("textbox", { name: "Email" })).toHaveValue(
DEFAULT_BUYER_DATA.email
DEFAULT_BUYER_DATA.email,
);
await expect(
buyerForm.getByRole("textbox", { name: "VAT Number" })
buyerForm.getByRole("textbox", { name: "VAT Number" }),
).toHaveValue(DEFAULT_BUYER_DATA.vatNo);
});
});

View file

@ -13,14 +13,14 @@ test.describe("Changelog page", () => {
level: 1,
name: "Changelog",
exact: true,
})
}),
).toBeVisible();
// Check subtitle
await expect(
page.getByText(
"All the latest updates, improvements, and fixes to EasyInvoicePDF"
)
"All the latest updates, improvements, and fixes to EasyInvoicePDF",
),
).toBeVisible();
// Check that changelog entries are present
@ -49,7 +49,7 @@ test.describe("Changelog page", () => {
// Check that we're on an individual entry page by looking for "Back to All Posts" link
await expect(
page.getByRole("link", { name: "Back to All Posts" })
page.getByRole("link", { name: "Back to All Posts" }),
).toBeVisible();
});
@ -84,24 +84,24 @@ test.describe("Changelog page", () => {
// Check author information
await expect(page.getByTestId("author-info-text")).toHaveText(
"Vlad SazonauFounder, EasyInvoicePDF"
"Vlad SazonauFounder, EasyInvoicePDF",
);
await expect(page.getByTestId("author-info-img")).toBeVisible();
// Check social sharing buttons are present
const twitterShareLink = page.locator(
'a[href*="twitter.com/intent/tweet"]'
'a[href*="twitter.com/intent/tweet"]',
);
await expect(twitterShareLink).toBeVisible();
const linkedinShareLink = page.locator(
'a[href*="linkedin.com/shareArticle"]'
'a[href*="linkedin.com/shareArticle"]',
);
await expect(linkedinShareLink).toBeVisible();
// Check "Go to App" CTA button
const goToAppButtonContainer = page.getByTestId(
"go-to-app-button-container"
"go-to-app-button-container",
);
const goToAppButton = goToAppButtonContainer.getByRole("link");
@ -133,7 +133,7 @@ test.describe("Changelog page", () => {
// Verify we're back on the main changelog page
await expect(page).toHaveURL("/changelog");
await expect(
page.getByRole("heading", { level: 1, name: "Changelog" })
page.getByRole("heading", { level: 1, name: "Changelog" }),
).toBeVisible();
});
@ -151,7 +151,7 @@ test.describe("Changelog page", () => {
// Verify the page loads correctly
await expect(
page.getByRole("link", { name: "Back to All Posts" })
page.getByRole("link", { name: "Back to All Posts" }),
).toBeVisible();
// Verify we can still navigate back

View file

@ -91,7 +91,7 @@ test.describe("PDF Preview", () => {
// Save the file to a browser-specific temporary location
const tmpPath = path.join(
getDownloadDir({ browserName }),
suggestedFilename
suggestedFilename,
);
await download.saveAs(tmpPath);
@ -108,12 +108,12 @@ test.describe("PDF Preview", () => {
// Check invoice header details
expect(pdfData.text).toContain(
`Invoice No. of: 1/${CURRENT_MONTH_AND_YEAR}`
`Invoice No. of: 1/${CURRENT_MONTH_AND_YEAR}`,
);
expect(pdfData.text).toContain("Reverse Charge");
expect(pdfData.text).toContain(`Date of issue: ${TODAY}`);
expect(pdfData.text).toContain(
`Date of sales/of executing the service: ${LAST_DAY_OF_CURRENT_MONTH}`
`Date of sales/of executing the service: ${LAST_DAY_OF_CURRENT_MONTH}`,
);
// Check seller details
@ -154,7 +154,7 @@ test.describe("PDF Preview", () => {
// Check page footer and metadata
expect(pdfData.text).toContain(
`1/${CURRENT_MONTH_AND_YEAR}·€0.00 due ${PAYMENT_DATE}·Created with https://easyinvoicepdf.comPage 1 of 1`
`1/${CURRENT_MONTH_AND_YEAR}·€0.00 due ${PAYMENT_DATE}·Created with https://easyinvoicepdf.comPage 1 of 1`,
);
});
@ -195,7 +195,7 @@ test.describe("PDF Preview", () => {
const pdfData = await pdf(dataBuffer);
expect((pdfData.info as { Title: string }).Title).toContain(
`Faktura nr: 1/${CURRENT_MONTH_AND_YEAR} | Created with https://easyinvoicepdf.com`
`Faktura nr: 1/${CURRENT_MONTH_AND_YEAR} | Created with https://easyinvoicepdf.com`,
);
// Verify PDF content
@ -209,7 +209,7 @@ test.describe("PDF Preview", () => {
const lastDayOfCurrentMonth = dayjs().endOf("month").format("YYYY-MM-DD");
expect(pdfData.text).toContain(
`Data sprzedaży / wykonania usługi: ${lastDayOfCurrentMonth}`
`Data sprzedaży / wykonania usługi: ${lastDayOfCurrentMonth}`,
);
expect(pdfData.text).toContain(`Razem do zapłaty: 0.00 EUR
@ -218,7 +218,7 @@ Pozostało do zapłaty: 0.00 EUR
Kwota słownie: zero EUR 00/100`);
expect(pdfData.text).toContain(
`1/${CURRENT_MONTH_AND_YEAR}·0,00 € do zapłaty do ${PAYMENT_DATE}·Utworzono za pomocą https://easyinvoicepdf.comStrona 1 z 1`
`1/${CURRENT_MONTH_AND_YEAR}·0,00 € do zapłaty do ${PAYMENT_DATE}·Utworzono za pomocą https://easyinvoicepdf.comStrona 1 z 1`,
);
});
@ -272,7 +272,7 @@ Kwota słownie: zero EUR 00/100`);
// Toggle notes visibility on
const sellerNotesSwitch = sellerSection.getByTestId(
`sellerNotesInvoiceFormFieldVisibilitySwitch`
`sellerNotesInvoiceFormFieldVisibilitySwitch`,
);
await expect(sellerNotesSwitch).toHaveRole("switch");
@ -302,7 +302,7 @@ Kwota słownie: zero EUR 00/100`);
// Toggle notes visibility on
const buyerNotesSwitch = buyerSection.getByTestId(
`buyerNotesInvoiceFormFieldVisibilitySwitch`
`buyerNotesInvoiceFormFieldVisibilitySwitch`,
);
await expect(buyerNotesSwitch).toHaveRole("switch");
@ -353,7 +353,7 @@ Kwota słownie: zero EUR 00/100`);
// Save the file to a browser-specific temporary location
const tmpPath = path.join(
getDownloadDir({ browserName }),
suggestedFilename
suggestedFilename,
);
await download.saveAs(tmpPath);
@ -368,12 +368,12 @@ Kwota słownie: zero EUR 00/100`);
// Verify PDF content
// Check invoice header details
expect(pdfData.text).toContain(
`Invoice No. of: 1/${CURRENT_MONTH_AND_YEAR}`
`Invoice No. of: 1/${CURRENT_MONTH_AND_YEAR}`,
);
expect(pdfData.text).toContain("HELLO FROM PLAYWRIGHT TEST!");
expect(pdfData.text).toContain(`Date of issue: ${today}`);
expect(pdfData.text).toContain(
`Date of sales/of executing the service: ${lastDayOfCurrentMonth}`
`Date of sales/of executing the service: ${lastDayOfCurrentMonth}`,
);
// Check seller details
@ -405,7 +405,7 @@ Kwota słownie: zero EUR 00/100`);
expect(pdfData.text).toContain("Paid: 0.00 GBP");
expect(pdfData.text).toContain("Left to pay: 3 000.00 GBP");
expect(pdfData.text).toContain(
"Amount in words: three thousand GBP 00/100"
"Amount in words: three thousand GBP 00/100",
);
// Check footer
@ -414,7 +414,7 @@ Kwota słownie: zero EUR 00/100`);
expect(pdfData.text).toContain("Reverse charge");
expect(pdfData.text).toContain(
`1/${CURRENT_MONTH_AND_YEAR}·£3,000.00 due ${paymentDate}·Created with https://easyinvoicepdf.comPage 1 of 1`
`1/${CURRENT_MONTH_AND_YEAR}·£3,000.00 due ${paymentDate}·Created with https://easyinvoicepdf.comPage 1 of 1`,
);
});
@ -502,10 +502,10 @@ Kwota słownie: zero EUR 00/100`);
// Verify preview tab is selected
await expect(
page.getByRole("tabpanel", { name: "Preview PDF" })
page.getByRole("tabpanel", { name: "Preview PDF" }),
).toBeVisible();
await expect(
page.getByRole("tabpanel", { name: "Edit Invoice" })
page.getByRole("tabpanel", { name: "Edit Invoice" }),
).toBeHidden();
// Set up download handler
@ -523,7 +523,7 @@ Kwota słownie: zero EUR 00/100`);
// Save the file to a browser-specific temporary location
const tmpPath = path.join(
getDownloadDir({ browserName }),
suggestedFilename
suggestedFilename,
);
await download.saveAs(tmpPath);
@ -542,7 +542,7 @@ Kwota słownie: zero EUR 00/100`);
const lastDayOfCurrentMonth = dayjs().endOf("month").format("YYYY-MM-DD");
expect(pdfData.text).toContain(
`Date de vente/prestation de service: ${lastDayOfCurrentMonth}`
`Date de vente/prestation de service: ${lastDayOfCurrentMonth}`,
);
// Verify calculations in Polish
@ -555,7 +555,7 @@ Montant en lettres: cent quatre-vingt-quatre GBP 50/100`);
await expect(page.getByTestId("download-pdf-toast")).toBeVisible();
await expect(
page.getByRole("link", { name: "Star on GitHub" })
page.getByRole("link", { name: "Star on GitHub" }),
).toBeVisible();
await expect(page.getByTestId("toast-cta-btn")).toBeVisible();
@ -565,10 +565,10 @@ Montant en lettres: cent quatre-vingt-quatre GBP 50/100`);
// Verify form tab is selected and data persists
await expect(
page.getByRole("tabpanel", { name: "Edit Invoice" })
page.getByRole("tabpanel", { name: "Edit Invoice" }),
).toBeVisible();
await expect(
page.getByRole("tabpanel", { name: "Preview PDF" })
page.getByRole("tabpanel", { name: "Preview PDF" }),
).toBeHidden();
// Verify form data persists
@ -576,30 +576,30 @@ Montant en lettres: cent quatre-vingt-quatre GBP 50/100`);
await expect(invoiceNumberValueInput).toHaveValue("2/05-2024");
await expect(
finalSection.getByRole("textbox", { name: "Notes", exact: true })
finalSection.getByRole("textbox", { name: "Notes", exact: true }),
).toHaveValue("Mobile test note");
// Verify seller information persists
await expect(
sellerSection.getByRole("textbox", { name: "Name" })
sellerSection.getByRole("textbox", { name: "Name" }),
).toHaveValue("Mobile Test Seller");
await expect(
sellerSection.getByRole("textbox", { name: "Address" })
sellerSection.getByRole("textbox", { name: "Address" }),
).toHaveValue("456 Mobile St");
// Verify invoice item persists
await expect(
invoiceItemsSection.getByRole("spinbutton", {
name: "Amount (Quantity)",
})
}),
).toHaveValue("3");
await expect(
invoiceItemsSection.getByRole("spinbutton", {
name: "Net Price (Rate or Unit Price)",
})
}),
).toHaveValue("50");
await expect(
invoiceItemsSection.getByRole("textbox", { name: "VAT", exact: true })
invoiceItemsSection.getByRole("textbox", { name: "VAT", exact: true }),
).toHaveValue("23");
// Verify calculations are correct
@ -607,19 +607,19 @@ Montant en lettres: cent quatre-vingt-quatre GBP 50/100`);
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");
});
@ -641,11 +641,11 @@ Montant en lettres: cent quatre-vingt-quatre GBP 50/100`);
});
await expect(invoiceNumberLabelInput).toHaveValue(
INITIAL_INVOICE_DATA.invoiceNumberObject.label
INITIAL_INVOICE_DATA.invoiceNumberObject.label,
);
await expect(invoiceNumberValueInput).toHaveValue(
INITIAL_INVOICE_DATA.invoiceNumberObject.value
INITIAL_INVOICE_DATA.invoiceNumberObject.value,
);
const languageSelect = page.getByRole("combobox", {
@ -655,11 +655,11 @@ Montant en lettres: cent quatre-vingt-quatre GBP 50/100`);
await languageSelect.selectOption("pl");
await expect(invoiceNumberLabelInput).toHaveValue(
`${TRANSLATIONS.pl.invoiceNumber}:`
`${TRANSLATIONS.pl.invoiceNumber}:`,
);
await expect(invoiceNumberValueInput).toHaveValue(
`1/${CURRENT_MONTH_AND_YEAR}`
`1/${CURRENT_MONTH_AND_YEAR}`,
);
// I can fill in a new invoice number
@ -697,7 +697,7 @@ Montant en lettres: cent quatre-vingt-quatre GBP 50/100`);
await languageSelect.selectOption("pt");
await expect(invoiceNumberLabelInput).toHaveValue(
`${TRANSLATIONS.pt.invoiceNumber}:`
`${TRANSLATIONS.pt.invoiceNumber}:`,
);
await invoiceNumberLabelInput.fill("Fatura TEST PORTUGUESE N°:");
@ -705,7 +705,7 @@ Montant en lettres: cent quatre-vingt-quatre GBP 50/100`);
await expect(
page.getByRole("button", {
name: `Switch to default label ("Fatura N°:")`,
})
}),
).toBeVisible();
// we wait until this button is visible and enabled, that means that the PDF preview has been regenerated
@ -738,7 +738,7 @@ Montant en lettres: cent quatre-vingt-quatre GBP 50/100`);
// Verify PDF content
expect(pdfData.text).toContain(
`Fatura TEST PORTUGUESE N°: 1/${CURRENT_MONTH_AND_YEAR}`
`Fatura TEST PORTUGUESE N°: 1/${CURRENT_MONTH_AND_YEAR}`,
);
expect(pdfData.text).toContain("Data de emissão");
});

View file

@ -0,0 +1,299 @@
import { expect, test } from "@playwright/test";
test.describe("Generate Invoice Link", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("can share invoice and data is persisted in new tab", async ({
page,
context,
}) => {
// Fill in some test data
const invoiceNumberFieldset = page.getByRole("group", {
name: "Invoice Number",
});
const invoiceNumberValueField = invoiceNumberFieldset.getByRole("textbox", {
name: "Value",
});
await invoiceNumberValueField.fill("SHARE-TEST-001");
const finalSection = page.getByTestId(`final-section`);
await finalSection
.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 (Quantity)" })
.fill("5");
await invoiceItemsSection
.getByRole("spinbutton", {
name: "Net Price (Rate or Unit 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("?template=default&data=");
// Open URL in new tab
const newPage = await context.newPage();
await newPage.goto(sharedUrl);
// Get elements from the new page context
const newInvoiceNumberFieldset = newPage.getByRole("group", {
name: "Invoice Number",
});
// Verify data is loaded in new tab
await expect(
newInvoiceNumberFieldset.getByRole("textbox", { name: "Value" }),
).toHaveValue("SHARE-TEST-001");
const newPageFinalSection = newPage.getByTestId(`final-section`);
await expect(
newPageFinalSection.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 (Quantity)",
}),
).toHaveValue("5");
await expect(
newInvoiceItemsSection.getByRole("spinbutton", {
name: "Net Price (Rate or Unit Price)",
}),
).toHaveValue("100");
await expect(
newInvoiceItemsSection.getByRole("textbox", { name: "VAT", exact: true }),
).toHaveValue("23");
// Close the new page
await newPage.close();
});
test("shows notification when invoice link is broken", async ({ page }) => {
// Navigate to page with invalid data parameter
await page.goto("/?data=invalid-data-string");
// Verify error toast appears
await expect(
page.getByText("The shared invoice URL appears to be incorrect"),
).toBeVisible();
// Verify error description is shown
await expect(
page.getByText(
"Please verify that you have copied the complete invoice URL. The link may be truncated or corrupted.",
),
).toBeVisible();
const clearUrlButton = page.getByRole("button", {
name: "Clear URL",
});
// Verify clear URL button is present
await expect(clearUrlButton).toBeVisible();
// Click clear URL button
await clearUrlButton.click();
// Verify toast is dismissed
await expect(
page.getByText("The shared invoice URL appears to be incorrect"),
).toBeHidden();
await expect(
page.getByText(
"Please verify that you have copied the complete invoice URL. The link may be truncated or corrupted.",
),
).toBeHidden();
// Wait for URL to be cleared and verify
await expect(page).toHaveURL("/?template=default");
});
test("backwards compatibility: old uncompressed URLs work the same as new compressed URLs", async ({
page,
}) => {
// Test URLs provided in the user query - these are old format URLs before compression was implemented
const oldUncompressedUrl =
"N4IgNghgdg5grhGBTEAuEAHMIA0IAmEALkgGID2ATgLbFogCyTDABACI4sCaPXuIAYziVKSKAICe9AKoBlNvxLUsxFOgDORSgEsMKPGHIxy9ftqgA3ctoFIAcnGoAjJJQDyTgFZIBRNKEgXbHRSCABrImEIFihKdDwLCDA4NRAARgB6AAYADgBaACYsgoBWEABfPEISNwAzAEl1dRT6ItK83Ly0sqrVOtlXCxtUtpKO-IBmNLNLa1sAFQk9egAlJAtXdSQWAGEACwhKZBmrYcW9Um0kMHxGgDVtdW0nMDUtFLwtsFfKfxBtfD0NIAdgALFkAGw5UElCagiZZHogKAQaipABS5D2UHY5H0IAg+Hwoia9AAMuQoPhKZxpABpfiJIh2EzoNIFOElCGM4gsy7XW7qB5PF5vSgfEBIWjaYIgTxYqAAAWlYAAdAJyNR+BABBq4FBmY4XL82RyYdy8Dq9QaHM5XPybvdHs9Xmh3khPgB3bS1IgAIRsQLNXP46m9voDAgdguFLrFEqg5BI6noyb8eETyejTpFrtQtSSW0qICccAkrj+AKBwNhoIhWRhJWBDf4KLR9AAggI0bsTJaiSSU+g7EhPdwqGFOHYuLTZB2eczWelgxaQEy+VdHULnaK3eKPZKVfQdWjlRAZerNa2k0ghyBr1nNzGd3n3cXtEohwBtUDmU62eolFtY0czjPcE1RVJZHIX1PUObY2HWa5yAwNEDVbSDs23XN4wPIgliQOoAHF5mkUw8HwvRiNIrDY13fNCwPVFyH1PxUDSS1qBYg1aJfXC8H1D96C2SghlsfhBKIXicPAg8oCQIgAAUdHE9iSiyDSMwU5ThmksDUHdBI6GHRSFz0+jDORBSOy41i0G6DSsi0ogbO4qSn1Aiz9yMlzbPQ1AnLXYhXNY8zX28zBRHmCAAA8Qv8hzNMipBorivz3IFTzwpScoAF0KKTJJ7PUpKmWi0VZEcWhKAkLL+MwCAJDQogGAUvZyEBdBvVEFgtGgdRagrPAMEa5rWqIdr8DC+qRqasQiDYFp0FGcZClBUMtF0JBFMatwoDAcwkGkShZQfW9ViQygthYAQDiOfFM1vabZOGzZKQ7OAJqobQAC8kHweZyDWWxtA2Z6DIivQrvez72p0P6AfIRpmjIDzsP0t8gA";
// Navigate to old uncompressed URL
await page.goto(`/?data=${oldUncompressedUrl}`);
// Verify the page loads without error
await expect(page).toHaveURL(
`/?data=${oldUncompressedUrl}&template=stripe`,
);
const oldUrl = page.url();
const generalInfoSection = page.getByTestId("general-information-section");
await expect(
generalInfoSection.getByRole("combobox", {
name: "Invoice PDF Language",
}),
).toHaveValue("pl");
// Verify the Stripe template is selected
await expect(
page.getByRole("combobox", { name: "Invoice Template" }),
).toHaveValue("stripe");
// Invoice Number
const invoiceNumberFieldset = page.getByRole("group", {
name: "Invoice Number",
});
await expect(
invoiceNumberFieldset.getByRole("textbox", { name: "Label" }),
).toHaveValue("Faktura nr:");
await expect(
invoiceNumberFieldset.getByRole("textbox", { name: "Value" }),
).toHaveValue("1/08-2025");
// Verify seller information is loaded
const sellerSection = page.getByTestId("seller-information-section");
const sellerNameField = sellerSection.getByRole("textbox", {
name: "Name",
});
await expect(sellerNameField).toHaveValue("John Doe");
// Verify buyer information is loaded
const buyerSection = page.getByTestId("buyer-information-section");
const buyerNameField = buyerSection.getByRole("textbox", { name: "Name" });
await expect(buyerNameField).toHaveValue("Acme Co");
// Verify invoice items are loaded
const invoiceItemsSection = page.getByTestId("invoice-items-section");
const itemAmountField = invoiceItemsSection.getByRole("spinbutton", {
name: "Net Price (Rate or Unit Price)",
});
await expect(itemAmountField).toHaveValue("15000");
// ______________________________________________
// now check that the compressed URL of the same invoice works the same
// ______________________________________________
const newCompressedUrl =
"/?template=stripe&data=N4IghiBcIA4DYgDQgEZRAWSxgBAEURwE0SikQBjdAVQGU9yATdAZwBcAnASxgFNz+0cgDMooAB7oAYmADWbAK4cwOAHYdoyAJ7oAjAHoADAA4AtACZD5gKwgAvsgDm6SzdMnTu28gAWLq9buZgDMuuRc6ABKvABuvBwsvDgAwj5gHI78yABWUJwKvMiyYiAAXnoA7AAshgBsxlXWwVXBht4gAILoAFIA9j6q+L1ZIABC6AAyvaqM04TUANLkyXrmzda15AyQ+YUgAKLo2f2qAAIAtmBccAB0FL3n5FKr65vIAOJ5HAXIABIvjTeIAAkl8fiA2Og2Lx2OQFFBhGA4IkHCAEJBQOVoLoKk0qrVDI1rBVCeQutAOhRzklkr1yONoAA5XgAd2IvQ4skIjKI81oXWQK2xa0BWzBe0O0DAVN4Fyut3uj2QkKEyHhO2+vFRj0gAG1QIZxchukbOuhaL1hGwWekknhYrw4L0YNTVJDkEsNeCJuhyBgEUjEshGVBdMgAPKmgAKrHiMS4FBGAEVTZFQ9ZDJnkLRTQAVdCMmPIaimgBq6czhmQAHVTQANKBVkBkL17ABaFczdgAushVJ2m3TW8gYOgWVwOElOGBVCxhPFyABHU0cfxuDzmKrkFi+5VRB0JJIUNIZEbq3bIGKmlniuxAA";
await page.goto(newCompressedUrl);
// Verify the page loads without error
await expect(page).toHaveURL(newCompressedUrl);
const newUrl = page.url();
const newGeneralInfoSection = page.getByTestId(
"general-information-section",
);
await expect(
newGeneralInfoSection.getByRole("combobox", {
name: "Invoice PDF Language",
}),
).toHaveValue("pl");
// Verify the Stripe template is selected
await expect(
page.getByRole("combobox", { name: "Invoice Template" }),
).toHaveValue("stripe");
// Invoice Number
const newInvoiceNumberFieldset = page.getByRole("group", {
name: "Invoice Number",
});
await expect(
newInvoiceNumberFieldset.getByRole("textbox", { name: "Label" }),
).toHaveValue("Faktura nr:");
await expect(
newInvoiceNumberFieldset.getByRole("textbox", { name: "Value" }),
).toHaveValue("1/08-2025");
// Verify seller information is loaded
const newSellerSection = page.getByTestId("seller-information-section");
const newSellerNameField = newSellerSection.getByRole("textbox", {
name: "Name",
});
await expect(newSellerNameField).toHaveValue("John Doe");
// Verify buyer information is loaded
const newBuyerSection = page.getByTestId("buyer-information-section");
const newBuyerNameField = newBuyerSection.getByRole("textbox", {
name: "Name",
});
await expect(newBuyerNameField).toHaveValue("Acme Co");
// Verify invoice items are loaded
const newInvoiceItemsSection = page.getByTestId("invoice-items-section");
const newItemAmountField = newInvoiceItemsSection.getByRole("spinbutton", {
name: "Net Price (Rate or Unit Price)",
});
await expect(newItemAmountField).toHaveValue("15000");
// Verify the compressed URL is shorter than the original
expect(newUrl.length).toBeLessThan(oldUrl.length);
// Calculate and verify the compression ratio
const compressionRatio =
((oldUrl.length - newUrl.length) / oldUrl.length) * 100;
// Verify significant compression occurred (at least 20% reduction)
expect(compressionRatio).toBeGreaterThan(20);
});
});

View file

@ -13,7 +13,7 @@ import {
import { expect, test } from "@playwright/test";
import dayjs from "dayjs";
import { INITIAL_INVOICE_DATA } from "../src/app/constants";
import { GITHUB_URL, VIDEO_DEMO_URL } from "@/config";
import { VIDEO_DEMO_URL } from "@/config";
test.describe("Invoice Generator Page", () => {
test.beforeEach(async ({ page }) => {
@ -22,45 +22,90 @@ test.describe("Invoice Generator Page", () => {
test("should redirect from /:locale/app to /", async ({ page }) => {
await page.goto("/en/app");
await expect(page).toHaveURL("/");
await expect(page).toHaveURL("/?template=default");
});
test("displays correct OG meta tags for default template", async ({
page,
}) => {
// Navigate to default template
await page.goto("/?template=default");
await expect(page).toHaveURL("/?template=default");
const templateCombobox = page.getByRole("combobox", {
name: "Invoice Template",
});
await expect(templateCombobox).toHaveValue("default");
// Check that OG image changed to Stripe template
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute(
"content",
"https://static.easyinvoicepdf.com/easy-invoice-opengraph-image.png?v=5",
);
// Check other meta tags for Stripe template
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute(
"content",
"App | Free Invoice Generator Live Preview, No Sign-Up",
);
await expect(
page.locator('meta[property="og:description"]'),
).toHaveAttribute(
"content",
"Create and download professional invoices instantly with EasyInvoicePDF.com. Free and open-source. No signup required.",
);
await expect(page.locator('meta[property="og:site_name"]')).toHaveAttribute(
"content",
"EasyInvoicePDF.com | Free Invoice Generator",
);
// Verify OG image dimensions
await expect(
page.locator('meta[property="og:image:width"]'),
).toHaveAttribute("content", "1200");
await expect(
page.locator('meta[property="og:image:height"]'),
).toHaveAttribute("content", "630");
await expect(page.locator('meta[property="og:image:alt"]')).toHaveAttribute(
"content",
"EasyInvoicePDF.com - Free Invoice Generator with Live PDF Preview",
);
});
test("displays correct buttons and links in header", async ({ page }) => {
// Check URL is correct
await expect(page).toHaveURL("/");
await expect(page).toHaveURL("/?template=default");
// Check title and branding
await expect(page).toHaveTitle(
"App | Free Invoice Generator Live Preview, No Sign-Up"
"App | Free Invoice Generator Live Preview, No Sign-Up",
);
const header = page.getByTestId("header");
await expect(header).toBeVisible();
await expect(
header.getByRole("link", { name: "EasyInvoicePDF" })
header.getByRole("link", { name: "EasyInvoicePDF" }),
).toBeVisible();
await expect(
header.getByText("Free Invoice Generator with Live PDF Preview")
header.getByText("Free Invoice Generator with Live PDF Preview"),
).toBeVisible();
// Check main action buttons
await expect(
page.getByRole("link", { name: "Support Project" })
page.getByRole("link", { name: "Support Project" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Generate a link to invoice" })
page.getByRole("button", { name: "Generate a link to invoice" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Download PDF in English" })
page.getByRole("link", { name: "Download PDF in English" }),
).toBeVisible();
await expect(
header.getByRole("link", { name: "Open Source" })
).toBeVisible();
await expect(
header.getByRole("link", { name: "Share your feedback" })
header.getByRole("link", { name: "Share your feedback" }),
).toBeVisible();
const howItWorksButton = header.getByRole("button", {
@ -73,13 +118,13 @@ test.describe("Invoice Generator Page", () => {
await howItWorksButton.click();
await expect(
page.getByRole("heading", { name: "How EasyInvoicePDF Works" })
page.getByRole("heading", { name: "How EasyInvoicePDF Works" }),
).toBeVisible();
await expect(
page.getByText(
"Watch this quick demo to learn how to create and customize your invoices."
)
"Watch this quick demo to learn how to create and customize your invoices.",
),
).toBeVisible();
// Check that video is displayed in dialog
@ -99,19 +144,18 @@ test.describe("Invoice Generator Page", () => {
await expect(dialog).toBeHidden();
await expect(
dialog.getByRole("heading", { name: "How EasyInvoicePDF Works" })
dialog.getByRole("heading", { name: "How EasyInvoicePDF Works" }),
).toBeHidden();
await expect(
header.getByRole("link", { name: "Open Source" })
).toHaveAttribute("href", GITHUB_URL);
// Verify GitHub Star CTA button is visible
await expect(page.getByTestId("github-star-cta-button")).toBeVisible();
// Verify buttons are enabled
await expect(
page.getByRole("button", { name: "Generate a link to invoice" })
page.getByRole("button", { name: "Generate a link to invoice" }),
).toBeEnabled();
await expect(
page.getByRole("link", { name: "Download PDF in English" })
page.getByRole("link", { name: "Download PDF in English" }),
).toBeEnabled();
});
@ -135,7 +179,7 @@ test.describe("Invoice Generator Page", () => {
// **CHECK GENERAL INFORMATION SECTION**
const generalInfoSection = page.getByTestId(`general-information-section`);
await expect(
generalInfoSection.getByText("General Information", { exact: true })
generalInfoSection.getByText("General Information", { exact: true }),
).toBeVisible();
// Check all supported languages are available as options with correct labels
@ -151,7 +195,7 @@ test.describe("Invoice Generator Page", () => {
const languageName = LANGUAGE_TO_LABEL[lang];
await expect(
languageSelect.locator(`option[value="${lang}"]`)
languageSelect.locator(`option[value="${lang}"]`),
).toHaveText(languageName);
}
@ -171,7 +215,7 @@ test.describe("Invoice Generator Page", () => {
`${currency} ${currencySymbol} ${currencyFullName}`.trim();
await expect(
currencySelect.locator(`option[value="${currency}"]`)
currencySelect.locator(`option[value="${currency}"]`),
).toHaveText(expectedLabel);
}
@ -188,9 +232,9 @@ test.describe("Invoice Generator Page", () => {
const isDefault = dateFormat === SUPPORTED_DATE_FORMATS[0];
await expect(
dateFormatSelect.locator(`option[value="${dateFormat}"]`)
dateFormatSelect.locator(`option[value="${dateFormat}"]`),
).toHaveText(
`${dateFormat} (Preview: ${preview}) ${isDefault ? "(default)" : ""}`
`${dateFormat} (Preview: ${preview}) ${isDefault ? "(default)" : ""}`,
);
}
@ -200,106 +244,106 @@ test.describe("Invoice Generator Page", () => {
});
await expect(
invoiceNumberFieldset.getByRole("textbox", { name: "Label" })
invoiceNumberFieldset.getByRole("textbox", { name: "Label" }),
).toHaveValue(INITIAL_INVOICE_DATA.invoiceNumberObject.label);
await expect(
invoiceNumberFieldset.getByRole("textbox", { name: "Value" })
invoiceNumberFieldset.getByRole("textbox", { name: "Value" }),
).toHaveValue(INITIAL_INVOICE_DATA.invoiceNumberObject.value);
// Date of Issue
await expect(
generalInfoSection.getByRole("textbox", { name: "Date of Issue" })
generalInfoSection.getByRole("textbox", { name: "Date of Issue" }),
).toHaveValue(INITIAL_INVOICE_DATA.dateOfIssue);
// Date of Service
await expect(
generalInfoSection.getByRole("textbox", { name: "Date of Service" })
generalInfoSection.getByRole("textbox", { name: "Date of Service" }),
).toHaveValue(INITIAL_INVOICE_DATA.dateOfService);
// Invoice Type
await expect(
generalInfoSection.getByRole("textbox", { name: "Invoice Type" })
generalInfoSection.getByRole("textbox", { name: "Invoice Type" }),
).toHaveValue(INITIAL_INVOICE_DATA.invoiceType);
// Visibility toggles
await expect(
generalInfoSection.getByRole("switch", { name: "Show in PDF" })
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 })
sellerSection.getByText("Seller Information", { exact: true }),
).toBeVisible();
// Name field
await expect(
sellerSection.getByRole("textbox", { name: "Name" })
sellerSection.getByRole("textbox", { name: "Name" }),
).toHaveValue(INITIAL_INVOICE_DATA.seller.name);
// Address field
await expect(
sellerSection.getByRole("textbox", { name: "Address" })
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" })
sellerSection.getByRole("textbox", { name: "VAT Number" }),
).toHaveValue(INITIAL_INVOICE_DATA.seller.vatNo);
await expect(
sellerSection.getByRole("switch", { name: /Show in PDF/i }).nth(0)
sellerSection.getByRole("switch", { name: /Show in PDF/i }).nth(0),
).toBeChecked();
// Email field
await expect(
sellerSection.getByRole("textbox", { name: "Email" })
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" })
sellerSection.getByRole("textbox", { name: "Account Number" }),
).toHaveValue(INITIAL_INVOICE_DATA.seller.accountNumber);
await expect(
sellerSection.getByRole("switch", { name: /Show in PDF/i }).nth(1)
sellerSection.getByRole("switch", { name: /Show in PDF/i }).nth(1),
).toBeChecked();
// SWIFT/BIC field and visibility toggle
await expect(
sellerSection.getByRole("textbox", { name: "SWIFT/BIC" })
sellerSection.getByRole("textbox", { name: "SWIFT/BIC" }),
).toHaveValue(INITIAL_INVOICE_DATA.seller.swiftBic);
await expect(
sellerSection.getByRole("switch", { name: /Show in PDF/i }).nth(2)
sellerSection.getByRole("switch", { name: /Show in PDF/i }).nth(2),
).toBeChecked();
// Verify Seller Management button is present
await expect(
sellerSection.getByRole("button", { name: "New Seller" })
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 })
buyerSection.getByText("Buyer Information", { exact: true }),
).toBeVisible();
// Name field
await expect(
buyerSection.getByRole("textbox", { name: "Name" })
buyerSection.getByRole("textbox", { name: "Name" }),
).toHaveValue(INITIAL_INVOICE_DATA.buyer.name);
// Address field
await expect(
buyerSection.getByRole("textbox", { name: "Address" })
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" })
buyerSection.getByRole("textbox", { name: "VAT Number" }),
).toHaveValue(INITIAL_INVOICE_DATA.buyer.vatNo);
const buyerVatNoFieldIsVisibleSwitch = buyerSection.getByTestId(
`buyerVatNoFieldIsVisible`
`buyerVatNoFieldIsVisible`,
);
await expect(buyerVatNoFieldIsVisibleSwitch).toHaveRole("switch");
@ -307,29 +351,31 @@ test.describe("Invoice Generator Page", () => {
// Email field
await expect(
buyerSection.getByRole("textbox", { name: "Email" })
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" })
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 })
invoiceItemsSection.getByText("Invoice Items", { exact: true }),
).toBeVisible();
// Check visibility toggles in settings
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show "Number" Column/i })
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
@ -337,54 +383,54 @@ test.describe("Invoice Generator Page", () => {
// Name field and visibility toggle
await expect(
invoiceItemsSection.getByRole("textbox", { name: "Name" })
invoiceItemsSection.getByRole("textbox", { name: "Name" }),
).toHaveValue(firstItem.name);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(0)
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(0),
).toBeChecked();
// Type of GTU field and visibility toggle
await expect(
invoiceItemsSection.getByRole("textbox", { name: "Type of GTU" })
invoiceItemsSection.getByRole("textbox", { name: "Type of GTU" }),
).toHaveValue(firstItem.typeOfGTU);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(1)
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(1),
).not.toBeChecked(); // we don't want to show this in PDF by default
// Amount field and visibility toggle
await expect(
invoiceItemsSection.getByRole("spinbutton", {
name: "Amount (Quantity)",
})
}),
).toHaveValue(firstItem.amount.toString());
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(2)
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(2),
).toBeChecked();
// Unit field and visibility toggle
await expect(
invoiceItemsSection.getByRole("textbox", { name: "Unit" })
invoiceItemsSection.getByRole("textbox", { name: "Unit" }),
).toHaveValue(firstItem.unit);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(3)
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(3),
).toBeChecked();
// Net Price field and visibility toggle
await expect(
invoiceItemsSection.getByRole("spinbutton", {
name: "Net Price (Rate or Unit Price)",
})
}),
).toHaveValue(firstItem.netPrice.toString());
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(4)
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 })
invoiceItemsSection.getByRole("textbox", { name: "VAT", exact: true }),
).toHaveValue(firstItem.vat);
await expect(
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(5)
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(5),
).toBeChecked();
// Net Amount field (read-only) and visibility toggle
@ -392,15 +438,15 @@ test.describe("Invoice Generator Page", () => {
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)
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(6),
).toBeChecked();
// VAT Amount field (read-only) and visibility toggle
@ -408,15 +454,15 @@ test.describe("Invoice Generator Page", () => {
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)
invoiceItemsSection.getByRole("switch", { name: /Show in PDF/i }).nth(7),
).toBeChecked();
// Pre-tax Amount field (read-only) and visibility toggle
@ -424,20 +470,20 @@ test.describe("Invoice Generator Page", () => {
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)
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" })
invoiceItemsSection.getByRole("button", { name: "Add invoice item" }),
).toBeVisible();
});
@ -449,7 +495,7 @@ test.describe("Invoice Generator Page", () => {
.getByRole("button", { name: "Add invoice item" })
.click();
await expect(
invoiceItemsSection.getByText("Item 2", { exact: true })
invoiceItemsSection.getByText("Item 2", { exact: true }),
).toBeVisible();
// Fill in new item details
@ -462,7 +508,7 @@ test.describe("Invoice Generator Page", () => {
// Set up dialog handler before triggering the action
page.on("dialog", async (dialog) => {
expect(dialog.message()).toBe(
"Are you sure you want to delete invoice item #2?"
"Are you sure you want to delete invoice item #2?",
);
await dialog.accept();
});
@ -473,7 +519,7 @@ test.describe("Invoice Generator Page", () => {
.click();
await expect(
invoiceItemsSection.getByText("Item 2", { exact: true })
invoiceItemsSection.getByText("Item 2", { exact: true }),
).toBeHidden();
});
@ -499,13 +545,13 @@ test.describe("Invoice Generator Page", () => {
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`);
@ -513,7 +559,7 @@ test.describe("Invoice Generator Page", () => {
finalSection.getByRole("textbox", {
name: "Total",
exact: true,
})
}),
).toHaveValue("246.00");
});
@ -533,15 +579,15 @@ test.describe("Invoice Generator Page", () => {
await invoiceItemsSection.getByRole("textbox", { name: "Name" }).clear();
await expect(
page.getByText("Seller name is required", { exact: true })
page.getByText("Seller name is required", { exact: true }),
).toBeVisible();
await expect(
page.getByText("Buyer name is required", { exact: true })
page.getByText("Buyer name is required", { exact: true }),
).toBeVisible();
await expect(
page.getByText("Item name is required", { exact: true })
page.getByText("Item name is required", { exact: true }),
).toBeVisible();
const dateOfIssue = dayjs().format("YYYY-MM-DD");
@ -562,7 +608,7 @@ test.describe("Invoice Generator Page", () => {
// Check if the date of issue is filled in correctly
await expect(
page.getByRole("textbox", { name: "Date of Issue" })
page.getByRole("textbox", { name: "Date of Issue" }),
).toHaveValue(dateOfIssue);
// Fill in seller name
@ -578,11 +624,11 @@ test.describe("Invoice Generator Page", () => {
// Check for error messages to be hidden
await expect(
page.getByText("Seller name is required", { exact: true })
page.getByText("Seller name is required", { exact: true }),
).toBeHidden();
await expect(
page.getByText("Buyer name is required", { exact: true })
page.getByText("Buyer name is required", { exact: true }),
).toBeHidden();
});
@ -636,12 +682,12 @@ test.describe("Invoice Generator Page", () => {
"textbox",
{
name: "Value",
}
},
);
await expect(invoiceNumberValueField2).toHaveValue("TEST/2024");
await expect(
finalSection.getByRole("textbox", { name: "Notes", exact: true })
finalSection.getByRole("textbox", { name: "Notes", exact: true }),
).toHaveValue("Test note");
});
@ -659,7 +705,7 @@ test.describe("Invoice Generator Page", () => {
await expect(netAmountFormElement).toHaveText("€EUR");
await expect(
invoiceItemsSection.getByText("Preview: €0.00 (zero EUR 00/100)")
invoiceItemsSection.getByText("Preview: €0.00 (zero EUR 00/100)"),
).toBeVisible();
const currencySelect = page.getByRole("combobox", { name: "Currency" });
@ -687,8 +733,8 @@ test.describe("Invoice Generator Page", () => {
"Preview: $100.75 (one hundred USD 75/100)",
{
exact: true,
}
)
},
),
).toBeVisible();
const finalSection = page.getByTestId(`final-section`);
@ -696,7 +742,7 @@ test.describe("Invoice Generator Page", () => {
finalSection.getByRole("textbox", {
name: "Total",
exact: true,
})
}),
).toHaveValue("201.50");
});
@ -716,7 +762,7 @@ test.describe("Invoice Generator Page", () => {
const sectionElement = page.getByTestId(section.id);
await expect(sectionElement).toBeVisible();
await expect(
sectionElement.getByRole("region", { name: section.label })
sectionElement.getByRole("region", { name: section.label }),
).toBeVisible();
}
@ -735,25 +781,25 @@ test.describe("Invoice Generator Page", () => {
await expect(
page
.getByTestId("general-information-section")
.getByRole("region", { name: "General Information" })
.getByRole("region", { name: "General Information" }),
).toBeVisible();
await expect(
page
.getByTestId("seller-information-section")
.getByRole("region", { name: "Seller Information" })
.getByRole("region", { name: "Seller Information" }),
).toBeHidden();
await expect(
page
.getByTestId("buyer-information-section")
.getByRole("region", { name: "Buyer Information" })
.getByRole("region", { name: "Buyer Information" }),
).toBeVisible();
await expect(
page
.getByTestId("invoice-items-section")
.getByRole("region", { name: "Invoice Items" })
.getByRole("region", { name: "Invoice Items" }),
).toBeHidden();
// Verify the state is saved in localStorage
@ -779,25 +825,25 @@ test.describe("Invoice Generator Page", () => {
await expect(
page
.getByTestId("general-information-section")
.getByRole("region", { name: "General Information" })
.getByRole("region", { name: "General Information" }),
).toBeVisible();
await expect(
page
.getByTestId("seller-information-section")
.getByRole("region", { name: "Seller Information" })
.getByRole("region", { name: "Seller Information" }),
).toBeHidden();
await expect(
page
.getByTestId("buyer-information-section")
.getByRole("region", { name: "Buyer Information" })
.getByRole("region", { name: "Buyer Information" }),
).toBeVisible();
await expect(
page
.getByTestId("invoice-items-section")
.getByRole("region", { name: "Invoice Items" })
.getByRole("region", { name: "Invoice Items" }),
).toBeHidden();
// Toggle states after reload
@ -815,25 +861,25 @@ test.describe("Invoice Generator Page", () => {
await expect(
page
.getByTestId("general-information-section")
.getByRole("region", { name: "General Information" })
.getByRole("region", { name: "General Information" }),
).toBeHidden();
await expect(
page
.getByTestId("seller-information-section")
.getByRole("region", { name: "Seller Information" })
.getByRole("region", { name: "Seller Information" }),
).toBeVisible();
await expect(
page
.getByTestId("buyer-information-section")
.getByRole("region", { name: "Buyer Information" })
.getByRole("region", { name: "Buyer Information" }),
).toBeVisible();
await expect(
page
.getByTestId("invoice-items-section")
.getByRole("region", { name: "Invoice Items" })
.getByRole("region", { name: "Invoice Items" }),
).toBeHidden();
// Verify updated state is saved in localStorage
@ -871,20 +917,20 @@ test.describe("Invoice Generator Page", () => {
await amountInput.fill("1000000000000"); // 1 trillion
await expect(
page.getByText("Amount must not exceed 9 999 999 999.99")
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")
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")
page.getByText("Amount must not exceed 9 999 999 999.99"),
).toBeHidden();
// **NET PRICE FIELD**
@ -900,7 +946,7 @@ test.describe("Invoice Generator Page", () => {
// Test exceeding maximum value
await netPriceInput.fill("1000000000000"); // 1 trillion
await expect(
page.getByText("Net price must not exceed 100 billion")
page.getByText("Net price must not exceed 100 billion"),
).toBeVisible();
// Test zero value
@ -911,7 +957,7 @@ test.describe("Invoice Generator Page", () => {
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")
page.getByText("Net price must not exceed 100 billion"),
).toBeHidden();
// **VAT FIELD**
@ -930,7 +976,7 @@ test.describe("Invoice Generator Page", () => {
await vatInput.fill("abc");
await expect(
page.getByText("Must be a valid number (0-100) or NP or OO")
page.getByText("Must be a valid number (0-100) or NP or OO"),
).toBeVisible();
// Try valid values
@ -939,12 +985,12 @@ test.describe("Invoice Generator Page", () => {
await vatInput.fill("NP");
await expect(
page.getByText("Must be a valid number (0-100) or NP or OO")
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")
page.getByText("Must be a valid number (0-100) or NP or OO"),
).toBeHidden();
});
@ -1017,171 +1063,29 @@ test.describe("Invoice Generator Page", () => {
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("can share invoice and data is persisted in new tab", async ({
page,
context,
}) => {
// Fill in some test data
const invoiceNumberFieldset = page.getByRole("group", {
name: "Invoice Number",
});
const invoiceNumberValueField = invoiceNumberFieldset.getByRole("textbox", {
name: "Value",
});
await invoiceNumberValueField.fill("SHARE-TEST-001");
const finalSection = page.getByTestId(`final-section`);
await finalSection
.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 (Quantity)" })
.fill("5");
await invoiceItemsSection
.getByRole("spinbutton", {
name: "Net Price (Rate or Unit 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(
invoiceNumberFieldset.getByRole("textbox", { name: "Value" })
).toHaveValue("SHARE-TEST-001");
const newPageFinalSection = newPage.getByTestId(`final-section`);
await expect(
newPageFinalSection.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 (Quantity)",
})
).toHaveValue("5");
await expect(
newInvoiceItemsSection.getByRole("spinbutton", {
name: "Net Price (Rate or Unit Price)",
})
).toHaveValue("100");
await expect(
newInvoiceItemsSection.getByRole("textbox", { name: "VAT", exact: true })
).toHaveValue("23");
// Close the new page
await newPage.close();
});
test("shows notification when invoice link is broken", async ({ page }) => {
// Navigate to page with invalid data parameter
await page.goto("/?data=invalid-data-string");
// Verify error toast appears
await expect(
page.getByText("The shared invoice URL appears to be incorrect")
).toBeVisible();
// Verify error description is shown
await expect(
page.getByText(
"Please verify that you have copied the complete invoice URL. The link may be truncated or corrupted."
)
).toBeVisible();
// Verify clear URL button is present
await expect(page.getByRole("button", { name: "Clear URL" })).toBeVisible();
// Click clear URL button
await page.getByRole("button", { name: "Clear URL" }).click();
// Verify toast is dismissed
await expect(
page.getByText("The shared invoice URL appears to be incorrect")
).toBeHidden();
// Wait for URL to be cleared and verify
await expect(page).toHaveURL("/");
await expect(page).not.toHaveURL(/\?data=/);
});
});

View file

@ -12,7 +12,7 @@ test.describe("Not Found page", () => {
// Verify error message is displayed
await expect(page.getByText("404")).toBeVisible();
await expect(
page.getByRole("heading", { name: "This page could not be found." })
page.getByRole("heading", { name: "This page could not be found." }),
).toBeVisible();
// Check return home link
@ -34,7 +34,7 @@ test.describe("Not Found page", () => {
// Verify error message is displayed in English (default locale)
await expect(page.getByText("404")).toBeVisible();
await expect(
page.getByRole("heading", { name: "This page could not be found." })
page.getByRole("heading", { name: "This page could not be found." }),
).toBeVisible();
// Check return home link

View file

@ -55,13 +55,13 @@ test.describe("Seller management", () => {
// Verify all switches are checked by default
await expect(
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(0)
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(0),
).toBeChecked();
await expect(
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(1)
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(1),
).toBeChecked();
await expect(
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(2)
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(2),
).toBeChecked();
// Toggle some visibility switches
@ -80,7 +80,7 @@ test.describe("Seller management", () => {
.fill(testData.notes);
const notesSellerSwitch = manageSellerDialog.getByTestId(
`sellerNotesDialogFieldVisibilitySwitch`
`sellerNotesDialogFieldVisibilitySwitch`,
);
await expect(notesSellerSwitch).toHaveRole("switch");
@ -92,12 +92,12 @@ test.describe("Seller management", () => {
await expect(
manageSellerDialog.getByRole("switch", {
name: "Apply to Current Invoice",
})
}),
).toBeChecked();
// Cancel button is shown
await expect(
manageSellerDialog.getByRole("button", { name: "Cancel" })
manageSellerDialog.getByRole("button", { name: "Cancel" }),
).toBeVisible();
// Save seller
@ -134,7 +134,7 @@ test.describe("Seller management", () => {
// Verify success toast message is visible
await expect(
page.getByText("Seller added successfully", { exact: true })
page.getByText("Seller added successfully", { exact: true }),
).toBeVisible();
// ------- TEST SAVED DETAILS IN INVOICE FORM -------
@ -145,7 +145,7 @@ test.describe("Seller management", () => {
const nameInput = sellerForm.getByRole("textbox", { name: "Name" });
await expect(nameInput).toHaveAttribute(
"title",
"Seller details are locked. Click the Edit Seller button (Pencil icon) to modify."
"Seller details are locked. Click the Edit Seller button (Pencil icon) to modify.",
);
// Seller Name
@ -154,18 +154,18 @@ test.describe("Seller management", () => {
// Seller Address
await expect(
sellerForm.getByRole("textbox", { name: "Address" })
sellerForm.getByRole("textbox", { name: "Address" }),
).toHaveAttribute("aria-readonly", "true");
await expect(
sellerForm.getByRole("textbox", { name: "Address" })
sellerForm.getByRole("textbox", { name: "Address" }),
).toHaveValue(testData.address);
// Seller VAT Number
await expect(
sellerForm.getByRole("textbox", { name: "VAT Number" })
sellerForm.getByRole("textbox", { name: "VAT Number" }),
).toHaveAttribute("aria-readonly", "true");
await expect(
sellerForm.getByRole("textbox", { name: "VAT Number" })
sellerForm.getByRole("textbox", { name: "VAT Number" }),
).toHaveValue(testData.vatNo);
const vatNumberSwitch = sellerForm.getByTestId(`sellerVatNoFieldIsVisible`);
@ -175,22 +175,22 @@ test.describe("Seller management", () => {
// Seller Email
await expect(
sellerForm.getByRole("textbox", { name: "Email" })
sellerForm.getByRole("textbox", { name: "Email" }),
).toHaveAttribute("aria-readonly", "true");
await expect(
sellerForm.getByRole("textbox", { name: "Email" })
sellerForm.getByRole("textbox", { name: "Email" }),
).toHaveValue(testData.email);
// Seller Account Number
await expect(
sellerForm.getByRole("textbox", { name: "Account Number" })
sellerForm.getByRole("textbox", { name: "Account Number" }),
).toHaveAttribute("aria-readonly", "true");
await expect(
sellerForm.getByRole("textbox", { name: "Account Number" })
sellerForm.getByRole("textbox", { name: "Account Number" }),
).toHaveValue(testData.accountNumber);
const accountNumberSwitch = sellerForm.getByTestId(
`sellerAccountNumberFieldIsVisible`
`sellerAccountNumberFieldIsVisible`,
);
// Verify Account Number switch is visible
await expect(accountNumberSwitch).not.toBeChecked();
@ -198,14 +198,14 @@ test.describe("Seller management", () => {
// Seller SWIFT/BIC
await expect(
sellerForm.getByRole("textbox", { name: "SWIFT/BIC" })
sellerForm.getByRole("textbox", { name: "SWIFT/BIC" }),
).toHaveAttribute("aria-readonly", "true");
await expect(
sellerForm.getByRole("textbox", { name: "SWIFT/BIC" })
sellerForm.getByRole("textbox", { name: "SWIFT/BIC" }),
).toHaveValue(testData.swiftBic);
const swiftBicSwitch = sellerForm.getByTestId(
`sellerSwiftBicFieldIsVisible`
`sellerSwiftBicFieldIsVisible`,
);
// Verify SWIFT/BIC switch is visible
await expect(swiftBicSwitch).not.toBeChecked();
@ -213,14 +213,14 @@ test.describe("Seller management", () => {
// Seller Notes
await expect(
sellerForm.getByRole("textbox", { name: "Notes" })
sellerForm.getByRole("textbox", { name: "Notes" }),
).toHaveAttribute("aria-readonly", "true");
await expect(
sellerForm.getByRole("textbox", { name: "Notes" })
sellerForm.getByRole("textbox", { name: "Notes" }),
).toHaveValue(testData.notes);
const notesSwitch = sellerForm.getByTestId(
`sellerNotesInvoiceFormFieldVisibilitySwitch`
`sellerNotesInvoiceFormFieldVisibilitySwitch`,
);
// Verify Notes switch is visible
await expect(notesSwitch).toBeChecked();
@ -228,7 +228,7 @@ test.describe("Seller management", () => {
// Verify the seller appears in the dropdown
await expect(
sellerForm.getByRole("combobox", { name: "Select Seller" })
sellerForm.getByRole("combobox", { name: "Select Seller" }),
).toContainText(testData.name);
// Test edit functionality
@ -237,43 +237,43 @@ test.describe("Seller management", () => {
// ------- TEST EDIT FUNCTIONALITY IN SELLER MANAGEMENT DIALOG -------
// Verify all fields are populated in edit dialog
await expect(
manageSellerDialog.getByRole("textbox", { name: "Name" })
manageSellerDialog.getByRole("textbox", { name: "Name" }),
).toHaveValue(testData.name);
await expect(
manageSellerDialog.getByRole("textbox", { name: "Address" })
manageSellerDialog.getByRole("textbox", { name: "Address" }),
).toHaveValue(testData.address);
await expect(
manageSellerDialog.getByRole("textbox", { name: "VAT Number" })
manageSellerDialog.getByRole("textbox", { name: "VAT Number" }),
).toHaveValue(testData.vatNo);
await expect(
manageSellerDialog.getByRole("textbox", { name: "Email" })
manageSellerDialog.getByRole("textbox", { name: "Email" }),
).toHaveValue(testData.email);
await expect(
manageSellerDialog.getByRole("textbox", { name: "Account Number" })
manageSellerDialog.getByRole("textbox", { name: "Account Number" }),
).toHaveValue(testData.accountNumber);
await expect(
manageSellerDialog.getByRole("textbox", { name: "SWIFT/BIC" })
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)
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(0),
).toBeChecked();
await expect(
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(1)
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(1),
).not.toBeChecked();
await expect(
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(2)
manageSellerDialog.getByRole("switch", { name: "Show in PDF" }).nth(2),
).not.toBeChecked();
// Verify notes text
await expect(
manageSellerDialog.getByRole("textbox", { name: "Notes" })
manageSellerDialog.getByRole("textbox", { name: "Notes" }),
).toHaveValue(testData.notes);
// Verify notes visibility switch is checked
const notesManageSellerDialogFormSwitch = manageSellerDialog.getByTestId(
`sellerNotesDialogFieldVisibilitySwitch`
`sellerNotesDialogFieldVisibilitySwitch`,
);
await expect(notesManageSellerDialogFormSwitch).toBeChecked();
@ -323,7 +323,7 @@ test.describe("Seller management", () => {
// Verify seller was added
const sellerForm = page.getByTestId(`seller-information-section`);
await expect(
sellerForm.getByRole("combobox", { name: "Select Seller" })
sellerForm.getByRole("combobox", { name: "Select Seller" }),
).toContainText(testData.name);
// Click delete button
@ -349,8 +349,8 @@ test.describe("Seller management", () => {
await expect(
page.getByText(
`Are you sure you want to delete "${testData.name}" seller?`
)
`Are you sure you want to delete "${testData.name}" seller?`,
),
).toBeVisible();
// Confirm deletion
@ -358,33 +358,33 @@ test.describe("Seller management", () => {
// Verify success message
await expect(
page.getByText("Seller deleted successfully", { exact: true })
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" })
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
DEFAULT_SELLER_DATA.name,
);
await expect(
sellerForm.getByRole("textbox", { name: "Address" })
sellerForm.getByRole("textbox", { name: "Address" }),
).toHaveValue(DEFAULT_SELLER_DATA.address);
await expect(
sellerForm.getByRole("textbox", { name: "Email" })
sellerForm.getByRole("textbox", { name: "Email" }),
).toHaveValue(DEFAULT_SELLER_DATA.email);
await expect(
sellerForm.getByRole("textbox", { name: "VAT Number" })
sellerForm.getByRole("textbox", { name: "VAT Number" }),
).toHaveValue(DEFAULT_SELLER_DATA.vatNo);
await expect(
sellerForm.getByRole("textbox", { name: "Account Number" })
sellerForm.getByRole("textbox", { name: "Account Number" }),
).toHaveValue(DEFAULT_SELLER_DATA.accountNumber);
await expect(
sellerForm.getByRole("textbox", { name: "SWIFT/BIC" })
sellerForm.getByRole("textbox", { name: "SWIFT/BIC" }),
).toHaveValue(DEFAULT_SELLER_DATA.swiftBic);
});
});

View file

@ -15,11 +15,19 @@ test.describe("Stripe Invoice Sharing Logic", () => {
test("can share invoice with Stripe template and *WITHOUT* logo", async ({
page,
}) => {
// Verify default template is selected by default
await expect(page).toHaveURL("/?template=default");
// Switch to Stripe template
await page
.getByRole("combobox", { name: "Invoice Template" })
.selectOption("stripe");
// Wait for URL to be updated
await page.waitForURL("/?template=stripe");
await expect(page).toHaveURL("/?template=stripe");
// Verify share button is still enabled (no logo uploaded)
const shareButton = page.getByRole("button", {
name: "Generate a link to invoice",
@ -33,7 +41,65 @@ test.describe("Stripe Invoice Sharing Logic", () => {
// Verify URL contains shared data
await page.waitForURL((url) => url.searchParams.has("data"));
expect(page.url()).toContain("?data=");
const url = page.url();
expect(url).toContain(`?template=stripe&data=`);
// Verify data parameter is not empty
const urlObj = new URL(url);
const dataParam = urlObj.searchParams.get("data");
expect(dataParam).toBeTruthy();
expect(dataParam).not.toBe("");
// ------------------------------------------------------------
// Open URL in new tab
// ------------------------------------------------------------
const context = page.context();
const newPage = await context.newPage();
await newPage.goto(url);
const newUrl = newPage.url();
expect(newUrl).toContain(`?template=stripe&data=`);
// Verify data parameter is not empty
const newUrlObj = new URL(newUrl);
const newDataParam = newUrlObj.searchParams.get("data");
expect(newDataParam).toBeTruthy();
expect(newDataParam).not.toBe("");
// Verify stripe template UI elements are visible
const newPageGeneralInfoSection = newPage.getByTestId(
"general-information-section",
);
// Verify logo upload section is visible (but empty since no logo was shared)
await expect(
newPageGeneralInfoSection.getByText("Company Logo (Optional)"),
).toBeVisible();
// Verify payment URL section is visible
await expect(
newPageGeneralInfoSection.getByRole("textbox", {
name: "Payment Link URL (Optional)",
}),
).toBeVisible();
const finalSection = newPage.getByTestId(`final-section`);
// Verify that signature fields are hidden (there are only for default template)
await expect(
finalSection.getByRole("switch", {
name: 'Show "Person Authorized to Receive" Signature Field in the PDF',
}),
).toBeHidden();
await expect(
finalSection.getByRole("switch", {
name: 'Show "Person Authorized to Issue" Signature Field in the PDF',
}),
).toBeHidden();
// Close the new page
await newPage.close();
});
test("cannot share invoice with Stripe template and *WITH* logo", async ({
@ -49,7 +115,7 @@ test.describe("Stripe Invoice Sharing Logic", () => {
// Wait for logo to be uploaded
const generalInfoSection = page.getByTestId("general-information-section");
await expect(
generalInfoSection.getByAltText("Company logo preview")
generalInfoSection.getByAltText("Company logo preview"),
).toBeVisible();
// Verify share button is disabled
@ -60,17 +126,16 @@ test.describe("Stripe Invoice Sharing Logic", () => {
// click over share button to verify tooltip
// on mobile, we need to click the button to show the toast because it's better UX for user (you can't hover on mobile)
// eslint-disable-next-line playwright/no-force-option
await shareButton.click({ force: true });
await shareButton.click();
await expect(page.getByText("Unable to Share Invoice")).toBeVisible({
timeout: 700,
timeout: 2000,
});
await expect(
page.getByText(
"Invoices with logos cannot be shared. Please remove the logo to generate a shareable link. You can still download the invoice as PDF and share it."
)
"Invoices with logos cannot be shared. Please remove the logo to generate a shareable link. You can still download the invoice as PDF and share it.",
),
).toBeVisible();
});
@ -86,7 +151,7 @@ test.describe("Stripe Invoice Sharing Logic", () => {
await page.evaluate((base64Data) => {
const fileInput = document.querySelector(
"#logoUpload"
"#logoUpload",
) as HTMLInputElement;
if (fileInput) {
@ -105,9 +170,13 @@ test.describe("Stripe Invoice Sharing Logic", () => {
}
}, SMALL_TEST_IMAGE_BASE64);
// Wait for logo to be uploaded
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(600);
const generalInfoSection = page.getByTestId("general-information-section");
await expect(
generalInfoSection.getByAltText("Company logo preview")
generalInfoSection.getByAltText("Company logo preview"),
).toBeVisible();
// Verify share button is disabled
@ -123,7 +192,7 @@ test.describe("Stripe Invoice Sharing Logic", () => {
// Wait for logo to be removed
await expect(
generalInfoSection.getByAltText("Company logo preview")
generalInfoSection.getByAltText("Company logo preview"),
).toBeHidden();
// Verify share button is enabled again
@ -133,10 +202,18 @@ test.describe("Stripe Invoice Sharing Logic", () => {
// Test that sharing works
await shareButton.click();
await page.waitForURL((url) => url.searchParams.has("data"));
expect(page.url()).toContain("?data=");
const url = page.url();
expect(url).toContain(`?template=stripe&data=`);
// Verify data parameter is not empty
const urlObj = new URL(url);
const dataParam = urlObj.searchParams.get("data");
expect(dataParam).toBeTruthy();
expect(dataParam).not.toBe("");
await expect(
page.getByText("Invoice link copied to clipboard!")
page.getByText("Invoice link copied to clipboard!"),
).toBeVisible();
});
@ -157,7 +234,7 @@ test.describe("Stripe Invoice Sharing Logic", () => {
// Upload a logo
await page.evaluate((base64Data) => {
const fileInput = document.querySelector(
"#logoUpload"
"#logoUpload",
) as HTMLInputElement;
if (fileInput) {
const byteString = atob(base64Data.split(",")[1]);
@ -194,12 +271,14 @@ test.describe("Stripe Invoice Sharing Logic", () => {
.getByRole("combobox", { name: "Invoice Template" })
.selectOption("stripe");
await page.waitForURL("/?template=stripe");
await page.evaluate(uploadBase64LogoAsFile, SMALL_TEST_IMAGE_BASE64);
// Wait for upload and verify share button is disabled
const generalInfoSection = page.getByTestId("general-information-section");
await expect(
generalInfoSection.getByAltText("Company logo preview")
generalInfoSection.getByAltText("Company logo preview"),
).toBeVisible();
const shareButton = page.getByRole("button", {
@ -229,12 +308,14 @@ test.describe("Stripe Invoice Sharing Logic", () => {
// Reload the page
await page.reload();
await page.waitForURL("/?template=stripe");
// Verify state persists after reload
await expect(
page.getByRole("combobox", { name: "Invoice Template" })
page.getByRole("combobox", { name: "Invoice Template" }),
).toHaveValue("stripe");
await expect(
generalInfoSection.getByAltText("Company logo preview")
generalInfoSection.getByAltText("Company logo preview"),
).toBeVisible();
// Verify share button is still disabled
@ -275,8 +356,8 @@ test.describe("Stripe Invoice Sharing Logic", () => {
await expect(
page.getByText(
"Invoices with logos cannot be shared. Please remove the logo to generate a shareable link. You can still download the invoice as PDF and share it."
)
"Invoices with logos cannot be shared. Please remove the logo to generate a shareable link. You can still download the invoice as PDF and share it.",
),
).toBeVisible();
// Remove logo and verify sharing works again

View file

@ -68,24 +68,75 @@ test.describe("Stripe Invoice Template", () => {
await page.evaluate(() => localStorage.clear());
});
test("displays correct OG meta tags for Stripe template", async ({
page,
}) => {
// Navigate to Stripe template
await page.goto("/?template=stripe");
await expect(page).toHaveURL("/?template=stripe");
const templateCombobox = page.getByRole("combobox", {
name: "Invoice Template",
});
await expect(templateCombobox).toHaveValue("stripe");
// Check that OG image changed to Stripe template
await expect(page.locator('meta[property="og:image"]')).toHaveAttribute(
"content",
"https://static.easyinvoicepdf.com/stripe-og.png",
);
// Check other meta tags for Stripe template
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute(
"content",
"Stripe Invoice Template | Free Invoice Generator",
);
await expect(
page.locator('meta[property="og:description"]'),
).toHaveAttribute(
"content",
"Create and download professional invoices instantly with EasyInvoicePDF.com. Free and open-source. No signup required.",
);
await expect(page.locator('meta[property="og:site_name"]')).toHaveAttribute(
"content",
"EasyInvoicePDF.com | Free Invoice Generator",
);
// Verify OG image dimensions
await expect(
page.locator('meta[property="og:image:width"]'),
).toHaveAttribute("content", "1200");
await expect(
page.locator('meta[property="og:image:height"]'),
).toHaveAttribute("content", "630");
await expect(page.locator('meta[property="og:image:alt"]')).toHaveAttribute(
"content",
"Stripe Invoice Template",
);
});
test("logo upload section and payment link URL section only appear for Stripe template", async ({
page,
}) => {
// Verify default template is selected by default
await expect(page).toHaveURL("/?template=default");
const generalInfoSection = page.getByTestId("general-information-section");
// Initially default template - logo section should not be visible
await expect(
generalInfoSection.getByText("Company Logo (Optional)")
generalInfoSection.getByText("Company Logo (Optional)"),
).toBeHidden();
await expect(
generalInfoSection.getByTestId("stripe-logo-upload-input")
generalInfoSection.getByTestId("stripe-logo-upload-input"),
).toBeHidden();
// Payment URL section should not be visible
await expect(
generalInfoSection.getByRole("textbox", {
name: "Payment Link URL (Optional)",
})
}),
).toBeHidden();
// Switch to Stripe template
@ -93,26 +144,31 @@ test.describe("Stripe Invoice Template", () => {
.getByRole("combobox", { name: "Invoice Template" })
.selectOption("stripe");
// Wait for URL to be updated
await page.waitForURL("/?template=stripe");
await expect(page).toHaveURL("/?template=stripe");
// Logo section should now be visible
await expect(
generalInfoSection.getByTestId("stripe-logo-upload-input")
generalInfoSection.getByTestId("stripe-logo-upload-input"),
).toBeVisible();
await expect(
generalInfoSection.getByText("Company Logo (Optional)")
generalInfoSection.getByText("Company Logo (Optional)"),
).toBeVisible();
await expect(
generalInfoSection.getByText("Click to upload your company logo")
generalInfoSection.getByText("Click to upload your company logo"),
).toBeVisible();
await expect(
generalInfoSection.getByText("JPEG, PNG or WebP (max 3MB)")
generalInfoSection.getByText("JPEG, PNG or WebP (max 3MB)"),
).toBeVisible();
// Payment URL section should now be visible
await expect(
generalInfoSection.getByRole("textbox", {
name: "Payment Link URL (Optional)",
})
}),
).toBeVisible();
// Switch back to default template
@ -122,18 +178,18 @@ test.describe("Stripe Invoice Template", () => {
// Logo section should be hidden again
await expect(
generalInfoSection.getByText("Company Logo (Optional)")
generalInfoSection.getByText("Company Logo (Optional)"),
).toBeHidden();
await expect(
generalInfoSection.getByTestId("stripe-logo-upload-input")
generalInfoSection.getByTestId("stripe-logo-upload-input"),
).toBeHidden();
// Payment URL section should be hidden again
await expect(
generalInfoSection.getByRole("textbox", {
name: "Payment Link URL (Optional)",
})
}),
).toBeHidden();
});
@ -148,7 +204,7 @@ test.describe("Stripe Invoice Template", () => {
// Create a mock file input event with invalid file type
await page.evaluate(() => {
const fileInput = document.querySelector(
"#logoUpload"
"#logoUpload",
) as HTMLInputElement;
if (fileInput) {
// Create a mock file with invalid type
@ -164,7 +220,7 @@ test.describe("Stripe Invoice Template", () => {
// Should show error toast
await expect(
page.getByText("Please select a valid image file (JPEG, PNG or WebP)")
page.getByText("Please select a valid image file (JPEG, PNG or WebP)"),
).toBeVisible();
});
@ -179,7 +235,7 @@ test.describe("Stripe Invoice Template", () => {
// Create a mock file input event with large file
await page.evaluate(() => {
const fileInput = document.querySelector(
"#logoUpload"
"#logoUpload",
) as HTMLInputElement;
if (fileInput) {
// Create a mock file that's too large (4MB)
@ -198,7 +254,7 @@ test.describe("Stripe Invoice Template", () => {
// Should show error toast
await expect(
page.getByText("Image size must be less than 3MB")
page.getByText("Image size must be less than 3MB"),
).toBeVisible();
});
@ -220,22 +276,22 @@ test.describe("Stripe Invoice Template", () => {
// Should show logo preview
await expect(
generalInfoSection.getByAltText("Company logo preview")
generalInfoSection.getByAltText("Company logo preview"),
).toBeVisible();
await expect(
generalInfoSection.getByText(
"Logo uploaded successfully. Click the X to remove it."
)
"Logo uploaded successfully. Click the X to remove it.",
),
).toBeVisible();
// Should show remove button
await expect(
generalInfoSection.getByRole("button", { name: "Remove logo" })
generalInfoSection.getByRole("button", { name: "Remove logo" }),
).toBeVisible();
// Upload area should be hidden
await expect(
generalInfoSection.getByText("Click to upload your company logo")
generalInfoSection.getByText("Click to upload your company logo"),
).toBeHidden();
});
@ -252,7 +308,7 @@ test.describe("Stripe Invoice Template", () => {
// Wait for logo to be uploaded
await expect(
generalInfoSection.getByAltText("Company logo preview")
generalInfoSection.getByAltText("Company logo preview"),
).toBeVisible();
// Click remove button
@ -265,12 +321,12 @@ test.describe("Stripe Invoice Template", () => {
// Logo preview should be hidden
await expect(
generalInfoSection.getByAltText("Company logo preview")
generalInfoSection.getByAltText("Company logo preview"),
).toBeHidden();
// Upload area should be visible again
await expect(
generalInfoSection.getByText("Click to upload your company logo")
generalInfoSection.getByText("Click to upload your company logo"),
).toBeVisible();
});
@ -340,7 +396,7 @@ test.describe("Stripe Invoice Template", () => {
const pdfData = await pdf(dataBuffer);
expect((pdfData.info as { Title: string }).Title).toContain(
`Invoice 1/${CURRENT_MONTH_AND_YEAR} | Created with https://easyinvoicepdf.com`
`Invoice 1/${CURRENT_MONTH_AND_YEAR} | Created with https://easyinvoicepdf.com`,
);
expect(pdfData.text).toContain("Invoice");
@ -355,7 +411,7 @@ test.describe("Stripe Invoice Template", () => {
expect(pdfData.text).toContain(
"Account Number: Seller account num-\nber\n" +
"SWIFT/BIC number: Seller swift bic\n" +
"Bill to\n"
"Bill to\n",
);
expect(pdfData.text).toContain("Bill to");
@ -371,7 +427,7 @@ test.describe("Stripe Invoice Template", () => {
expect(pdfData.text).toContain("DescriptionQtyUnit PriceAmount");
expect(pdfData.text).toContain("Item name");
expect(pdfData.text).toContain(
`${START_OF_CURRENT_MONTH} ${LAST_DAY_OF_CURRENT_MONTH}`
`${START_OF_CURRENT_MONTH} ${LAST_DAY_OF_CURRENT_MONTH}`,
);
expect(pdfData.text).toContain("1€0.00€0.00");
expect(pdfData.text).toContain("Subtotal€0.00");
@ -379,7 +435,7 @@ test.describe("Stripe Invoice Template", () => {
expect(pdfData.text).toContain("Amount Due€0.00");
expect(pdfData.text).toContain("Reverse charge");
expect(pdfData.text).toContain(
`1/${CURRENT_MONTH_AND_YEAR}·€0.00 due ${PAYMENT_DATE}·Created with https://easyinvoicepdf.comPage 1 of 1`
`1/${CURRENT_MONTH_AND_YEAR}·€0.00 due ${PAYMENT_DATE}·Created with https://easyinvoicepdf.comPage 1 of 1`,
);
});
@ -407,7 +463,7 @@ test.describe("Stripe Invoice Template", () => {
// Should not show error for valid URL
await expect(paymentUrlInput).toHaveValue(
"https://buy.stripe.com/test_payment_link"
"https://buy.stripe.com/test_payment_link",
);
});
@ -429,7 +485,7 @@ test.describe("Stripe Invoice Template", () => {
// Wait a moment for any debounced localStorage updates
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(500);
await page.waitForTimeout(600);
// Verify data is actually saved in localStorage
const storedData = (await page.evaluate((key) => {
@ -449,19 +505,19 @@ test.describe("Stripe Invoice Template", () => {
// Verify template is still Stripe
await expect(
page.getByRole("combobox", { name: "Invoice Template" })
page.getByRole("combobox", { name: "Invoice Template" }),
).toHaveValue("stripe");
// Verify payment URL persists
await expect(
generalInfoSection.getByRole("textbox", {
name: "Payment Link URL (Optional)",
})
}),
).toHaveValue("https://buy.stripe.com/test_payment_link");
// Verify logo persists
await expect(
generalInfoSection.getByAltText("Company logo preview")
generalInfoSection.getByAltText("Company logo preview"),
).toBeVisible();
});
});

View file

@ -10,7 +10,7 @@ export const SMALL_TEST_IMAGE_BASE64 =
export const uploadBase64LogoAsFile = (base64Data: string) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const fileInput = document.querySelector(
"#logoUpload"
"#logoUpload",
) as HTMLInputElement | null;
if (!fileInput) {

View file

@ -97,5 +97,5 @@ export default tseslint.config(
projectService: true,
},
},
}
},
);

View file

@ -15,7 +15,6 @@ const config: KnipConfig = {
"@ianvs/prettier-plugin-sort-imports",
"react-email",
"react-scan",
"@stagewise/toolbar-next",
],
ignore: [
"lint-staged.config.js",

View file

@ -4,7 +4,7 @@ module.exports = {
"*": () => [
`pnpm run type-check:go`,
`pnpm run lint`,
`pnpm run knip`,
// `pnpm run knip`, // TODO: temporarily disabled due to issues with knip
`pnpm run prettify --write`,
],
};

View file

@ -11,7 +11,7 @@
"badge": "Features",
"title": "Everything you need for professional invoicing",
"description": "Our simple yet powerful invoice generator includes all the features you need to create professional invoices quickly.",
"comingSoon": "Pro version and API coming soon",
"comingSoon": "E-invoices support coming soon",
"items": {
"livePreview": {
"title": "Live Preview",

View file

@ -22,7 +22,7 @@ async function validatei18nAndTranslationFiles() {
// Import the translations schema using jiti
// @ts-ignore
const { translationsSchema, TRANSLATIONS } = await loadTsFileViaJiti.import(
"./src/app/schema/translations.ts"
"./src/app/schema/translations.ts",
);
const result = translationsSchema.safeParse(TRANSLATIONS);
@ -41,7 +41,7 @@ async function validatei18nAndTranslationFiles() {
// Import the messages schema using jiti
// @ts-ignore
const { messagesSchema } = await loadTsFileViaJiti.import(
"./src/app/schema/i18n-schema.ts"
"./src/app/schema/i18n-schema.ts",
);
// Validate messages
@ -52,7 +52,7 @@ async function validatei18nAndTranslationFiles() {
const validationPromises = is18nJSONMessageFiles.map(async (file) => {
try {
const messages = JSON.parse(
await fs.promises.readFile(path.join(messagesDir, file), "utf8")
await fs.promises.readFile(path.join(messagesDir, file), "utf8"),
);
const result = messagesSchema.safeParse(messages);
@ -83,7 +83,7 @@ async function validatei18nAndTranslationFiles() {
const hasErrors = results.some(
(result) =>
result.status === "rejected" ||
(result.status === "fulfilled" && !result.value.success)
(result.status === "fulfilled" && !result.value.success),
);
if (hasErrors) {
@ -93,7 +93,7 @@ async function validatei18nAndTranslationFiles() {
} else if (!result.value.success) {
console.error(
`❌ Invalid i18n messages in ${result.value.file}:`,
result.value.error
result.value.error,
);
}
});

View file

@ -2,7 +2,7 @@
"name": "pdf-invoice-generator",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.9.0",
"packageManager": "pnpm@10.14.0",
"engines": {
"node": ">=20.0.0"
},
@ -17,7 +17,8 @@
"prettify": "prettier --write --cache '**/*.{ts?(x),json,js,mjs,yml,yaml,md}'",
"knip": "knip",
"update-deps": "pnpm upgrade --interactive --latest",
"test": "pnpm exec playwright test --reporter=list",
"vitest": "vitest --reporter=verbose",
"vitest:ui": "vitest --ui",
"e2e": "pnpm exec playwright test --reporter=list",
"e2e:ui": "pnpm exec playwright test --ui",
"dedupe": "pnpm dedupe",
@ -31,6 +32,7 @@
"@hookform/resolvers": "3.9.0",
"@mdx-js/loader": "3.1.0",
"@mdx-js/react": "3.1.0",
"@microlink/react-json-view": "1.27.0",
"@next/mdx": "15.3.3",
"@radix-ui/react-accordion": "1.2.3",
"@radix-ui/react-alert-dialog": "1.1.6",
@ -88,15 +90,14 @@
"@eslint/eslintrc": "3.3.1",
"@next/eslint-plugin-next": "15.2.3",
"@playwright/test": "1.52.0",
"@stagewise/toolbar-next": "0.1.2",
"@types/file-saver": "2.0.7",
"@types/node": "22.8.1",
"@types/pdf-parse": "1.1.5",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript/native-preview": "7.0.0-dev.20250525.1",
"@typescript/native-preview": "7.0.0-dev.20250819.1",
"autoprefixer": "10.4.21",
"eslint": "9.26.0",
"eslint": "9.33.0",
"eslint-config-next": "15.2.3",
"eslint-plugin-playwright": "2.2.0",
"eslint-plugin-react-you-might-not-need-an-effect": "0.0.39",
@ -110,7 +111,8 @@
"react-scan": "0.3.4",
"schema-dts": "1.1.5",
"tailwindcss": "3.4.14",
"typescript": "5.8.3",
"typescript-eslint": "8.32.0"
"typescript": "5.9.2",
"typescript-eslint": "8.40.0",
"vitest": "3.2.4"
}
}

File diff suppressed because it is too large Load diff

View file

@ -39,7 +39,7 @@ export async function subscribeAction(formData: FormData) {
// Check email rate limit
const emailLimit = await checkRateLimit(
validatedFields.email.toLowerCase(),
emailLimiter
emailLimiter,
);
if (!emailLimit.success) {
return { error: emailLimit.error };
@ -53,7 +53,7 @@ export async function subscribeAction(formData: FormData) {
if (
existingContacts?.data?.some(
(contact) =>
contact.email.toLowerCase() === validatedFields.email.toLowerCase()
contact.email.toLowerCase() === validatedFields.email.toLowerCase(),
)
) {
return { error: "This email is already subscribed." };

View file

@ -49,7 +49,7 @@ export function customPremiumToast(toast: Omit<ToastProps, "id">) {
),
{
duration: Infinity,
}
},
);
}
@ -71,12 +71,12 @@ export function customDefaultToast(toast: Omit<ToastProps, "id">) {
),
{
duration: Infinity,
}
},
);
}
const SonnerCloseButton = (
props: React.ButtonHTMLAttributes<HTMLButtonElement>
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
) => {
return (
<button
@ -142,7 +142,7 @@ function PremiumDonationToast(props: ToastProps) {
<PremiumToastDonationButton
onClick={() => {
umamiTrackEvent(
"donate_btn_click_download_pdf_toast_premium"
"donate_btn_click_download_pdf_toast_premium",
);
sonnerToast.dismiss(id);
@ -152,7 +152,7 @@ function PremiumDonationToast(props: ToastProps) {
<PremiumToastFeedbackButton
onClick={() => {
umamiTrackEvent(
"feedback_btn_click_download_pdf_toast_premium"
"feedback_btn_click_download_pdf_toast_premium",
);
sonnerToast.dismiss(id);
@ -163,7 +163,7 @@ function PremiumDonationToast(props: ToastProps) {
<PremiumToastFeedbackButton
onClick={() => {
umamiTrackEvent(
"feedback_btn_click_download_pdf_toast_premium"
"feedback_btn_click_download_pdf_toast_premium",
);
sonnerToast.dismiss(id);
@ -177,7 +177,7 @@ function PremiumDonationToast(props: ToastProps) {
}
function PremiumToastFeedbackButton(
props: React.AnchorHTMLAttributes<HTMLAnchorElement>
props: React.AnchorHTMLAttributes<HTMLAnchorElement>,
) {
return (
<Button
@ -200,7 +200,7 @@ function PremiumToastFeedbackButton(
}
function PremiumToastDonationButton(
props: React.AnchorHTMLAttributes<HTMLAnchorElement>
props: React.AnchorHTMLAttributes<HTMLAnchorElement>,
) {
return (
<Button
@ -272,7 +272,7 @@ function DefaultDonationToast(props: ToastProps) {
<DefaultToastDonationButton
onClick={() => {
umamiTrackEvent(
"donate_btn_click_download_pdf_toast_default"
"donate_btn_click_download_pdf_toast_default",
);
sonnerToast.dismiss(id);
@ -282,7 +282,7 @@ function DefaultDonationToast(props: ToastProps) {
<DefaultToastFeedbackButton
onClick={() => {
umamiTrackEvent(
"feedback_btn_click_download_pdf_toast_default"
"feedback_btn_click_download_pdf_toast_default",
);
sonnerToast.dismiss(id);
@ -293,7 +293,7 @@ function DefaultDonationToast(props: ToastProps) {
<DefaultToastFeedbackButton
onClick={() => {
umamiTrackEvent(
"feedback_btn_click_download_pdf_toast_default"
"feedback_btn_click_download_pdf_toast_default",
);
sonnerToast.dismiss(id);
@ -307,7 +307,7 @@ function DefaultDonationToast(props: ToastProps) {
}
function DefaultToastDonationButton(
props: React.AnchorHTMLAttributes<HTMLAnchorElement>
props: React.AnchorHTMLAttributes<HTMLAnchorElement>,
) {
return (
<Button
@ -330,7 +330,7 @@ function DefaultToastDonationButton(
}
function DefaultToastFeedbackButton(
props: React.AnchorHTMLAttributes<HTMLAnchorElement>
props: React.AnchorHTMLAttributes<HTMLAnchorElement>,
) {
return (
<Button

View file

@ -0,0 +1,261 @@
"use client";
import { Button } from "@/components/ui/button";
import { CustomTooltip, TooltipProvider } from "@/components/ui/tooltip";
import { initializeLocalStorageDebugger } from "@/lib/localStorage-debug-listener";
import ReactJsonView from "@microlink/react-json-view";
import {
ChevronDown,
ChevronUp,
Database,
Move,
RefreshCw,
X,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
interface DraggablePosition {
x: number;
y: number;
}
/**
* This component is used to view the localStorage data in a draggable window.
* It is only visible in development mode.
*
*/
export function DevLocalStorageView() {
const [isOpen, setIsOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [localStorageData, setLocalStorageData] = useState<
Record<string, unknown>
>({});
const [position, setPosition] = useState<DraggablePosition>({ x: 20, y: 20 });
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [lastUpdated, setLastUpdated] = useState<string>("");
const windowRef = useRef<HTMLDivElement>(null);
const dragHandleRef = useRef<HTMLDivElement>(null);
const getLocalStorageData = useCallback(() => {
const data: Record<string, unknown> = {};
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
// we only want to show the keys from localStorage that starts with EASY_INVOICE
if (key?.startsWith("EASY_INVOICE")) {
const value = localStorage.getItem(key);
try {
// Try to parse as JSON first
data[key] = JSON.parse(value || "");
} catch {
// If not JSON, store as string
data[key] = value;
}
}
}
} catch (error) {
return { error: "Failed to access localStorage", details: error };
}
return data;
}, []);
// Function to update localStorage data
const updateLocalStorageReactState = useCallback(() => {
setLocalStorageData(getLocalStorageData() || {});
setLastUpdated(new Date().toLocaleTimeString());
}, [getLocalStorageData]);
// Initialize localStorage data and set up listeners
useEffect(() => {
// Initialize the localStorage debugger for listening to localStorage changes in the same tab
initializeLocalStorageDebugger();
updateLocalStorageReactState();
// Listen for storage events (changes from other tabs/windows)
const handleStorageChange = () => {
updateLocalStorageReactState();
};
// Listen for custom localStorage events (changes from same tab)
const handleCustomStorageChange = () => {
updateLocalStorageReactState();
};
window.addEventListener("storage", handleStorageChange);
window.addEventListener("local-storage-change", handleCustomStorageChange);
return () => {
window.removeEventListener("storage", handleStorageChange);
window.removeEventListener(
"local-storage-change",
handleCustomStorageChange,
);
};
}, [updateLocalStorageReactState]);
// Mouse event handlers for dragging
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!dragHandleRef.current?.contains(e.target as Node)) return;
setIsDragging(true);
const rect = windowRef.current?.getBoundingClientRect();
if (rect) {
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}
}, []);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
setPosition({
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y,
});
};
const handleMouseUp = () => {
setIsDragging(false);
};
if (isDragging) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "none";
}
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "";
};
}, [isDragging, dragOffset]);
return (
<TooltipProvider>
{/* Toggle Button */}
<CustomTooltip
trigger={
<Button
onClick={() => setIsOpen(!isOpen)}
_size="sm"
_variant="outline"
className="fixed bottom-2 left-2 isolate z-[50] border border-gray-300 bg-gray-100 shadow-sm backdrop-blur-sm transition-all duration-200 animate-in fade-in slide-in-from-bottom-2 hover:border-gray-400 hover:bg-gray-200 hover:shadow-md"
>
<Database className="h-4 w-4 text-gray-600" />
</Button>
}
content="View localStorage data"
/>
{/* Draggable Window */}
{isOpen ? (
<div
ref={windowRef}
className={`fixed isolate z-[52] rounded-lg border border-gray-300 bg-white shadow-lg ${
isDragging ? "cursor-grabbing" : ""
}`}
style={{
left: `${position.x}px`,
top: `${position.y}px`,
width: isExpanded ? "650px" : "500px",
height: isExpanded ? "550px" : "400px",
minWidth: "300px",
minHeight: "200px",
}}
onMouseDown={handleMouseDown}
>
{/* Header with drag handle */}
<div
ref={dragHandleRef}
className="flex cursor-grab items-center justify-between rounded-t-lg border-b border-gray-200 bg-gray-50 p-3 active:cursor-grabbing"
>
<div className="flex items-center gap-2">
<Move className="h-4 w-4 text-gray-500" />
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-700">
localStorage Viewer
</span>
<span className="text-xs text-gray-500">
{Object.keys(localStorageData).length} items
{lastUpdated && ` • Last updated: ${lastUpdated}`}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<CustomTooltip
trigger={
<Button
onClick={updateLocalStorageReactState}
_size="sm"
_variant="ghost"
className="h-6 w-6 p-0"
>
<RefreshCw className="h-3 w-3" />
</Button>
}
content="Refresh data"
/>
<CustomTooltip
trigger={
<Button
onClick={() => setIsExpanded(!isExpanded)}
_size="sm"
_variant="ghost"
className="h-6 w-6 p-0"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronUp className="h-3 w-3" />
)}
</Button>
}
content={isExpanded ? "Collapse" : "Expand"}
/>
<CustomTooltip
trigger={
<Button
onClick={() => setIsOpen(false)}
_size="sm"
_variant="ghost"
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
}
content="Close"
/>
</div>
</div>
{/* Content */}
<div className="h-full overflow-hidden p-3">
<div className="h-[calc(100%-60px)] overflow-y-auto">
<ReactJsonView
src={localStorageData}
theme="rjv-default"
collapsed={isExpanded ? false : 1}
displayDataTypes={false}
displayObjectSize={true}
enableClipboard={false}
style={{
fontSize: "12px",
lineHeight: "1.4",
}}
/>
</div>
</div>
</div>
) : null}
</TooltipProvider>
);
}

View file

@ -35,7 +35,7 @@ const InvoicePDFViewer = dynamic(
{
ssr: false,
loading: () => <DesktopPDFViewerModuleLoading />,
}
},
);
const AndroidPDFViewer = dynamic(
@ -43,7 +43,7 @@ const AndroidPDFViewer = dynamic(
{
ssr: false,
loading: () => <AndroidPDFViewerModuleLoading />,
}
},
);
const PdfViewer = ({
@ -131,7 +131,7 @@ export function InvoiceClientPage({
<div className="h-[480px] overflow-auto rounded-lg border-b px-3 shadow-sm">
<InvoiceForm
invoiceData={invoiceDataState}
onInvoiceDataChange={handleInvoiceDataChange}
handleInvoiceDataChange={handleInvoiceDataChange}
setCanShareInvoice={setCanShareInvoice}
/>
</div>
@ -210,7 +210,7 @@ export function InvoiceClientPage({
<div className="h-[620px] overflow-auto border-b px-3 pl-0 2xl:h-[700px]">
<InvoiceForm
invoiceData={invoiceDataState}
onInvoiceDataChange={handleInvoiceDataChange}
handleInvoiceDataChange={handleInvoiceDataChange}
setCanShareInvoice={setCanShareInvoice}
/>
</div>

View file

@ -63,13 +63,13 @@ type AccordionKeys = Array<(typeof DEFAULT_ACCORDION_VALUES)[number]>;
interface InvoiceFormProps {
invoiceData: InvoiceData;
onInvoiceDataChange: (updatedData: InvoiceData) => void;
handleInvoiceDataChange: (updatedData: InvoiceData) => void;
setCanShareInvoice: (canShareInvoice: boolean) => void;
}
export const InvoiceForm = memo(function InvoiceForm({
invoiceData,
onInvoiceDataChange,
handleInvoiceDataChange,
setCanShareInvoice,
}: InvoiceFormProps) {
const form = useForm<InvoiceData>({
@ -96,7 +96,7 @@ export const InvoiceForm = memo(function InvoiceForm({
const selectedDateFormat = useWatch({ control, name: "dateFormat" });
const isPaymentDueBeforeDateOfIssue = dayjs(paymentDue).isBefore(
dayjs(dateOfIssue)
dayjs(dateOfIssue),
);
// payment due date is 14 days after the date of issue or the same day
@ -124,7 +124,7 @@ export const InvoiceForm = memo(function InvoiceForm({
? Number(
invoiceItems
.reduce((sum, item) => sum + (item?.preTaxAmount || 0), 0)
.toFixed(2)
.toFixed(2),
)
: 0;
@ -177,7 +177,7 @@ export const InvoiceForm = memo(function InvoiceForm({
}
},
// debounce delay in ms
DEBOUNCE_TIMEOUT
DEBOUNCE_TIMEOUT,
);
// subscribe to form changes to regenerate pdf on every input change
@ -211,12 +211,12 @@ export const InvoiceForm = memo(function InvoiceForm({
const currentFormData = watch();
debouncedRegeneratePdfOnFormChange(currentFormData);
},
[remove, watch, debouncedRegeneratePdfOnFormChange]
[remove, watch, debouncedRegeneratePdfOnFormChange],
);
// TODO: refactor this and debouncedRegeneratePdfOnFormChange(), so data is saved to local storage, basically copy everything from debouncedRegeneratePdfOnFormChange() and use this onSubmit function in two places
const onSubmit = (data: InvoiceData) => {
onInvoiceDataChange(data);
handleInvoiceDataChange(data);
};
/**
@ -230,7 +230,7 @@ export const InvoiceForm = memo(function InvoiceForm({
// Try to load from localStorage
try {
const savedState = localStorage.getItem(
ACCORDION_STATE_LOCAL_STORAGE_KEY
ACCORDION_STATE_LOCAL_STORAGE_KEY,
);
if (savedState) {
@ -273,7 +273,7 @@ export const InvoiceForm = memo(function InvoiceForm({
localStorage.setItem(
ACCORDION_STATE_LOCAL_STORAGE_KEY,
JSON.stringify(stateToSave)
JSON.stringify(stateToSave),
);
} catch (error) {
console.error("Error saving accordion state:", error);
@ -310,7 +310,7 @@ export const InvoiceForm = memo(function InvoiceForm({
if (Array.isArray(error)) {
return error.map((item, index) =>
Object.entries(
item as { [key: string]: { message?: string } }
item as { [key: string]: { message?: string } },
).map(([fieldName, fieldError]) => (
<li
key={`${key}.${index}.${fieldName}`}
@ -318,14 +318,14 @@ export const InvoiceForm = memo(function InvoiceForm({
>
{fieldError?.message || "Unknown error"}
</li>
))
)),
);
}
// Handle nested object errors
if (error && typeof error === "object") {
return Object.entries(
error as { [key: string]: { message?: string } }
error as { [key: string]: { message?: string } },
).map(([nestedKey, nestedError]) => {
return (
<li key={`${key}.${nestedKey}`} className="text-sm">
@ -342,7 +342,7 @@ export const InvoiceForm = memo(function InvoiceForm({
</div>,
{
closeButton: true,
}
},
);
})}
>
@ -636,53 +636,58 @@ export const InvoiceForm = memo(function InvoiceForm({
)}
</div>
<div>
<div className="relative mt-5 space-y-4">
{/* Show/hide Person Authorized to Receive field in PDF switch */}
<div className="flex items-center justify-between">
<Label htmlFor={`personAuthorizedToReceiveFieldIsVisible`}>
Show &quot;Person Authorized to Receive&quot; Signature Field in
the PDF
</Label>
{/*
Stripe template doesn't have these fields
*/}
{invoiceData.template === "default" && (
<div>
<div className="relative mt-5 space-y-4">
{/* Show/hide Person Authorized to Receive field in PDF switch */}
<div className="flex items-center justify-between">
<Label htmlFor={`personAuthorizedToReceiveFieldIsVisible`}>
Show &quot;Person Authorized to Receive&quot; Signature Field
in the PDF
</Label>
<Controller
name={`personAuthorizedToReceiveFieldIsVisible`}
control={control}
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`personAuthorizedToReceiveFieldIsVisible`}
checked={value}
onCheckedChange={onChange}
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
/>
)}
/>
</div>
<Controller
name={`personAuthorizedToReceiveFieldIsVisible`}
control={control}
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`personAuthorizedToReceiveFieldIsVisible`}
checked={value}
onCheckedChange={onChange}
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
/>
)}
/>
</div>
{/* Show/hide Person Authorized to Issue field in PDF switch */}
<div className="flex items-center justify-between">
<Label htmlFor={`personAuthorizedToIssueFieldIsVisible`}>
Show &quot;Person Authorized to Issue&quot; Signature Field in
the PDF
</Label>
{/* Show/hide Person Authorized to Issue field in PDF switch */}
<div className="flex items-center justify-between">
<Label htmlFor={`personAuthorizedToIssueFieldIsVisible`}>
Show &quot;Person Authorized to Issue&quot; Signature Field in
the PDF
</Label>
<Controller
name={`personAuthorizedToIssueFieldIsVisible`}
control={control}
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`personAuthorizedToIssueFieldIsVisible`}
checked={value}
onCheckedChange={onChange}
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
/>
)}
/>
<Controller
name={`personAuthorizedToIssueFieldIsVisible`}
control={control}
render={({ field: { value, onChange, ...field } }) => (
<Switch
{...field}
id={`personAuthorizedToIssueFieldIsVisible`}
checked={value}
onCheckedChange={onChange}
className="h-5 w-8 [&_span]:size-4 [&_span]:data-[state=checked]:translate-x-3 rtl:[&_span]:data-[state=checked]:-translate-x-3"
/>
)}
/>
</div>
</div>
</div>
</div>
)}
</div>
</form>
);
@ -703,7 +708,7 @@ const calculateItemTotals = (item: InvoiceItemData | null) => {
const formattedVatAmount = Number(vatAmount.toFixed(2));
const formattedPreTaxAmount = Number(
(formattedNetAmount + formattedVatAmount).toFixed(2)
(formattedNetAmount + formattedVatAmount).toFixed(2),
);
return {

View file

@ -101,7 +101,7 @@ export const GeneralInformation = memo(function GeneralInformation({
const isDateOfServiceEqualsEndOfCurrentMonth = dayjs(dateOfService).isSame(
dayjs().endOf("month"),
"day"
"day",
);
const isDefaultInvoiceNumberLabel =
@ -109,7 +109,7 @@ export const GeneralInformation = memo(function GeneralInformation({
// extract the month and year from the invoice number (i.e. 1/04-2025 -> 04-2025)
const extractInvoiceMonthAndYear = /(\d{2}-\d{4})/.exec(
invoiceNumberValue ?? ""
invoiceNumberValue ?? "",
)?.[1];
const isInvoiceNumberInCurrentMonth =
@ -144,7 +144,7 @@ export const GeneralInformation = memo(function GeneralInformation({
toast.error("Error uploading image. Please try again.");
}
},
[setValue]
[setValue],
);
const handleLogoRemove = useCallback(() => {
@ -325,7 +325,7 @@ export const GeneralInformation = memo(function GeneralInformation({
// we need to keep the invoice number suffix (e.g. 1/MM-YYYY) for better user experience, when switching language
setValue(
"invoiceNumberObject.label",
`${newInvoiceNumberLabel}:`
`${newInvoiceNumberLabel}:`,
);
setValue("invoiceNumberObject.value", invoiceNumberValue);
}}
@ -460,7 +460,7 @@ export const GeneralInformation = memo(function GeneralInformation({
onClick={() => {
setValue(
"invoiceNumberObject.label",
defaultInvoiceNumber
defaultInvoiceNumber,
);
}}
>
@ -504,7 +504,7 @@ export const GeneralInformation = memo(function GeneralInformation({
onClick={() => {
setValue(
"invoiceNumberObject.value",
`1/${CURRENT_MONTH_AND_YEAR}`
`1/${CURRENT_MONTH_AND_YEAR}`,
);
}}
>

View file

@ -114,14 +114,14 @@ export const InvoiceItems = memo(function InvoiceItems({
type="button"
onClick={() => {
const canDelete = window.confirm(
`Are you sure you want to delete invoice item #${index + 1}?`
`Are you sure you want to delete invoice item #${index + 1}?`,
);
if (canDelete) {
handleRemoveItem(index);
}
}}
className="flex items-center justify-center rounded-full bg-red-600 p-2 transition-colors hover:bg-red-700"
className="flex items-center justify-center rounded-full bg-red-600 p-2 transition-colors hover:bg-red-700 active:scale-[98%] active:transition-transform"
>
<span className="sr-only">
Delete Invoice Item {index + 1}

View file

@ -123,7 +123,7 @@ export function InvoicePDFDownloadLink({
if (!pdfLoading) {
const timer = setTimeout(
() => setIsLoading(false),
LOADING_BUTTON_TIMEOUT
LOADING_BUTTON_TIMEOUT,
);
return () => clearTimeout(timer);
}
@ -155,13 +155,13 @@ export function InvoicePDFDownloadLink({
onClick={handleClick}
className={cn(
"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",
"shadow-sm shadow-black/5 outline-offset-2 hover:bg-slate-900/90 active:scale-[98%] active:transition-transform",
"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]",
{
"pointer-events-none opacity-70": isLoading,
"lg:w-[240px]": invoiceData.language === "pt",
}
},
)}
>
<ButtonContent isLoading={isLoading} language={invoiceData.language} />

View file

@ -46,7 +46,7 @@ export function InvoicePDFDownloadMultipleLanguages({
}, [language]);
const generateAndZipPDFs = async (
selectedLanguages: SupportedLanguages[]
selectedLanguages: SupportedLanguages[],
) => {
try {
// Generate PDF documents for each selected language
@ -83,7 +83,7 @@ export function InvoicePDFDownloadMultipleLanguages({
selectedLanguages.forEach((lang, index) => {
zip.file(
`invoice-${lang}-${invoiceNumberFormatted}.pdf`,
pdfBlobs[index]
pdfBlobs[index],
);
});

View file

@ -30,7 +30,7 @@ export function StripeDueAmount({
dayjs.locale(language);
const paymentDueDate = dayjs(invoiceData.paymentDue).format(
invoiceData.dateFormat
invoiceData.dateFormat,
);
// Check if payOnlineUrl is provided and valid

View file

@ -22,7 +22,7 @@ export function StripeFooter({
const invoiceNumber = `${invoiceNumberValue}`;
const paymentDueDate = dayjs(invoiceData.paymentDue).format(
invoiceData.dateFormat
invoiceData.dateFormat,
);
return (

View file

@ -16,13 +16,13 @@ export function StripeInvoiceInfo({
const t = TRANSLATIONS[language];
const dateOfIssue = dayjs(invoiceData.dateOfIssue).format(
invoiceData.dateFormat
invoiceData.dateFormat,
);
const invoiceNumberValue = invoiceData?.invoiceNumberObject?.value;
const paymentDueDate = dayjs(invoiceData.paymentDue).format(
invoiceData.dateFormat
invoiceData.dateFormat,
);
// for better readability, we need to adjust the column width based on the language

View file

@ -31,7 +31,7 @@ export function StripeItemsTable({
// Check if any items have numeric VAT values (not "NP" or "OO")
const hasNumericVat = invoiceData.items.some(
(item) => typeof item.vat === "number"
(item) => typeof item.vat === "number",
);
// Calculate service period (example: Jan 01 2025 - Jan 31 2025)
@ -40,7 +40,7 @@ export function StripeItemsTable({
.format(invoiceData.dateFormat);
const servicePeriodEnd = dayjs(invoiceData.dateOfService).format(
invoiceData.dateFormat
invoiceData.dateFormat,
);
const vatAmountFieldIsVisible = invoiceData.items[0].vatFieldIsVisible;

View file

@ -19,7 +19,7 @@ export function StripeTotals({
// Calculate subtotal (sum of all items)
const subtotal = invoiceData.items.reduce(
(sum, item) => sum + item.netAmount,
0
0,
);
const formattedSubtotal = formatCurrency({
amount: subtotal,
@ -35,7 +35,7 @@ export function StripeTotals({
// Check if any items have numeric VAT values (not "NP" or "OO")
const hasNumericVat = invoiceData.items.some(
(item) => typeof item.vat === "number"
(item) => typeof item.vat === "number",
);
return (

View file

@ -19,7 +19,7 @@ export function InvoiceFooter({
const invoiceNumberValue = invoiceData?.invoiceNumberObject?.value;
const paymentDueDate = dayjs(invoiceData.paymentDue).format(
invoiceData.dateFormat
invoiceData.dateFormat,
);
const invoiceTotal = invoiceData?.total;

View file

@ -16,10 +16,10 @@ export function InvoiceHeader({
const t = TRANSLATIONS[language];
const dateOfIssue = dayjs(invoiceData.dateOfIssue).format(
invoiceData.dateFormat
invoiceData.dateFormat,
);
const dateOfService = dayjs(invoiceData.dateOfService).format(
invoiceData.dateFormat
invoiceData.dateFormat,
);
const invoiceNumberLabel = invoiceData?.invoiceNumberObject?.label;

View file

@ -16,7 +16,7 @@ export function InvoicePaymentInfo({
const t = TRANSLATIONS[language];
const paymentDate = dayjs(invoiceData.paymentDue).format(
invoiceData.dateFormat
invoiceData.dateFormat,
);
const paymentMethodIsVisible = invoiceData.paymentMethodFieldIsVisible;

View file

@ -35,7 +35,7 @@ export function InvoiceVATSummaryTable({
const totalNetAmount = sortedItems.reduce(
(acc, item) => acc + item.netAmount,
0
0,
);
const formattedTotalNetAmount = totalNetAmount
.toLocaleString("en-US", {
@ -46,7 +46,7 @@ export function InvoiceVATSummaryTable({
const totalVATAmount = sortedItems.reduce(
(acc, item) => acc + item.vatAmount,
0
0,
);
const formattedTotalVATAmount = totalVATAmount
.toLocaleString("en-US", {

View file

@ -24,7 +24,7 @@ export default function Error({
{
closeButton: true,
richColors: true,
}
},
);
}, [error]);
@ -60,7 +60,7 @@ export default function Error({
// Clear the invoice data and start from scratch
localStorage.setItem(
PDF_DATA_LOCAL_STORAGE_KEY,
JSON.stringify(INITIAL_INVOICE_DATA)
JSON.stringify(INITIAL_INVOICE_DATA),
);
// Attempt to recover by trying to re-render the segment

View file

@ -4,11 +4,9 @@ import { INITIAL_INVOICE_DATA } from "@/app/constants";
import {
invoiceSchema,
PDF_DATA_LOCAL_STORAGE_KEY,
SUPPORTED_LANGUAGES,
SUPPORTED_TEMPLATES,
type InvoiceData,
} from "@/app/schema";
import { TRANSLATIONS } from "@/app/schema/translations";
import { GithubIcon } from "@/components/etc/github-logo";
import { ProjectLogo } from "@/components/etc/project-logo";
import { Button } from "@/components/ui/button";
import {
@ -24,6 +22,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { Footer } from "@/components/footer";
import { GitHubStarCTA } from "@/components/github-star-cta";
import { ProjectLogoDescription } from "@/components/project-logo-description";
import { GITHUB_URL, VIDEO_DEMO_URL } from "@/config";
import { isLocalStorageAvailable } from "@/lib/check-local-storage";
@ -35,8 +34,13 @@ import {
compressToEncodedURIComponent,
decompressFromEncodedURIComponent,
} from "lz-string";
import {
compressInvoiceData,
decompressInvoiceData,
} from "@/utils/url-compression";
import dynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { InvoiceClientPage } from "./components";
@ -45,73 +49,35 @@ import {
customPremiumToast,
} from "./components/cta-toasts";
import { InvoicePDFDownloadLink } from "./components/invoice-pdf-download-link";
import { handleInvoiceNumberBreakingChange } from "./utils/invoice-number-breaking-change";
// import { DevLocalStorageView } from "./components/dev/dev-local-storage-view";
// import { InvoicePDFDownloadMultipleLanguages } from "./components/invoice-pdf-download-multiple-languages";
/**
* This function handles the breaking change of the invoice number field.
* It removes the old "invoiceNumber" field and adds the new "invoiceNumberObject" field with label and value.
* @param json - The JSON object to handle the breaking change.
* @returns The updated JSON object.
*/
function handleInvoiceNumberBreakingChange(json: unknown) {
// check if the invoice number is in the json
if (
typeof json === "object" &&
json !== null &&
"invoiceNumber" in json &&
typeof json.invoiceNumber === "string" &&
"language" in json
) {
umamiTrackEvent("breaking_change_detected");
let lang: keyof typeof TRANSLATIONS;
const invoiceLanguage = z
.enum(SUPPORTED_LANGUAGES)
.safeParse(json.language);
if (!invoiceLanguage.success) {
console.error("Invalid invoice language:", invoiceLanguage.error);
// fallback to default language
lang = SUPPORTED_LANGUAGES[0];
} else {
lang = invoiceLanguage.data;
}
const invoiceNumberLabel = TRANSLATIONS[lang].invoiceNumber;
// Create new object without invoiceNumber and with invoiceNumberObject
const newJson = {
...json,
// assign invoiceNumber to invoiceNumberObject.value
invoiceNumberObject: {
label: `${invoiceNumberLabel}:`,
value: json.invoiceNumber,
},
};
// remove deprecated invoiceNumber from json
delete (newJson as Record<string, unknown>).invoiceNumber;
// update json
json = newJson;
return json;
}
return json;
}
const DevLocalStorageView = dynamic(
() =>
import("./components/dev/dev-local-storage-view").then(
(mod) => mod.DevLocalStorageView,
),
{ ssr: false },
);
export function AppPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
const urlTemplateSearchParam = searchParams.get("template");
// Validate template parameter with zod
const templateValidation = z
.enum(SUPPORTED_TEMPLATES)
.default("default")
.safeParse(urlTemplateSearchParam);
const { isDesktop } = useDeviceContext();
const isMobile = !isDesktop;
const [invoiceDataState, setInvoiceDataState] = useState<InvoiceData | null>(
null
null,
);
const [errorWhileGeneratingPdfIsShown, setErrorWhileGeneratingPdfIsShown] =
@ -119,6 +85,55 @@ export function AppPageClient() {
const [canShareInvoice, setCanShareInvoice] = useState(true);
// Helper function to load from localStorage
const loadFromLocalStorage = useCallback(() => {
try {
const savedData = localStorage.getItem(PDF_DATA_LOCAL_STORAGE_KEY);
if (savedData) {
const json: unknown = JSON.parse(savedData);
// this should happen before parsing the data with zod
const updatedJson = handleInvoiceNumberBreakingChange(json);
const parsedData = invoiceSchema.parse(updatedJson);
// if template is in url, use it
if (templateValidation.success) {
parsedData.template = templateValidation.data;
}
setInvoiceDataState(parsedData);
} else {
if (templateValidation.success) {
// if no data in local storage and template is in url, set initial data with template from url
setInvoiceDataState({
...INITIAL_INVOICE_DATA,
template: templateValidation.data,
});
} else {
// if no data in local storage, set initial data
setInvoiceDataState(INITIAL_INVOICE_DATA);
}
}
} catch (error) {
console.error("Failed to load saved invoice data:", error);
setInvoiceDataState(INITIAL_INVOICE_DATA);
toast.error(
"Unable to load your saved invoice data. For your convenience, we've reset the form to default values. Please try creating a new invoice.",
{
duration: 20000,
closeButton: true,
richColors: true,
},
);
Sentry.captureException(error);
}
}, [templateValidation.data, templateValidation.success]);
// Scroll to top on mount
useEffect(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
@ -127,20 +142,37 @@ export function AppPageClient() {
// Initialize data from URL or localStorage on mount
useEffect(() => {
const compressedInvoiceDataInUrl = searchParams.get("data");
const urlTemplateSearchParam = searchParams.get("template");
// Validate template parameter with zod
const templateValidation = z
.enum(SUPPORTED_TEMPLATES)
.default("default")
.safeParse(urlTemplateSearchParam);
// first try to load from url
if (compressedInvoiceDataInUrl) {
try {
const decompressed = decompressFromEncodedURIComponent(
compressedInvoiceDataInUrl
compressedInvoiceDataInUrl,
);
const parsedJSON: unknown = JSON.parse(decompressed);
// Restore original keys from compressed format, we store keys in compressed format to reduce URL size i.e. {name: "John Doe"} -> {n: "John Doe"}
const decompressedKeys = decompressInvoiceData(
parsedJSON as Record<string, unknown>,
);
// this should happen before parsing the data with zod
const updatedJson = handleInvoiceNumberBreakingChange(parsedJSON);
const updatedJson = handleInvoiceNumberBreakingChange(decompressedKeys);
const validated = invoiceSchema.parse(updatedJson);
if (templateValidation.success) {
validated.template = templateValidation.data;
}
setInvoiceDataState(validated);
} catch (error) {
// fallback to local storage
@ -153,42 +185,7 @@ export function AppPageClient() {
// if no data in url, load from local storage
loadFromLocalStorage();
}
}, [searchParams]);
// Helper function to load from localStorage
const loadFromLocalStorage = () => {
try {
const savedData = localStorage.getItem(PDF_DATA_LOCAL_STORAGE_KEY);
if (savedData) {
const json: unknown = JSON.parse(savedData);
// this should happen before parsing the data with zod
const updatedJson = handleInvoiceNumberBreakingChange(json);
const parsedData = invoiceSchema.parse(updatedJson);
setInvoiceDataState(parsedData);
} else {
// if no data in local storage, set initial data
setInvoiceDataState(INITIAL_INVOICE_DATA);
}
} catch (error) {
console.error("Failed to load saved invoice data:", error);
setInvoiceDataState(INITIAL_INVOICE_DATA);
toast.error(
"Unable to load your saved invoice data. For your convenience, we've reset the form to default values. Please try creating a new invoice.",
{
duration: 20000,
closeButton: true,
richColors: true,
}
);
Sentry.captureException(error);
}
};
}, [loadFromLocalStorage, searchParams]);
// Save to localStorage whenever data changes on form update
useEffect(() => {
@ -199,18 +196,30 @@ export function AppPageClient() {
localStorage.setItem(
PDF_DATA_LOCAL_STORAGE_KEY,
JSON.stringify(newInvoiceDataValidated)
JSON.stringify(newInvoiceDataValidated),
);
// Check if URL has data and current data is different
// Update template in search params if it exists
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set("template", newInvoiceDataValidated.template);
router.replace(`/?${newSearchParams.toString()}`);
// Check if URL has data i.e. if user has shared invoice link
const urlData = searchParams.get("data");
if (urlData) {
try {
const decompressed = decompressFromEncodedURIComponent(urlData);
const urlParsed: unknown = JSON.parse(decompressed);
const urlValidated = invoiceSchema.parse(urlParsed);
// Restore original keys from compressed format
const decompressedKeys = decompressInvoiceData(
urlParsed as Record<string, unknown>,
);
const urlValidated = invoiceSchema.parse(decompressedKeys);
if (
JSON.stringify(urlValidated) !==
@ -232,7 +241,7 @@ export function AppPageClient() {
duration: 10000,
closeButton: true,
richColors: true,
}
},
);
// Clean URL if data differs
@ -241,7 +250,9 @@ export function AppPageClient() {
} catch (error) {
console.error("Failed to compare with URL data:", error);
// TODO: move to 'Initialize data from URL or localStorage on mount' useEffect?
toast.error("The shared invoice URL appears to be incorrect", {
id: "invalid-invoice-url-error-toast", // prevent duplicate toasts
description: (
<div className="flex flex-col gap-2">
<p className="">
@ -252,7 +263,7 @@ export function AppPageClient() {
_variant="outline"
_size="sm"
onClick={() => {
router.replace("/");
router.replace("/?template=default");
toast.dismiss();
}}
>
@ -302,8 +313,8 @@ export function AppPageClient() {
}
};
// Show cta toast after 40 seconds on the app page
const initialTimer = setTimeout(showCTAToast, 40_000);
// Show cta toast after 50 seconds on the app page
const initialTimer = setTimeout(showCTAToast, 50_000);
return () => {
clearTimeout(initialTimer);
@ -335,11 +346,16 @@ export function AppPageClient() {
if (invoiceDataState) {
try {
const newInvoiceDataValidated = invoiceSchema.parse(invoiceDataState);
const stringified = JSON.stringify(newInvoiceDataValidated);
const compressedData = compressToEncodedURIComponent(stringified);
// Compress JSON keys before stringifying to reduce URL size
const compressedKeys = compressInvoiceData(newInvoiceDataValidated);
const compressedJson = JSON.stringify(compressedKeys);
const compressedData = compressToEncodedURIComponent(compressedJson);
// Check if the compressed data length exceeds browser URL limits
// Most browsers have a limit around 2000 characters for URLs
// With key compression, we can fit much larger invoices within this limit
const URL_LENGTH_LIMIT = 2000;
const estimatedUrlLength =
window.location.origin.length + 7 + compressedData.length; // 7 for "/?data="
@ -351,14 +367,19 @@ export function AppPageClient() {
return;
}
router.push(`/?data=${compressedData}`);
router.push(
`/?template=${newInvoiceDataValidated.template}&data=${compressedData}`,
);
// Construct full URL with locale and compressed data
const newFullUrl = `${window.location.origin}/?data=${compressedData}`;
const newFullUrl = `${window.location.origin}/?template=${newInvoiceDataValidated.template}&data=${compressedData}`;
// Copy to clipboard
await navigator.clipboard.writeText(newFullUrl);
// Dismiss any existing toast before showing new one
toast.dismiss();
toast.success("Invoice link copied to clipboard!", {
description:
"Share this link to let others view and edit this invoice",
@ -382,6 +403,10 @@ export function AppPageClient() {
return (
<TooltipProvider delayDuration={0}>
{process.env.NEXT_PUBLIC_DEBUG_LOCAL_STORAGE_UI === "true" && (
<DevLocalStorageView />
)}
<div className="flex flex-col items-center justify-start bg-gray-100 pb-4 sm:p-4 md:justify-center lg:min-h-screen">
<div className="w-full max-w-7xl bg-white p-3 shadow-lg sm:mb-0 sm:rounded-lg sm:p-6 2xl:max-w-[1680px]">
<div data-testid="header">
@ -398,7 +423,7 @@ export function AppPageClient() {
<div className="mb-1 flex w-full flex-wrap justify-center gap-3 lg:flex-nowrap lg:justify-end">
<Button
asChild
className="mx-2 w-full bg-blue-500 text-white transition-all hover:scale-105 hover:bg-blue-600 hover:no-underline lg:mx-0 lg:w-auto"
className="mx-2 w-full bg-blue-500 text-white transition-all hover:no-underline hover:opacity-90 lg:mx-0 lg:w-auto"
_variant="link"
onClick={() => {
// analytics track event
@ -427,7 +452,7 @@ export function AppPageClient() {
onClick={handleShareInvoice}
_variant="outline"
className={cn(
"mx-2 mb-2 w-full lg:mx-0 lg:mb-0 lg:w-auto"
"mx-2 mb-2 w-full lg:mx-0 lg:mb-0 lg:w-auto",
)}
>
Generate a link to invoice
@ -565,6 +590,7 @@ export function AppPageClient() {
</ul>
}
/>
<GitHubStarCTA />
</TooltipProvider>
);
}
@ -580,18 +606,12 @@ function ProjectInfo() {
return (
<>
<span className="relative bottom-0 text-center text-sm text-gray-900 lg:bottom-3">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="group inline-flex items-center gap-1"
title="View on GitHub"
<button
onClick={handleWatchDemoClick}
className="inline-flex items-center gap-1.5 transition-colors hover:text-blue-600 hover:underline"
>
<span className="transition-all group-hover:text-blue-600 group-hover:underline">
Open Source
</span>
<GithubIcon />
</a>
<span>How it works</span>
</button>
{" | "}
<a
href="https://dub.sh/easy-invoice-pdf-feedback"
@ -600,13 +620,6 @@ function ProjectInfo() {
>
Share your feedback
</a>
{" | "}
<button
onClick={handleWatchDemoClick}
className="inline-flex items-center gap-1.5 transition-colors hover:text-blue-600 hover:underline"
>
<span>How it works</span>
</button>
</span>
<Dialog open={isVideoDialogOpen} onOpenChange={setIsVideoDialogOpen}>

View file

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { AppPageClient } from "./page.client";
import { STATIC_ASSETS_URL } from "@/config";
// we generate metadata here, because we need access to searchParams (in layout we don't have it)
export async function generateMetadata({
@ -8,7 +9,12 @@ export async function generateMetadata({
searchParams: { [key: string]: string | string[] | undefined };
}): Promise<Metadata> {
const hasShareableData = Boolean(searchParams?.data);
const isProd = process.env.VERCEL_ENV === "production";
const isStripeTemplate = Boolean(searchParams?.template === "stripe");
const isProd =
process.env.VERCEL_ENV === "production" &&
`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` ===
"https://easyinvoicepdf.com";
const defaultRobotsConfig = {
index: false,
@ -36,6 +42,22 @@ export async function generateMetadata({
alternates: {
canonical: "/",
},
...(isStripeTemplate && {
openGraph: {
title: "Stripe Invoice Template | Free Invoice Generator",
description:
"Create and download professional invoices instantly with EasyInvoicePDF.com. Free and open-source. No signup required.",
siteName: "EasyInvoicePDF.com | Free Invoice Generator",
images: [
{
url: `${STATIC_ASSETS_URL}/stripe-og.png`,
width: 1200,
height: 630,
alt: "Stripe Invoice Template",
},
],
},
}),
};
}

View file

@ -0,0 +1,372 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleInvoiceNumberBreakingChange } from "../invoice-number-breaking-change";
import { SUPPORTED_LANGUAGES, type InvoiceData } from "@/app/schema";
import { TRANSLATIONS } from "@/app/schema/translations";
// Mock the umami tracking function
vi.mock("@/lib/umami-analytics-track-event", () => ({
umamiTrackEvent: vi.fn(),
}));
import { umamiTrackEvent } from "@/lib/umami-analytics-track-event";
describe("handleInvoiceNumberBreakingChange", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("valid input scenarios", () => {
it("should transform invoiceNumber to invoiceNumberObject with English language", () => {
const input = {
invoiceNumber: "INV-2024-001",
language: "en",
otherField: "preserved",
};
const result = handleInvoiceNumberBreakingChange(input);
expect(result).toEqual({
language: "en",
otherField: "preserved",
invoiceNumberObject: {
label: `${TRANSLATIONS.en.invoiceNumber}:`,
value: "INV-2024-001",
},
});
// Should not contain the old invoiceNumber field
expect(result).not.toHaveProperty("invoiceNumber");
// Should track the breaking change event
expect(umamiTrackEvent).toHaveBeenCalledWith("breaking_change_detected");
expect(umamiTrackEvent).toHaveBeenCalledTimes(1);
});
it("should transform invoiceNumber with Polish language", () => {
const input = {
invoiceNumber: "FAKT-001",
language: "pl",
};
const result = handleInvoiceNumberBreakingChange(input);
expect(result).toEqual({
language: "pl",
invoiceNumberObject: {
label: `${TRANSLATIONS.pl.invoiceNumber}:`,
value: "FAKT-001",
},
});
expect(umamiTrackEvent).toHaveBeenCalledWith("breaking_change_detected");
});
it("should transform invoiceNumber with German language", () => {
const input = {
invoiceNumber: "RG-2024-001",
language: "de",
};
const result = handleInvoiceNumberBreakingChange(input);
expect(result).toEqual({
language: "de",
invoiceNumberObject: {
label: `${TRANSLATIONS.de.invoiceNumber}:`,
value: "RG-2024-001",
},
});
expect(umamiTrackEvent).toHaveBeenCalledWith("breaking_change_detected");
});
it("should preserve all other fields when transforming", () => {
const input = {
invoiceNumber: "123",
language: "en",
dateOfIssue: "2024-01-15",
seller: { name: "ACME Corp" },
buyer: { name: "Client Ltd" },
items: [{ name: "Product A", amount: 1 }],
total: 100,
};
const result = handleInvoiceNumberBreakingChange(input);
expect(result).toEqual({
language: "en",
dateOfIssue: "2024-01-15",
seller: { name: "ACME Corp" },
buyer: { name: "Client Ltd" },
items: [{ name: "Product A", amount: 1 }],
total: 100,
invoiceNumberObject: {
label: `${TRANSLATIONS.en.invoiceNumber}:`,
value: "123",
},
});
});
});
describe("invalid language scenarios", () => {
it("should fallback to default language when language is invalid", () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {
// do nothing
});
const input = {
invoiceNumber: "INV-001",
language: "invalid-lang",
};
const result = handleInvoiceNumberBreakingChange(input);
const defaultLanguage = SUPPORTED_LANGUAGES[0];
expect(result).toEqual({
language: "invalid-lang",
invoiceNumberObject: {
label: `${TRANSLATIONS[defaultLanguage].invoiceNumber}:`,
value: "INV-001",
},
});
// Should log error for invalid language
expect(consoleSpy).toHaveBeenCalledWith(
"Invalid invoice language:",
expect.any(Object),
);
// Should still track the breaking change event
expect(umamiTrackEvent).toHaveBeenCalledWith("breaking_change_detected");
consoleSpy.mockRestore();
});
it("should fallback to default language when language is not a string", () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {
// do nothing
});
const input = {
invoiceNumber: "INV-001",
language: 123,
};
const result = handleInvoiceNumberBreakingChange(input);
const defaultLanguage = SUPPORTED_LANGUAGES[0];
expect(result).toEqual({
language: 123,
invoiceNumberObject: {
label: `${TRANSLATIONS[defaultLanguage].invoiceNumber}:`,
value: "INV-001",
},
});
expect(consoleSpy).toHaveBeenCalled();
expect(umamiTrackEvent).toHaveBeenCalledWith("breaking_change_detected");
consoleSpy.mockRestore();
});
});
describe("no transformation scenarios", () => {
it("should return unchanged when invoiceNumber field is missing", () => {
const input = {
language: "en",
dateOfIssue: "2024-01-15",
seller: { name: "ACME Corp" },
};
const result = handleInvoiceNumberBreakingChange(input);
expect(result).toBe(input);
expect(umamiTrackEvent).not.toHaveBeenCalled();
});
it("should return unchanged when language field is missing", () => {
const input = {
invoiceNumber: "INV-001",
dateOfIssue: "2024-01-15",
};
const result = handleInvoiceNumberBreakingChange(input);
expect(result).toBe(input);
expect(umamiTrackEvent).not.toHaveBeenCalled();
});
it("should return unchanged when invoiceNumber is not a string", () => {
const input = {
invoiceNumber: 123,
language: "en",
};
const result = handleInvoiceNumberBreakingChange(input);
expect(result).toBe(input);
expect(umamiTrackEvent).not.toHaveBeenCalled();
});
it("should transform even when invoiceNumber is empty string", () => {
const input = {
invoiceNumber: "",
language: "en",
};
const result = handleInvoiceNumberBreakingChange(input);
expect(result).toEqual({
language: "en",
invoiceNumberObject: {
label: `${TRANSLATIONS.en.invoiceNumber}:`,
value: "",
},
});
expect(umamiTrackEvent).toHaveBeenCalledWith("breaking_change_detected");
});
it("should return unchanged when input is null", () => {
const result = handleInvoiceNumberBreakingChange(null);
expect(result).toBe(null);
expect(umamiTrackEvent).not.toHaveBeenCalled();
});
it("should return unchanged when input is undefined", () => {
const result = handleInvoiceNumberBreakingChange(undefined);
expect(result).toBe(undefined);
expect(umamiTrackEvent).not.toHaveBeenCalled();
});
it("should return unchanged when input is not an object", () => {
const stringInput = "test";
const numberInput = 42;
const booleanInput = true;
expect(handleInvoiceNumberBreakingChange(stringInput)).toBe(stringInput);
expect(handleInvoiceNumberBreakingChange(numberInput)).toBe(numberInput);
expect(handleInvoiceNumberBreakingChange(booleanInput)).toBe(
booleanInput,
);
expect(umamiTrackEvent).not.toHaveBeenCalled();
});
it("should return unchanged when input is an array", () => {
const arrayInput = [1, 2, 3];
const result = handleInvoiceNumberBreakingChange(arrayInput);
expect(result).toBe(arrayInput);
expect(umamiTrackEvent).not.toHaveBeenCalled();
});
});
describe("edge cases", () => {
it("should handle input with existing invoiceNumberObject field", () => {
const input = {
invoiceNumber: "OLD-001",
language: "en",
invoiceNumberObject: {
label: "Existing Label:",
value: "Existing Value",
},
} as unknown as InvoiceData;
const result = handleInvoiceNumberBreakingChange(input);
// Should overwrite the existing invoiceNumberObject
expect(result).toEqual({
language: "en",
invoiceNumberObject: {
label: `${TRANSLATIONS.en.invoiceNumber}:`,
value: "OLD-001",
},
});
expect(umamiTrackEvent).toHaveBeenCalledWith("breaking_change_detected");
});
it("should handle all supported languages correctly", () => {
SUPPORTED_LANGUAGES.forEach((lang) => {
const input = {
invoiceNumber: `INV-${lang}`,
language: lang,
};
const result = handleInvoiceNumberBreakingChange(input);
expect(result).toEqual({
language: lang,
invoiceNumberObject: {
label: `${TRANSLATIONS[lang].invoiceNumber}:`,
value: `INV-${lang}`,
},
});
});
// Should track one event per language
expect(umamiTrackEvent).toHaveBeenCalledTimes(SUPPORTED_LANGUAGES.length);
});
it("should handle special characters in invoiceNumber", () => {
const input = {
invoiceNumber: "INV/2024\\001-#@!",
language: "en",
};
const result = handleInvoiceNumberBreakingChange(input);
expect((result as InvoiceData).invoiceNumberObject?.value).toBe(
"INV/2024\\001-#@!",
);
});
it("should handle very long invoiceNumber", () => {
const longInvoiceNumber = "A".repeat(1000);
const input = {
invoiceNumber: longInvoiceNumber,
language: "en",
};
const result = handleInvoiceNumberBreakingChange(input);
expect((result as InvoiceData).invoiceNumberObject?.value).toBe(
longInvoiceNumber,
);
});
});
describe("type safety", () => {
it("should maintain proper typing after transformation", () => {
const input = {
invoiceNumber: "INV-001",
language: "en" as const,
numericField: 42,
booleanField: true,
arrayField: [1, 2, 3],
objectField: { nested: "value" },
};
const result = handleInvoiceNumberBreakingChange(input);
expect(typeof result).toBe("object");
expect(result).not.toBe(null);
if (typeof result === "object" && result !== null) {
expect("invoiceNumberObject" in result).toBe(true);
expect("invoiceNumber" in result).toBe(false);
if ("invoiceNumberObject" in result) {
const invoiceNumberObject = (result as unknown as InvoiceData)
.invoiceNumberObject;
expect(typeof invoiceNumberObject?.label).toBe("string");
expect(typeof invoiceNumberObject?.value).toBe("string");
}
}
});
});
});

View file

@ -0,0 +1,66 @@
import { SUPPORTED_LANGUAGES } from "@/app/schema";
import { TRANSLATIONS } from "@/app/schema/translations";
import { umamiTrackEvent } from "@/lib/umami-analytics-track-event";
import { z } from "zod";
/**
* This function handles the breaking change of the invoice number field.
* It removes the old "invoiceNumber" field and adds the new "invoiceNumberObject" field with label and value.
*
* @example
* ```typescript
* const json = { invoiceNumber: "123", language: "en" };
* const updatedJson = handleInvoiceNumberBreakingChange(json);
* // Returns: { invoiceNumberObject: { label: "Invoice Number:", value: "123" }
* ```
*/
export function handleInvoiceNumberBreakingChange(json: unknown) {
// check if the invoice number is in the json
if (
typeof json === "object" &&
json !== null &&
"invoiceNumber" in json &&
typeof json.invoiceNumber === "string" &&
"language" in json
) {
umamiTrackEvent("breaking_change_detected");
let lang: keyof typeof TRANSLATIONS;
const invoiceLanguage = z
.enum(SUPPORTED_LANGUAGES)
.safeParse(json.language);
if (!invoiceLanguage.success) {
console.error("Invalid invoice language:", invoiceLanguage.error);
// fallback to default language
lang = SUPPORTED_LANGUAGES[0];
} else {
lang = invoiceLanguage.data;
}
const invoiceNumberLabel = TRANSLATIONS[lang].invoiceNumber;
// Create new object without invoiceNumber and with invoiceNumberObject
const newJson = {
...json,
// assign invoiceNumber to invoiceNumberObject.value
invoiceNumberObject: {
label: `${invoiceNumberLabel}:`,
value: json.invoiceNumber,
},
};
// remove deprecated invoiceNumber from json
delete (newJson as Record<string, unknown>).invoiceNumber;
// update json
json = newJson;
return json;
}
return json;
}

View file

@ -85,7 +85,7 @@ export function LanguageSwitcher({
startTransition(() => {
const pathnameWithoutLocale = pathname.replace(
`/${locale}`,
""
"",
);
router.push(pathnameWithoutLocale || "/", {

View file

@ -373,7 +373,7 @@ function FeaturesSection() {
{FEATURES_CARDS.map((feature) => {
const title = t(`features.items.${feature.translationKey}.title`);
const description = t(
`features.items.${feature.translationKey}.description`
`features.items.${feature.translationKey}.description`,
);
return (

View file

@ -21,7 +21,7 @@ export default function Error({ error, reset }: Props) {
{
closeButton: true,
richColors: true,
}
},
);
}, [error]);

View file

@ -23,6 +23,7 @@ import {
import { env } from "@/env";
import { ipLimiter } from "@/lib/rate-limit";
import { compressInvoiceData } from "@/utils/url-compression";
export const dynamic = "force-dynamic";
@ -53,18 +54,18 @@ export async function GET(req: NextRequest) {
headers: {
"Content-Type": "application/json",
},
}
},
);
}
const GENERATED_ENGLISH_INVOICE_PDF_DOCUMENT = renderToBuffer(
<InvoicePdfTemplateToRenderOnBackend
invoiceData={ENGLISH_INVOICE_REAL_DATA}
/>
/>,
).catch((err) => {
console.error(
"\n\n_________________________Error during `renderToBuffer` for English invoice:",
err
err,
);
throw err;
@ -73,11 +74,11 @@ export async function GET(req: NextRequest) {
const GENERATED_POLISH_INVOICE_PDF_DOCUMENT = renderToBuffer(
<InvoicePdfTemplateToRenderOnBackend
invoiceData={POLISH_INVOICE_REAL_DATA}
/>
/>,
).catch((err) => {
console.error(
"\n\n_________________________Error during `renderToBuffer` for Polish invoice:",
err
err,
);
throw err;
@ -96,7 +97,7 @@ export async function GET(req: NextRequest) {
]).catch((err) => {
console.error(
"\n\n_________________________Error during `Promise.allSettled`:",
err
err,
);
})) || [];
@ -111,7 +112,7 @@ export async function GET(req: NextRequest) {
} else if (invoice.status === "rejected") {
console.error(
"\n\n_________________________Error in generate-invoice route:",
invoice?.reason || "Unknown error"
invoice?.reason || "Unknown error",
);
}
}
@ -134,17 +135,21 @@ export async function GET(req: NextRequest) {
if (!ATTACHMENTS.length) {
return NextResponse.json(
{ error: "No attachments found" },
{ status: 400 }
{ status: 400 },
);
}
const newInvoiceDataValidated = invoiceSchema.parse(
ENGLISH_INVOICE_REAL_DATA
ENGLISH_INVOICE_REAL_DATA,
);
const stringified = JSON.stringify(newInvoiceDataValidated);
const compressedData = compressToEncodedURIComponent(stringified);
const invoiceUrl = `https://easyinvoicepdf.com/?data=${compressedData}`;
// Compress JSON keys before stringifying to reduce URL size
const compressedKeys = compressInvoiceData(newInvoiceDataValidated);
const compressedJson = JSON.stringify(compressedKeys);
const compressedData = compressToEncodedURIComponent(compressedJson);
const invoiceUrl = `https://easyinvoicepdf.com/?template=${newInvoiceDataValidated.template}&data=${compressedData}`;
const monthAndYear = dayjs().format("MMMM YYYY");
@ -173,27 +178,31 @@ export async function GET(req: NextRequest) {
fileName: attachment.filename,
fileContent: Buffer.from(attachment.content),
folderId: folderToUploadInvoices.id,
})
}),
);
const uploadResults = await Promise.allSettled(uploadPromises);
const failedUploads = uploadResults.filter(
(result): result is PromiseRejectedResult => result.status === "rejected"
(result): result is PromiseRejectedResult => result.status === "rejected",
);
if (failedUploads.length > 0) {
console.error(
"Some files failed to upload to Google Drive:",
failedUploads
failedUploads,
);
return NextResponse.json(
{ error: "Failed to upload invoices to Google Drive" },
{ status: 500 }
{ status: 500 },
);
}
const companyEmailLink = `https://outlook.office.com/mail/deeplink/compose?to=${env.INVOICE_EMAIL_COMPANY_TO}&subject=Invoice%20for%20${monthAndYear}&body=Hello%2C%0A%0AInvoice%20for%20${monthAndYear}%20in%20attachments%0A%0AHave%20a%20nice%20day`;
const companyEmailLink =
`https://outlook.office.com/mail/deeplink/compose` +
`?to=${encodeURIComponent(env.INVOICE_EMAIL_COMPANY_TO)}` +
`&subject=${encodeURIComponent(`Invoice for ${monthAndYear}`)}` +
`&body=${encodeURIComponent(`Hello,\nThe invoice for ${monthAndYear} is in the attachment.\n\nHave a nice day.`)}`;
// we only need the value of the invoice number e.g. 1/05.2025
const invoiceNumberValue =
@ -258,7 +267,7 @@ EasyInvoicePDF.com`,
]);
const failedNotifications = notificationResults.filter(
(result): result is PromiseRejectedResult => result.status === "rejected"
(result): result is PromiseRejectedResult => result.status === "rejected",
);
if (failedNotifications.length > 0) {
@ -269,14 +278,14 @@ EasyInvoicePDF.com`,
return NextResponse.json(
{ message: "Invoice sent successfully" },
{ status: 200 }
{ status: 200 },
);
} catch (error) {
console.error("Error in generate-invoice route:", error);
return NextResponse.json(
{ error: "Failed to generate and send invoice" },
{ status: 500 }
{ status: 500 },
);
}
}

View file

@ -173,7 +173,7 @@ export default async function ChangelogEntryPage({
rel="noopener noreferrer"
className="transition-all hover:scale-110"
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(
`EasyInvoicePDF: ${entry.metadata.title || `Update ${formattedDate}`}`
`EasyInvoicePDF: ${entry.metadata.title || `Update ${formattedDate}`}`,
)}&url=${encodeURIComponent(`${APP_URL}/changelog/${slug}`)}`}
>
<svg
@ -196,10 +196,8 @@ export default async function ChangelogEntryPage({
rel="noopener noreferrer"
className="transition-all hover:scale-110"
href={`http://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(
`${APP_URL}/changelog/${slug}`
)}&title=${encodeURIComponent(
`EasyInvoicePDF: ${entry.metadata.title || `Update ${formattedDate}`}`
)}`}
`${APP_URL}/changelog/${slug}`,
)}&title=${encodeURIComponent(`EasyInvoicePDF: ${entry.metadata.title || `Update ${formattedDate}`}`)}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -220,10 +218,8 @@ export default async function ChangelogEntryPage({
rel="noopener noreferrer"
className="transition-all hover:scale-110"
href={`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(
`${APP_URL}/changelog/${slug}`
)}&title=${encodeURIComponent(
`EasyInvoicePDF: ${entry.metadata.title || `Update ${formattedDate}`}`
)}`}
`${APP_URL}/changelog/${slug}`,
)}&title=${encodeURIComponent(`EasyInvoicePDF: ${entry.metadata.title || `Update ${formattedDate}`}`)}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -245,7 +241,7 @@ export default async function ChangelogEntryPage({
rel="noopener noreferrer"
className="transition-all hover:scale-110"
href={`https://news.ycombinator.com/submitlink?u=${encodeURIComponent(
`${APP_URL}/changelog/${slug}`
`${APP_URL}/changelog/${slug}`,
)}&t=${encodeURIComponent(`EasyInvoicePDF: ${entry.metadata.title || `Update ${formattedDate}`}`)}`}
>
<svg

View file

@ -26,7 +26,7 @@ async function getChangelogFiles(): Promise<string[]> {
"src",
"app",
"changelog",
"content"
"content",
);
const files = await readdir(changelogDir);
return files.filter((file) => file.endsWith(".mdx"));
@ -69,7 +69,7 @@ async function importChangelogFile(filename: string) {
if (error instanceof z.ZodError) {
console.error(
`Invalid metadata in changelog file ${filename}:`,
error.errors
error.errors,
);
return null;
}
@ -119,7 +119,7 @@ export async function getChangelogEntries(): Promise<ChangelogEntry[]> {
// Sort by date (newest first)
return entries.sort(
(a, b) =>
new Date(b.metadata.date).getTime() - new Date(a.metadata.date).getTime()
new Date(b.metadata.date).getTime() - new Date(a.metadata.date).getTime(),
);
}
@ -127,7 +127,7 @@ export async function getChangelogEntries(): Promise<ChangelogEntry[]> {
* Gets a specific changelog entry by slug
*/
export async function getChangelogEntry(
slug: string
slug: string,
): Promise<ChangelogEntry | null> {
const files = await getChangelogFiles();
const filename = files.find((file) => filenameToSlug(file) === slug);
@ -172,11 +172,11 @@ export function formatChangelogDate(date: string): string {
* Gets the next changelog entry after the current one (based on date order)
*/
export async function getNextChangelogEntry(
currentSlug: string
currentSlug: string,
): Promise<ChangelogEntry | null> {
const allEntries = await getChangelogEntries();
const currentIndex = allEntries.findIndex(
(entry) => entry.slug === currentSlug
(entry) => entry.slug === currentSlug,
);
// If current entry is not found or is the first one (newest), return null
@ -192,11 +192,11 @@ export async function getNextChangelogEntry(
* Gets the previous changelog entry before the current one (based on date order)
*/
export async function getPreviousChangelogEntry(
currentSlug: string
currentSlug: string,
): Promise<ChangelogEntry | null> {
const allEntries = await getChangelogEntries();
const currentIndex = allEntries.findIndex(
(entry) => entry.slug === currentSlug
(entry) => entry.slug === currentSlug,
);
// If current entry is not found or is the last one (oldest), return null

View file

@ -2,7 +2,6 @@ import { DeviceContextProvider } from "@/contexts/device-context";
import { checkDeviceUserAgent } from "@/lib/check-device.server";
import { NextIntlClientProvider } from "next-intl";
// import { ReactScan } from "@/components/dev/react-scan";
// import { DevToolbar } from "@/components/dev/stagewise-toolbar";
import { SpeedInsights } from "@vercel/speed-insights/next";
import type { Metadata, Viewport } from "next";
@ -153,9 +152,6 @@ export default async function RootLayout({
<NextIntlClientProvider>
{children}
{/* Stagewise toolbar for development */}
{/* <DevToolbar /> */}
{/* https://sonner.emilkowal.ski/ */}
<Toaster visibleToasts={1} richColors closeButton />
{/* should only be enabled in production */}

View file

@ -12,10 +12,14 @@ export default function robots(): MetadataRoute.Robots {
"/",
// Allow about pages in all languages
...SUPPORTED_LANGUAGES.map((locale) => `/${locale}/about`),
// Allow template parameter URLs
"/?template=*",
],
disallow: [
// Disallow shared invoice URLs, like /?data=*
"/?data=*",
"/?*data=*",
"/?template=*&data=*",
"/?data=*&template=*",
// Disallow subscription confirmation pages with and without tokens
"/confirm-subscription",
"/confirm-subscription?*",

View file

@ -395,7 +395,7 @@ export const invoiceSchema = z.object({
}, "Logo must be a valid image (JPEG, PNG or WebP) in base64 format")
.optional()
.describe(
"Stripe template specific field. Logo must be a valid image (JPEG, PNG or WebP) in base64 format"
"Stripe template specific field. Logo must be a valid image (JPEG, PNG or WebP) in base64 format",
),
/**
@ -447,7 +447,7 @@ export const invoiceSchema = z.object({
return val;
})
.describe(
"Invoice date of service. Default is the last day of the current month"
"Invoice date of service. Default is the last day of the current month",
),
invoiceType: z
@ -506,9 +506,9 @@ export const invoiceSchema = z.object({
.url("Please enter a valid URL or leave empty")
.refine(
(url) => url.startsWith("https://"),
"URL must start with https://"
"URL must start with https://",
), // Validate HTTPS URL format
])
]),
)
.optional()
.describe("Stripe template specific field. URL field for payment link"),
@ -557,7 +557,7 @@ const uniqueCurrencies = new Set(SUPPORTED_CURRENCIES);
if (uniqueCurrencies.size !== SUPPORTED_CURRENCIES.length) {
const duplicates = SUPPORTED_CURRENCIES.filter(
(currency, index) => SUPPORTED_CURRENCIES.indexOf(currency) !== index
(currency, index) => SUPPORTED_CURRENCIES.indexOf(currency) !== index,
);
const currencyFullNames = duplicates.map((currency) => {
@ -567,8 +567,6 @@ if (uniqueCurrencies.size !== SUPPORTED_CURRENCIES.length) {
});
throw new Error(
`SUPPORTED_CURRENCIES contains duplicate entries: ${currencyFullNames.join(
", "
)}`
`SUPPORTED_CURRENCIES contains duplicate entries: ${currencyFullNames.join(", ")}`,
);
}

View file

@ -119,7 +119,7 @@ export const translationSchema = z
// ...etc
// }
const languageToSchemaMap = Object.fromEntries(
SUPPORTED_LANGUAGES.map((lang) => [lang, translationSchema])
SUPPORTED_LANGUAGES.map((lang) => [lang, translationSchema]),
);
// Schema for all translations
export const translationsSchema = z.object(languageToSchemaMap);

View file

@ -6,7 +6,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date();
const languages = Object.fromEntries(
SUPPORTED_LANGUAGES.map((lang) => [lang, `${APP_URL}/${lang}/about`])
SUPPORTED_LANGUAGES.map((lang) => [lang, `${APP_URL}/${lang}/about`]),
);
const sitemapEntries: MetadataRoute.Sitemap = [

View file

@ -37,7 +37,7 @@ interface BuyerDialogProps {
onClose: React.Dispatch<React.SetStateAction<boolean>>;
handleBuyerAdd?: (
buyer: BuyerData,
{ shouldApplyNewBuyerToInvoice }: { shouldApplyNewBuyerToInvoice: boolean }
{ shouldApplyNewBuyerToInvoice }: { shouldApplyNewBuyerToInvoice: boolean },
) => void;
handleBuyerEdit?: (buyer: BuyerData) => void;
initialData: BuyerData | null;
@ -96,7 +96,7 @@ export function BuyerDialog({
vatNoFieldIsVisible: true,
notes: "",
notesFieldIsVisible: true,
}
},
);
}
}, [shouldApplyFormValues, formValues, initialData, isEditMode, form]);
@ -117,7 +117,7 @@ export function BuyerDialog({
if (!existingBuyersValidationResult.success) {
console.error(
"Invalid existing buyers data:",
existingBuyersValidationResult.error
existingBuyersValidationResult.error,
);
// Show error toast
@ -135,7 +135,7 @@ export function BuyerDialog({
// Validate buyer data against existing buyers
const isDuplicateName = existingBuyersValidationResult.data.some(
(buyer: BuyerData) =>
buyer.name === formValues.name && buyer.id !== formValues.id
buyer.name === formValues.name && buyer.id !== formValues.id,
);
if (isDuplicateName) {

View file

@ -46,7 +46,7 @@ export function BuyerManagement({
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [buyersSelectOptions, setBuyersSelectOptions] = useState<BuyerData[]>(
[]
[],
);
// const [selectedBuyerId, setSelectedBuyerId] = useState("");
const [editingBuyer, setEditingBuyer] = useState<BuyerData | null>(null);
@ -86,7 +86,7 @@ export function BuyerManagement({
// Update buyers when a new one is added
const handleBuyerAdd = (
newBuyer: BuyerData,
{ shouldApplyNewBuyerToInvoice }: { shouldApplyNewBuyerToInvoice: boolean }
{ shouldApplyNewBuyerToInvoice }: { shouldApplyNewBuyerToInvoice: boolean },
) => {
try {
const newBuyerWithId = {
@ -130,12 +130,12 @@ export function BuyerManagement({
const handleBuyerEdit = (editedBuyer: BuyerData) => {
try {
const updatedBuyers = buyersSelectOptions.map((buyer) =>
buyer.id === editedBuyer.id ? editedBuyer : buyer
buyer.id === editedBuyer.id ? editedBuyer : buyer,
);
localStorage.setItem(
BUYERS_LOCAL_STORAGE_KEY,
JSON.stringify(updatedBuyers)
JSON.stringify(updatedBuyers),
);
setBuyersSelectOptions(updatedBuyers);
@ -167,7 +167,7 @@ export function BuyerManagement({
if (id) {
setSelectedBuyerId(id);
const selectedBuyer = buyersSelectOptions.find(
(buyer) => buyer.id === id
(buyer) => buyer.id === id,
);
if (selectedBuyer) {
@ -187,12 +187,12 @@ export function BuyerManagement({
try {
setBuyersSelectOptions((prevBuyers) => {
const updatedBuyers = prevBuyers.filter(
(buyer) => buyer.id !== selectedBuyerId
(buyer) => buyer.id !== selectedBuyerId,
);
localStorage.setItem(
BUYERS_LOCAL_STORAGE_KEY,
JSON.stringify(updatedBuyers)
JSON.stringify(updatedBuyers),
);
return updatedBuyers;
});
@ -222,7 +222,7 @@ export function BuyerManagement({
};
const activeBuyer = buyersSelectOptions.find(
(buyer) => buyer.id === selectedBuyerId
(buyer) => buyer.id === selectedBuyerId,
);
return (
@ -240,7 +240,7 @@ export function BuyerManagement({
id={buyerSelectId}
className={cn(
"block h-8 max-w-[200px] text-[12px]",
!selectedBuyerId && "italic text-gray-700"
!selectedBuyerId && "italic text-gray-700",
)}
onChange={handleBuyerChange}
value={selectedBuyerId}

View file

@ -1,15 +0,0 @@
"use client";
import { StagewiseToolbar } from "@stagewise/toolbar-next";
const stagewiseConfig = {
plugins: [],
};
export function DevToolbar() {
if (process.env.NODE_ENV !== "development") {
return null;
}
return <StagewiseToolbar config={stagewiseConfig} />;
}

View file

@ -8,7 +8,7 @@ export function GithubIcon({ className }: { className?: string }) {
xmlns="http://www.w3.org/2000/svg"
className={cn(
"h-5 w-5 transition-all group-hover:fill-blue-600",
className
className,
)}
>
<title>View on GitHub</title>

View file

@ -0,0 +1,40 @@
"use client";
import { Button } from "@/components/ui/button";
import { GITHUB_URL } from "@/config";
import { umamiTrackEvent } from "@/lib/umami-analytics-track-event";
import { cn } from "@/lib/utils";
import { Star } from "lucide-react";
import Link from "next/link";
export function GitHubStarCTA() {
const handleStarClick = () => {
umamiTrackEvent("github_star_cta_clicked");
};
return (
<div className="fixed right-2 top-2 z-50 duration-500 animate-in fade-in slide-in-from-top-4">
<Button
asChild
_variant="outline"
_size="sm"
className={cn(
"group border-slate-200 bg-white shadow-sm transition-all duration-300 hover:scale-105 hover:border-slate-300 hover:shadow-md",
"text-slate-900 hover:text-slate-900",
)}
onClick={handleStarClick}
data-testid="github-star-cta-button"
>
<Link
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-2"
>
<Star className="h-4 w-4 text-slate-600 transition-all duration-300 ease-in-out group-hover:fill-yellow-400 group-hover:text-yellow-500" />
<span className="text-sm font-medium">Star</span>
</Link>
</Button>
</div>
);
}

View file

@ -16,7 +16,7 @@ export function GoToAppButton({
_variant="outline"
className={cn(
"group relative overflow-hidden border-slate-200 px-8 shadow-sm transition-all duration-300 hover:border-slate-200/80 hover:shadow-lg",
className
className,
)}
asChild
>
@ -39,7 +39,7 @@ export function BlackGoToAppButton({
<GoToAppButton
className={cn(
"relative overflow-hidden bg-zinc-900 text-white transition-all duration-300 hover:scale-[1.02] hover:bg-zinc-800 hover:text-white active:scale-[0.98]",
className
className,
)}
>
{children}

View file

@ -39,7 +39,7 @@ interface SellerDialogProps {
seller: SellerData,
{
shouldApplyNewSellerToInvoice,
}: { shouldApplyNewSellerToInvoice: boolean }
}: { shouldApplyNewSellerToInvoice: boolean },
) => void;
handleSellerEdit?: (seller: SellerData) => void;
initialData: SellerData | null;
@ -108,7 +108,7 @@ export function SellerDialog({
swiftBicFieldIsVisible: true,
notes: "",
notesFieldIsVisible: true,
}
},
);
}
}, [shouldApplyFormValues, formValues, initialData, isEditMode, form]);
@ -129,7 +129,7 @@ export function SellerDialog({
if (!existingSellersValidationResult.success) {
console.error(
"Invalid existing sellers data:",
existingSellersValidationResult.error
existingSellersValidationResult.error,
);
// Show error toast
@ -149,7 +149,7 @@ export function SellerDialog({
// Validate seller data against existing sellers
const isDuplicateName = existingSellersValidationResult.data.some(
(seller: SellerData) =>
seller.name === formValues.name && seller.id !== formValues.id
seller.name === formValues.name && seller.id !== formValues.id,
);
if (isDuplicateName) {

View file

@ -82,7 +82,7 @@ export function SellerManagement({
const selectedSeller = validationResult.data.find(
(seller: SellerData) => {
return seller?.id === invoiceData?.seller?.id;
}
},
);
setSellersSelectOptions(validationResult.data);
@ -99,7 +99,7 @@ export function SellerManagement({
newSeller: SellerData,
{
shouldApplyNewSellerToInvoice,
}: { shouldApplyNewSellerToInvoice: boolean }
}: { shouldApplyNewSellerToInvoice: boolean },
) => {
try {
const newSellerWithId = {
@ -113,7 +113,7 @@ export function SellerManagement({
// Save to localStorage
localStorage.setItem(
SELLERS_LOCAL_STORAGE_KEY,
JSON.stringify(newSellers)
JSON.stringify(newSellers),
);
// Update the sellers state
@ -146,12 +146,12 @@ export function SellerManagement({
const handleSellerEdit = (editedSeller: SellerData) => {
try {
const updatedSellers = sellersSelectOptions.map((seller) =>
seller.id === editedSeller.id ? editedSeller : seller
seller.id === editedSeller.id ? editedSeller : seller,
);
localStorage.setItem(
SELLERS_LOCAL_STORAGE_KEY,
JSON.stringify(updatedSellers)
JSON.stringify(updatedSellers),
);
setSellersSelectOptions(updatedSellers);
@ -183,7 +183,7 @@ export function SellerManagement({
if (id) {
setSelectedSellerId(id);
const selectedSeller = sellersSelectOptions.find(
(seller) => seller.id === id
(seller) => seller.id === id,
);
if (selectedSeller) {
@ -203,12 +203,12 @@ export function SellerManagement({
try {
setSellersSelectOptions((prevSellers) => {
const updatedSellers = prevSellers.filter(
(seller) => seller.id !== selectedSellerId
(seller) => seller.id !== selectedSellerId,
);
localStorage.setItem(
SELLERS_LOCAL_STORAGE_KEY,
JSON.stringify(updatedSellers)
JSON.stringify(updatedSellers),
);
return updatedSellers;
});
@ -238,7 +238,7 @@ export function SellerManagement({
};
const activeSeller = sellersSelectOptions.find(
(seller) => seller.id === selectedSellerId
(seller) => seller.id === selectedSellerId,
);
return (
@ -256,7 +256,7 @@ export function SellerManagement({
id={sellerSelectId}
className={cn(
"block h-8 max-w-[200px] text-[12px]",
!selectedSellerId && "italic text-gray-700"
!selectedSellerId && "italic text-gray-700",
)}
onChange={handleSellerChange}
value={selectedSellerId}

View file

@ -26,7 +26,7 @@ function SubmitButton({
className={cn(
"absolute right-2 top-1.5 transition-all duration-200",
"hover:opacity-90 active:scale-95",
pending && "cursor-not-allowed opacity-80"
pending && "cursor-not-allowed opacity-80",
)}
disabled={pending}
>
@ -64,7 +64,7 @@ export function SubscribeInput({
className={cn(
"flex h-12 items-center justify-between",
"rounded-lg border bg-emerald-50 px-4 py-2",
"duration-300 animate-in fade-in-0 slide-in-from-top-1"
"duration-300 animate-in fade-in-0 slide-in-from-top-1",
)}
>
<p className="flex items-center gap-2 text-emerald-700">
@ -111,7 +111,7 @@ export function SubscribeInput({
"placeholder:text-muted-foreground/60",
"transition-all duration-200",
"focus:ring-primary/20 focus:ring-2 focus:ring-offset-0",
"hover:border-primary/50"
"hover:border-primary/50",
)}
required
/>

View file

@ -30,7 +30,7 @@ const AccordionTrigger = React.forwardRef<
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all [&[data-state=open]>svg]:rotate-180",
className
className,
)}
{...props}
>

View file

@ -19,7 +19,7 @@ const AlertDialogOverlay = React.forwardRef<
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
className,
)}
{...props}
ref={ref}
@ -37,7 +37,7 @@ const AlertDialogContent = React.forwardRef<
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 grid max-h-[calc(100%-4rem)] w-full -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto border border-slate-200 bg-white p-6 shadow-lg shadow-black/5 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] dark:border-slate-800 dark:bg-slate-950 sm:max-w-[400px] sm:rounded-xl",
className
className,
)}
{...props}
/>
@ -52,7 +52,7 @@ const AlertDialogHeader = ({
<div
className={cn(
"flex flex-col space-y-1 text-center sm:text-left",
className
className,
)}
{...props}
/>
@ -66,7 +66,7 @@ const AlertDialogFooter = ({
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3",
className
className,
)}
{...props}
/>
@ -119,7 +119,7 @@ const AlertDialogCancel = React.forwardRef<
className={cn(
buttonVariants({ _variant: "outline" }),
"mt-2 sm:mt-0",
className
className,
)}
{...props}
/>

View file

@ -20,7 +20,7 @@ const badgeVariants = cva(
defaultVariants: {
variant: "default",
},
}
},
);
export interface BadgeProps

View file

@ -14,7 +14,7 @@ export const ButtonHelper = ({
_size="sm"
className={cn(
"h-5 max-w-full whitespace-normal text-pretty p-0 text-left underline",
className
className,
)}
{...props}
>

View file

@ -5,7 +5,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-colors outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:shrink-0 [&[data-disabled=true]]:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-colors outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:shrink-0 [&[data-disabled=true]]:opacity-50 active:scale-[98%] active:transition-transform",
{
variants: {
_variant: {
@ -32,7 +32,7 @@ const buttonVariants = cva(
_variant: "default",
_size: "default",
},
}
},
);
export interface ButtonProps
@ -47,7 +47,7 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className, _variant, _size, asChild = false, type = "button", ...props },
ref
ref,
) => {
const Comp = asChild ? Slot : "button";
return (
@ -58,7 +58,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{...props}
/>
);
}
},
);
Button.displayName = "Button";

View file

@ -16,7 +16,7 @@ const Command = React.forwardRef<
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50",
className
className,
)}
{...props}
/>
@ -45,7 +45,7 @@ const CommandInput = React.forwardRef<
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 dark:placeholder:text-slate-400",
className
className,
)}
{...props}
/>
@ -88,7 +88,7 @@ const CommandGroup = React.forwardRef<
ref={ref}
className={cn(
"overflow-hidden p-1 text-slate-950 dark:text-slate-50 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-slate-500 dark:[&_[cmdk-group-heading]]:text-slate-400",
className
className,
)}
{...props}
/>
@ -116,7 +116,7 @@ const CommandItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-slate-100 data-[selected=true]:text-slate-900 data-[disabled=true]:opacity-50 dark:data-[selected='true']:bg-slate-800 dark:data-[selected=true]:text-slate-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
className,
)}
{...props}
/>
@ -132,7 +132,7 @@ const CommandShortcut = ({
<span
className={cn(
"ml-auto text-xs tracking-widest text-slate-500 dark:text-slate-400",
className
className,
)}
{...props}
/>

View file

@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
className={cn(
"fixed inset-0 z-[101] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
className,
)}
{...props}
/>
@ -39,7 +39,7 @@ const DialogContent = React.forwardRef<
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-[101] grid max-h-[calc(100%-4rem)] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-xl border border-slate-200 bg-white p-6 shadow-lg shadow-black/5 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] dark:border-slate-800 dark:bg-slate-950 sm:max-w-[400px] sm:rounded-xl",
className
className,
)}
{...props}
>
@ -64,7 +64,7 @@ const DialogHeader = ({
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
className,
)}
{...props}
/>
@ -78,7 +78,7 @@ const DialogFooter = ({
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:gap-3",
className
className,
)}
{...props}
/>

View file

@ -35,7 +35,7 @@ const DisclosureGroup = React.forwardRef<HTMLDivElement, DisclosureGroupProps>(
{...props}
className={composeTailwindRenderProps(
className,
"peer cursor-pointer disabled:cursor-not-allowed disabled:opacity-75"
"peer cursor-pointer disabled:cursor-not-allowed disabled:opacity-75",
)}
>
{(values) => (
@ -45,7 +45,7 @@ const DisclosureGroup = React.forwardRef<HTMLDivElement, DisclosureGroupProps>(
)}
</Accordion>
);
}
},
);
DisclosureGroup.displayName = "DisclosureGroup";
@ -61,13 +61,13 @@ const Disclosure = React.forwardRef<HTMLDivElement, CollapsibleProps>(
{...props}
className={composeTailwindRenderProps(
className,
"w-full min-w-60 border-b disabled:opacity-60"
"w-full min-w-60 border-b disabled:opacity-60",
)}
>
{children}
</Collapsible>
);
}
},
);
Disclosure.displayName = "Disclosure";
@ -83,7 +83,7 @@ const DisclosureTrigger = React.forwardRef<HTMLButtonElement, ButtonProps>(
slot="trigger"
className={composeTailwindRenderProps(
className,
"flex w-full items-center justify-between gap-x-2 py-3 text-left text-base font-medium disabled:cursor-default disabled:opacity-50 forced-colors:disabled:text-[GrayText] [&[aria-expanded=true]_[data-slot=disclosure-chevron]]:rotate-180"
"flex w-full items-center justify-between gap-x-2 py-3 text-left text-base font-medium disabled:cursor-default disabled:opacity-50 forced-colors:disabled:text-[GrayText] [&[aria-expanded=true]_[data-slot=disclosure-chevron]]:rotate-180",
)}
{...props}
>
@ -104,7 +104,7 @@ const DisclosureTrigger = React.forwardRef<HTMLButtonElement, ButtonProps>(
</Button>
</Heading>
);
}
},
);
DisclosureTrigger.displayName = "DisclosureTrigger";
@ -121,7 +121,7 @@ const DisclosurePanel = React.forwardRef<
data-slot="disclosure-panel"
className={composeTailwindRenderProps(
className,
"cursor-text overflow-hidden text-sm text-slate-600 transition-all duration-200 ease-in-out"
"cursor-text overflow-hidden text-sm text-slate-600 transition-all duration-200 ease-in-out",
)}
{...props}
>
@ -138,10 +138,10 @@ DisclosurePanel.displayName = "DisclosurePanel";
function composeTailwindRenderProps<T>(
className: string | ((v: T) => string) | undefined,
tailwind: string
tailwind: string,
): string | ((v: T) => string) {
return composeRenderProps(className, (className) =>
twMerge(tailwind, className)
twMerge(tailwind, className),
);
}

View file

@ -55,7 +55,7 @@ function DropdownMenuContent({
isCloseFromMouse.current = true;
onPointerDown?.(e);
},
[onPointerDown]
[onPointerDown],
);
const handlePointerDownOutside = React.useCallback(
@ -63,7 +63,7 @@ function DropdownMenuContent({
isCloseFromMouse.current = true;
onPointerDownOutside?.(e);
},
[onPointerDownOutside]
[onPointerDownOutside],
);
const handleCloseAutoFocus = React.useCallback(
@ -79,7 +79,7 @@ function DropdownMenuContent({
e.preventDefault();
isCloseFromMouse.current = false;
},
[onCloseAutoFocus]
[onCloseAutoFocus],
);
return (
@ -89,7 +89,7 @@ function DropdownMenuContent({
sideOffset={sideOffset}
className={cn(
"z-50 min-w-40 overflow-hidden rounded-md border border-slate-200 bg-white p-1 text-slate-950 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className
className,
)}
onPointerDown={handlePointerDown}
onPointerDownOutside={handlePointerDownOutside}
@ -124,7 +124,7 @@ function DropdownMenuItem({
data-variant={variant}
className={cn(
"data-[variant=destructive]:*:[svg]:!text-destructive-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-slate-100 hover:text-slate-900 data-[disabled]:pointer-events-none data-[highlighted]:bg-slate-100 data-[variant=destructive]:data-[highlighted]:bg-red-500/10 data-[inset]:pl-8 data-[highlighted]:text-slate-900 data-[variant=destructive]:data-[highlighted]:text-slate-50 data-[variant=destructive]:text-slate-50 data-[disabled]:opacity-50 data-[highlighted]:outline-none data-[variant=destructive]:hover:bg-red-500/10 data-[variant=destructive]:hover:text-slate-50 dark:hover:bg-slate-800 dark:hover:text-slate-50 dark:data-[highlighted]:bg-slate-800 dark:data-[variant=destructive]:data-[highlighted]:bg-red-900/10 dark:data-[highlighted]:text-slate-50 dark:data-[variant=destructive]:data-[highlighted]:text-slate-50 dark:data-[variant=destructive]:text-slate-50 dark:data-[variant=destructive]:hover:bg-red-900/10 dark:data-[variant=destructive]:hover:text-slate-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
className,
)}
{...props}
/>
@ -142,7 +142,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
className={cn(
"outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
className,
)}
checked={checked}
{...props}
@ -178,7 +178,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
className={cn(
"outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
className,
)}
{...props}
>
@ -205,7 +205,7 @@ function DropdownMenuLabel({
data-inset={inset}
className={cn(
"px-2 py-1.5 text-xs font-medium text-slate-500 data-[inset]:pl-8 dark:text-slate-400",
className
className,
)}
{...props}
/>
@ -221,7 +221,7 @@ function DropdownMenuSeparator({
data-slot="dropdown-menu-separator"
className={cn(
"-mx-1 my-1 h-px bg-slate-200 dark:bg-slate-800",
className
className,
)}
{...props}
/>
@ -237,7 +237,7 @@ function DropdownMenuShortcut({
data-slot="dropdown-menu-shortcut"
className={cn(
"-me-1 ms-auto inline-flex h-5 max-h-full items-center rounded border border-slate-200 bg-white px-1 font-[inherit] text-[0.625rem] font-medium text-slate-500/70 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400/70",
className
className,
)}
{...props}
/>
@ -264,7 +264,7 @@ function DropdownMenuSubTrigger({
data-inset={inset}
className={cn(
"outline-hidden flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm focus:bg-slate-100 focus:text-slate-900 data-[state=open]:bg-slate-100 data-[inset]:pl-8 data-[state=open]:text-slate-900 dark:focus:bg-slate-800 dark:focus:text-slate-50 dark:data-[state=open]:bg-slate-800 dark:data-[state=open]:text-slate-50",
className
className,
)}
{...props}
>
@ -286,7 +286,7 @@ function DropdownMenuSubContent({
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-40 overflow-hidden rounded-md border border-slate-200 bg-white p-1 text-slate-950 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className
className,
)}
{...props}
/>

View file

@ -25,7 +25,7 @@ type FormFieldContextValue<
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
{} as FormFieldContextValue,
);
const FormField = <
@ -69,7 +69,7 @@ type FormItemContextValue = {
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
{} as FormItemContextValue,
);
const FormItem = React.forwardRef<
@ -160,7 +160,7 @@ const FormMessage = React.forwardRef<
id={formMessageId}
className={cn(
"text-[13px] font-medium text-red-500 dark:text-red-900",
className
className,
)}
{...props}
>

View file

@ -15,14 +15,14 @@ const Input = React.memo(
"[&::-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" &&
"p-0 pr-3 italic text-slate-500/70 file:me-3 file:h-full file:border-0 file:border-r file:border-solid file:border-slate-200 file:bg-transparent file:px-3 file:text-sm file:font-medium file:not-italic file:text-slate-950 dark:text-slate-400/70 dark:file:border-slate-800 dark:file:text-slate-50",
className
className,
)}
ref={ref}
{...props}
/>
);
}
)
},
),
);
Input.displayName = "Input";

View file

@ -13,11 +13,11 @@ const Label = React.memo(
ref={ref}
className={cn(
"block text-balance text-xs font-medium text-gray-900 peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-slate-50",
className
className,
)}
{...props}
/>
))
)),
);
Label.displayName = "Label";

View file

@ -54,7 +54,7 @@ const MoneyInput = React.memo(
className={cn(
"-me-px rounded-e-none ps-6 shadow-none",
getCurrencyPadding(currencySymbol),
props.className
props.className,
)}
placeholder="0.00"
/>
@ -64,7 +64,7 @@ const MoneyInput = React.memo(
</div>
</div>
);
})
}),
);
MoneyInput.displayName = "MoneyInput";
@ -97,7 +97,7 @@ const ReadOnlyMoneyInput = React.memo(
"-me-px block w-full cursor-not-allowed rounded-md rounded-e-none border border-gray-300 bg-gray-100 px-3 py-2 ps-6",
getCurrencyPadding(currencySymbol),
"focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50",
props.className
props.className,
)}
placeholder="0.00"
type="text"
@ -110,7 +110,7 @@ const ReadOnlyMoneyInput = React.memo(
</div>
</div>
);
})
}),
);
ReadOnlyMoneyInput.displayName = "ReadOnlyMoneyInput";

View file

@ -47,7 +47,7 @@ const multiSelectVariants = cva(
defaultVariants: {
variant: "default",
},
}
},
);
/**
@ -153,12 +153,12 @@ export const MultiSelect = React.forwardRef<
handleDownload,
...props
},
ref
ref,
) => {
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
const handleInputKeyDown = (
event: React.KeyboardEvent<HTMLInputElement>
event: React.KeyboardEvent<HTMLInputElement>,
) => {
if (event.key === "Enter") {
setIsPopoverOpen(true);
@ -222,7 +222,7 @@ export const MultiSelect = React.forwardRef<
selectedLanguages.length === 1 && "lg:w-[200px]",
selectedLanguages.length === 2 && "lg:w-[240px]",
selectedLanguages.length >= 3 && "lg:w-[280px]",
className
className,
)}
>
<div className="flex w-full items-center">
@ -249,7 +249,7 @@ export const MultiSelect = React.forwardRef<
<Badge
className={cn(
"border-foreground/1 bg-transparent text-foreground hover:bg-transparent",
multiSelectVariants({ variant })
multiSelectVariants({ variant }),
)}
>
{`+ ${selectedLanguages.length - maxCount} more`}
@ -276,7 +276,7 @@ export const MultiSelect = React.forwardRef<
<Button
onClick={handleTogglePopover}
className={cn(
"mb-4 h-auto rounded-l-none rounded-r-lg border-l-0 bg-slate-900 px-2 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"
"mb-4 h-auto rounded-l-none rounded-r-lg border-l-0 bg-slate-900 px-2 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",
)}
>
<ChevronDown className="text-muted-foreground h-4 w-4" />
@ -300,7 +300,7 @@ export const MultiSelect = React.forwardRef<
<CommandGroup>
{options.map((option) => {
const isSelected = selectedLanguages.includes(
option.value as SupportedLanguages
option.value as SupportedLanguages,
);
return (
<CommandItem
@ -315,7 +315,7 @@ export const MultiSelect = React.forwardRef<
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-gray-500",
isSelected
? "bg-gray-50 text-gray-900"
: "opacity-50 [&_svg]:invisible"
: "opacity-50 [&_svg]:invisible",
)}
>
<CheckIcon className="h-4 w-4" />
@ -369,7 +369,7 @@ export const MultiSelect = React.forwardRef<
</PopoverContent>
</Popover>
);
}
},
);
MultiSelect.displayName = "MultiSelect";

View file

@ -20,7 +20,7 @@ const PopoverContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-slate-200 bg-white p-4 text-slate-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className
className,
)}
{...props}
/>

View file

@ -20,7 +20,7 @@ const SelectNative = React.forwardRef<HTMLSelectElement, SelectPropsNative>(
props.multiple
? "py-1 [&>*]:px-3 [&>*]:py-1 [&_option:checked]:bg-slate-100 dark:[&_option:checked]:bg-slate-800"
: "h-9 pe-8 ps-3",
className
className,
)}
ref={ref}
translate="no"
@ -35,7 +35,7 @@ const SelectNative = React.forwardRef<HTMLSelectElement, SelectPropsNative>(
)}
</div>
);
}
},
);
SelectNative.displayName = "SelectNative";

View file

@ -11,7 +11,7 @@ const Separator = React.forwardRef<
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
@ -20,11 +20,11 @@ const Separator = React.forwardRef<
className={cn(
"shrink-0 bg-slate-200 dark:bg-slate-800",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
className,
)}
{...props}
/>
)
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;

View file

@ -16,18 +16,18 @@ const Switch = React.memo(
<SwitchPrimitives.Root
className={cn(
"focus-visible:outline-ring/70 peer inline-flex h-6 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent outline-offset-2 transition-colors focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-slate-900 data-[state=unchecked]:bg-slate-200 dark:data-[state=checked]:bg-slate-50 dark:data-[state=unchecked]:bg-slate-800",
className
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block size-5 rounded-full bg-white shadow-sm shadow-black/5 ring-0 transition-transform duration-300 [transition-timing-function:cubic-bezier(0.16,1,0.3,1)] data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 dark:bg-slate-950 rtl:data-[state=checked]:-translate-x-4"
"pointer-events-none block size-5 rounded-full bg-white shadow-sm shadow-black/5 ring-0 transition-transform duration-300 [transition-timing-function:cubic-bezier(0.16,1,0.3,1)] data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 dark:bg-slate-950 rtl:data-[state=checked]:-translate-x-4",
)}
/>
</SwitchPrimitives.Root>
))
)),
);
Switch.displayName = SwitchPrimitives.Root.displayName;

View file

@ -27,7 +27,7 @@ function TabsList({
data-slot="tabs-list"
className={cn(
"inline-flex w-fit items-center justify-center gap-2 rounded-md bg-slate-200 p-1 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
className
className,
)}
{...props}
/>
@ -43,7 +43,7 @@ function TabsTrigger({
data-slot="tabs-trigger"
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-[4px] px-3 py-1.5 text-sm font-medium ring-offset-white transition-all hover:bg-slate-100 hover:text-slate-900 focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-slate-950 data-[state=active]:shadow-sm dark:ring-offset-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50 dark:focus-visible:ring-slate-300 dark:data-[state=active]:bg-slate-950 dark:data-[state=active]:text-slate-50",
className
className,
)}
{...props}
/>

View file

@ -10,7 +10,7 @@ const Textarea = React.forwardRef<
<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 [&[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
className,
)}
ref={ref}
{...props}

View file

@ -32,6 +32,6 @@ export function ErrorGeneratingPdfToast() {
</span>,
{
duration: Infinity,
}
},
);
}

View file

@ -28,8 +28,8 @@ const TooltipContent = React.memo(
ref={ref}
sideOffset={sideOffset}
className={cn(
"relative z-50 max-w-[280px] rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-950 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className
"relative isolate z-[100] max-w-[280px] rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-950 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
className,
)}
{...props}
>
@ -39,7 +39,7 @@ const TooltipContent = React.memo(
)}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
))
)),
);
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
@ -82,7 +82,7 @@ const CustomTooltip = React.memo(
) : null}
</Tooltip>
);
}
},
);
CustomTooltip.displayName = "CustomTooltip";

View file

@ -35,7 +35,7 @@ export const Video = ({ src, fallbackImg, testId = "" }: VideoProps) => {
});
}
},
[inViewRef]
[inViewRef],
);
useEffect(() => {

View file

@ -12,7 +12,7 @@ type MediaQueryCallback = (event: { matches: boolean; media: string }) => void;
* */
function attachMediaListener(
query: MediaQueryList,
callback: MediaQueryCallback
callback: MediaQueryCallback,
) {
try {
query.addEventListener("change", callback);
@ -41,10 +41,10 @@ function useMediaQuery(
initialValue?: boolean,
{ getInitialValueInEffect }: UseMediaQueryOptions = {
getInitialValueInEffect: true,
}
},
) {
const [matches, setMatches] = useState(
getInitialValueInEffect ? initialValue : getInitialValue(query)
getInitialValueInEffect ? initialValue : getInitialValue(query),
);
const queryRef = useRef<MediaQueryList | null>(null);
@ -53,7 +53,7 @@ function useMediaQuery(
queryRef.current = window.matchMedia(query);
setMatches(queryRef.current.matches);
return attachMediaListener(queryRef.current, (event) =>
setMatches(event.matches)
setMatches(event.matches),
);
}

View file

@ -13,7 +13,7 @@ export default getRequestConfig(async ({ requestLocale }) => {
: routing.defaultLocale;
const messages = await import(`../../messages/${locale}.json`).then(
(module: { default: typeof EnMessages }) => module.default
(module: { default: typeof EnMessages }) => module.default,
);
return {

View file

@ -10,7 +10,7 @@ import { UAParser } from "ua-parser-js";
export const checkDeviceUserAgent = async () => {
if (typeof process === "undefined") {
throw new Error(
"[Server method] you are importing a server-only module outside of server"
"[Server method] you are importing a server-only module outside of server",
);
}

View file

@ -53,7 +53,7 @@ export async function initializeGoogleDrive() {
async function createFolder(
drive: ReturnType<typeof google.drive>,
folderName: string,
parentFolderId?: string
parentFolderId?: string,
): Promise<GoogleDriveFile> {
const fileMetadata = {
name: folderName,
@ -145,7 +145,7 @@ export async function createOrFindInvoiceFolder({
yearFolder = await createFolder(
googleDrive,
yearFolderName,
validatedInput.parentFolderId
validatedInput.parentFolderId,
);
}
@ -172,14 +172,14 @@ export async function createOrFindInvoiceFolder({
"\n\n________month folder already exists, using it: ",
monthFolder,
{ monthFolderName, yearFolderName },
"\n\n"
"\n\n",
);
} else {
// if the month folder does not exist (new month), create it
monthFolder = await createFolder(
googleDrive,
monthFolderName,
yearFolder.id
yearFolder.id,
);
}
@ -204,7 +204,7 @@ export async function createOrFindInvoiceFolder({
folderToUploadInvoices = await createFolder(
googleDrive,
"invoices",
monthFolder.id
monthFolder.id,
);
}
@ -214,7 +214,7 @@ export async function createOrFindInvoiceFolder({
console.log(
"\n\n________invoice to upload Google Drive folder path: ",
googleDriveFolderPath,
"\n\n"
"\n\n",
);
return {

View file

@ -0,0 +1,61 @@
"use client";
// localStorage-debug-listener.ts
let isInitialized = false;
/**
* This function initializes the localStorage debugger for listening to localStorage changes in the same tab.
* It is used to debug the localStorage data in the same tab.
*
*/
export function initializeLocalStorageDebugger() {
if (
typeof window === "undefined" ||
typeof localStorage === "undefined" ||
typeof document === "undefined" ||
isInitialized
) {
return;
}
const originalSetItem = localStorage.setItem;
localStorage.setItem = function (key, value) {
console.debug(`[localStorage] setItem: ${key} = ${value}`);
const event = new CustomEvent("local-storage-change", {
detail: { key, value, type: "setItem" },
});
window.dispatchEvent(event);
originalSetItem.call(this, key, value);
};
const originalRemoveItem = localStorage.removeItem;
localStorage.removeItem = function (key) {
console.debug(`[localStorage] removeItem: ${key}`);
const event = new CustomEvent("local-storage-change", {
detail: { key, type: "removeItem" },
});
window.dispatchEvent(event);
originalRemoveItem.call(this, key);
};
const originalClear = localStorage.clear;
localStorage.clear = function () {
console.debug(`[localStorage] clear`);
const event = new CustomEvent("local-storage-change", {
detail: { type: "clear" },
});
window.dispatchEvent(event);
originalClear.call(this);
};
isInitialized = true;
console.debug("[localStorage] Debug listener initialized");
}
export interface LocalStorageChangeEvent extends CustomEvent {
detail: {
key?: string;
value?: string;
type: "setItem" | "removeItem" | "clear";
};
}

View file

@ -25,7 +25,7 @@ interface RateLimitResult {
export async function checkRateLimit(
identifier: string,
limiter: Ratelimit
limiter: Ratelimit,
): Promise<RateLimitResult> {
try {
const result = await limiter.limit(identifier);

View file

@ -30,12 +30,12 @@ export async function sendTelegramMessage({
parse_mode: "Markdown",
}),
cache: "no-store",
}
},
);
if (!textResponse.ok) {
throw new Error(
`Failed to send Telegram message: ${textResponse.statusText}`
`Failed to send Telegram message: ${textResponse.statusText}`,
);
}
@ -57,12 +57,12 @@ export async function sendTelegramMessage({
},
body: formData,
cache: "no-store",
}
},
);
if (!fileResponse.ok) {
throw new Error(
`Failed to send file ${file.filename}: ${fileResponse.statusText}`
`Failed to send file ${file.filename}: ${fileResponse.statusText}`,
);
}
}

View file

@ -28,7 +28,7 @@ declare global {
*/
export const umamiTrackEvent = (
eventName: string,
options?: UmamiTrackOptions
options?: UmamiTrackOptions,
) => {
if (typeof window === "undefined") return;

View file

@ -0,0 +1,84 @@
import type {
InvoiceData,
InvoiceItemData,
SellerData,
BuyerData,
} from "@/app/schema";
// Test data fixtures
export const MOCK_SELLER_DATA = {
id: "seller-123",
name: "ACME Corp",
address: "123 Main St, City, 12345",
vatNo: "VAT123456789",
vatNoFieldIsVisible: true,
email: "seller@acme.com",
accountNumber: "1234567890123456",
accountNumberFieldIsVisible: true,
swiftBic: "ABCDUS33XXX",
swiftBicFieldIsVisible: true,
notes: "Seller notes",
notesFieldIsVisible: true,
} as const satisfies SellerData;
export const MOCK_BUYER_DATA = {
id: "buyer-456",
name: "XYZ Ltd",
address: "456 Oak Ave, Town, 67890",
vatNo: "VAT987654321",
vatNoFieldIsVisible: true,
email: "buyer@xyz.com",
notes: "Buyer notes",
notesFieldIsVisible: true,
} as const satisfies BuyerData;
export const MOCK_INVOICE_ITEM_DATA = {
invoiceItemNumberIsVisible: true,
name: "Product A",
nameFieldIsVisible: true,
typeOfGTU: "GTU_01",
typeOfGTUFieldIsVisible: true,
amount: 2,
amountFieldIsVisible: true,
unit: "pcs",
unitFieldIsVisible: true,
netPrice: 100.5,
netPriceFieldIsVisible: true,
vat: 23,
vatFieldIsVisible: true,
netAmount: 201,
netAmountFieldIsVisible: true,
vatAmount: 46.23,
vatAmountFieldIsVisible: true,
preTaxAmount: 247.23,
preTaxAmountFieldIsVisible: true,
} as const satisfies InvoiceItemData;
export const MOCK_INVOICE_DATA = {
language: "en",
dateFormat: "YYYY-MM-DD",
currency: "EUR",
template: "default",
logo: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
invoiceNumberObject: {
label: "Invoice Number",
value: "INV-2024-001",
},
dateOfIssue: "2024-01-15",
dateOfService: "2024-01-31",
invoiceType: "Standard Invoice",
invoiceTypeFieldIsVisible: true,
seller: MOCK_SELLER_DATA,
buyer: MOCK_BUYER_DATA,
items: [MOCK_INVOICE_ITEM_DATA],
total: 247.23,
vatTableSummaryIsVisible: true,
paymentMethod: "Bank Transfer",
paymentMethodFieldIsVisible: true,
paymentDue: "2024-01-29",
stripePayOnlineUrl: "https://checkout.stripe.com/pay/cs_123",
notes: "Thank you for your business",
notesFieldIsVisible: true,
personAuthorizedToReceiveFieldIsVisible: true,
personAuthorizedToIssueFieldIsVisible: true,
} as const satisfies InvoiceData;

View file

@ -0,0 +1,673 @@
import { describe, it, expect } from "vitest";
import {
compressInvoiceData,
decompressInvoiceData,
INVOICE_KEY_COMPRESSION_MAP,
} from "../url-compression";
import {
type InvoiceData,
type InvoiceItemData,
type SellerData,
type BuyerData,
invoiceSchema,
sellerSchema,
buyerSchema,
invoiceItemSchema,
} from "@/app/schema";
import { MOCK_INVOICE_DATA, MOCK_INVOICE_ITEM_DATA } from "./data";
describe("URL Compression Utilities", () => {
describe("compressInvoiceData", () => {
it("should compress invoice data by remapping keys to shorter identifiers", () => {
const result = compressInvoiceData(MOCK_INVOICE_DATA);
// Check that root level keys are mapped
expect(result).toHaveProperty("a", "en"); // language
expect(result).toHaveProperty("b", "YYYY-MM-DD"); // dateFormat
expect(result).toHaveProperty("c", "EUR"); // currency
expect(result).toHaveProperty("d", "default"); // template
expect(result).toHaveProperty("e", MOCK_INVOICE_DATA.logo); // logo
expect(result).toHaveProperty("g", "2024-01-15"); // dateOfIssue
expect(result).toHaveProperty("h", "2024-01-31"); // dateOfService
expect(result).toHaveProperty("i", "Standard Invoice"); // invoiceType
expect(result).toHaveProperty("j", true); // invoiceTypeFieldIsVisible
expect(result).toHaveProperty("n", 247.23); // total
expect(result).toHaveProperty("o", true); // vatTableSummaryIsVisible
expect(result).toHaveProperty("p", "Bank Transfer"); // paymentMethod
expect(result).toHaveProperty("q", true); // paymentMethodFieldIsVisible
expect(result).toHaveProperty("r", "2024-01-29"); // paymentDue
expect(result).toHaveProperty(
"s",
"https://checkout.stripe.com/pay/cs_123",
); // stripePayOnlineUrl
expect(result).toHaveProperty("t", "Thank you for your business"); // notes
expect(result).toHaveProperty("u", true); // notesFieldIsVisible
expect(result).toHaveProperty("v", true); // personAuthorizedToReceiveFieldIsVisible
expect(result).toHaveProperty("w", true); // personAuthorizedToIssueFieldIsVisible
// Check that invoiceNumberObject is mapped
expect(result).toHaveProperty("f");
const invoiceNumberObj = result.f as Record<string, unknown>;
expect(invoiceNumberObj).toHaveProperty("x", "Invoice Number"); // label
expect(invoiceNumberObj).toHaveProperty("y", "INV-2024-001"); // value
});
it("should compress nested seller data", () => {
const result = compressInvoiceData(MOCK_INVOICE_DATA);
expect(result).toHaveProperty("k"); // seller
const seller = result.k as Record<string, unknown>;
expect(seller).toHaveProperty("z", "seller-123"); // id
expect(seller).toHaveProperty("A", "ACME Corp"); // name
expect(seller).toHaveProperty("B", "123 Main St, City, 12345"); // address
expect(seller).toHaveProperty("C", "VAT123456789"); // vatNo
expect(seller).toHaveProperty("D", true); // vatNoFieldIsVisible
expect(seller).toHaveProperty("E", "seller@acme.com"); // email
expect(seller).toHaveProperty("F", "1234567890123456"); // accountNumber
expect(seller).toHaveProperty("G", true); // accountNumberFieldIsVisible
expect(seller).toHaveProperty("H", "ABCDUS33XXX"); // swiftBic
expect(seller).toHaveProperty("I", true); // swiftBicFieldIsVisible
});
it("should compress nested buyer data", () => {
const result = compressInvoiceData(MOCK_INVOICE_DATA);
expect(result).toHaveProperty("l"); // buyer
const buyer = result.l as Record<string, unknown>;
expect(buyer).toHaveProperty("z", "buyer-456"); // id
expect(buyer).toHaveProperty("A", "XYZ Ltd"); // name
expect(buyer).toHaveProperty("B", "456 Oak Ave, Town, 67890"); // address
expect(buyer).toHaveProperty("C", "VAT987654321"); // vatNo
expect(buyer).toHaveProperty("D", true); // vatNoFieldIsVisible
expect(buyer).toHaveProperty("E", "buyer@xyz.com"); // email
});
it("should compress items array", () => {
const result = compressInvoiceData(MOCK_INVOICE_DATA);
expect(result).toHaveProperty("m"); // items
const items = result.m as Record<string, unknown>[];
expect(Array.isArray(items)).toBe(true);
expect(items).toHaveLength(1);
const item = items[0];
expect(item).toHaveProperty("J", true); // invoiceItemNumberIsVisible
expect(item).toHaveProperty("A", "Product A"); // name (using buyer/seller name mapping)
expect(item).toHaveProperty("K", true); // nameFieldIsVisible
expect(item).toHaveProperty("L", "GTU_01"); // typeOfGTU
expect(item).toHaveProperty("M", true); // typeOfGTUFieldIsVisible
expect(item).toHaveProperty("N", 2); // amount
expect(item).toHaveProperty("O", true); // amountFieldIsVisible
expect(item).toHaveProperty("P", "pcs"); // unit
expect(item).toHaveProperty("Q", true); // unitFieldIsVisible
expect(item).toHaveProperty("R", 100.5); // netPrice
expect(item).toHaveProperty("S", true); // netPriceFieldIsVisible
expect(item).toHaveProperty("T", 23); // vat
expect(item).toHaveProperty("U", true); // vatFieldIsVisible
expect(item).toHaveProperty("V", 201); // netAmount
expect(item).toHaveProperty("W", true); // netAmountFieldIsVisible
expect(item).toHaveProperty("X", 46.23); // vatAmount
expect(item).toHaveProperty("Y", true); // vatAmountFieldIsVisible
expect(item).toHaveProperty("Z", 247.23); // preTaxAmount
expect(item).toHaveProperty("0", true); // preTaxAmountFieldIsVisible
});
it("should handle missing optional fields", () => {
const minimalInvoiceData: Partial<InvoiceData> = {
language: "en",
dateFormat: "YYYY-MM-DD",
currency: "USD",
template: "stripe",
dateOfIssue: "2024-01-01",
dateOfService: "2024-01-01",
paymentDue: "2024-01-15",
total: 100,
seller: {
name: "Test Seller",
address: "123 Test St",
email: "test@seller.com",
vatNoFieldIsVisible: true,
accountNumberFieldIsVisible: true,
swiftBicFieldIsVisible: true,
notesFieldIsVisible: true,
} as SellerData,
buyer: {
name: "Test Buyer",
address: "456 Test Ave",
email: "test@buyer.com",
vatNoFieldIsVisible: true,
notesFieldIsVisible: true,
} as BuyerData,
items: [
{
invoiceItemNumberIsVisible: true,
name: "Test Item",
nameFieldIsVisible: true,
typeOfGTU: "",
typeOfGTUFieldIsVisible: true,
amount: 1,
amountFieldIsVisible: true,
unit: "pc",
unitFieldIsVisible: true,
netPrice: 100,
netPriceFieldIsVisible: true,
vat: 0,
vatFieldIsVisible: true,
netAmount: 100,
netAmountFieldIsVisible: true,
vatAmount: 0,
vatAmountFieldIsVisible: true,
preTaxAmount: 100,
preTaxAmountFieldIsVisible: true,
},
],
invoiceTypeFieldIsVisible: true,
vatTableSummaryIsVisible: true,
paymentMethodFieldIsVisible: true,
notesFieldIsVisible: true,
personAuthorizedToReceiveFieldIsVisible: true,
personAuthorizedToIssueFieldIsVisible: true,
};
const result = compressInvoiceData(minimalInvoiceData as InvoiceData);
expect(result).toHaveProperty("a", "en"); // language
expect(result).toHaveProperty("c", "USD"); // currency
expect(result).toHaveProperty("d", "stripe"); // template
expect(result).toHaveProperty("n", 100); // total
// Should not have invoiceNumberObject since it's not provided
expect(result.f).toBeUndefined();
});
it("should handle empty arrays", () => {
const dataWithEmptyItems = {
...MOCK_INVOICE_DATA,
items: [],
};
const result = compressInvoiceData(dataWithEmptyItems);
expect(result).toHaveProperty("m"); // items
expect(result.m).toEqual([]);
});
});
describe("decompressInvoiceData", () => {
it("should decompress data by restoring original keys", () => {
const compressedData = compressInvoiceData(MOCK_INVOICE_DATA);
const result = decompressInvoiceData(compressedData);
// Check root level restoration
expect(result).toHaveProperty("language", "en");
expect(result).toHaveProperty("dateFormat", "YYYY-MM-DD");
expect(result).toHaveProperty("currency", "EUR");
expect(result).toHaveProperty("template", "default");
expect(result).toHaveProperty("logo", MOCK_INVOICE_DATA.logo);
expect(result).toHaveProperty("dateOfIssue", "2024-01-15");
expect(result).toHaveProperty("dateOfService", "2024-01-31");
expect(result).toHaveProperty("invoiceType", "Standard Invoice");
expect(result).toHaveProperty("invoiceTypeFieldIsVisible", true);
expect(result).toHaveProperty("total", 247.23);
expect(result).toHaveProperty("vatTableSummaryIsVisible", true);
expect(result).toHaveProperty("paymentMethod", "Bank Transfer");
expect(result).toHaveProperty("paymentMethodFieldIsVisible", true);
expect(result).toHaveProperty("paymentDue", "2024-01-29");
expect(result).toHaveProperty(
"stripePayOnlineUrl",
"https://checkout.stripe.com/pay/cs_123",
);
expect(result).toHaveProperty("notes", "Thank you for your business");
expect(result).toHaveProperty("notesFieldIsVisible", true);
expect(result).toHaveProperty(
"personAuthorizedToReceiveFieldIsVisible",
true,
);
expect(result).toHaveProperty(
"personAuthorizedToIssueFieldIsVisible",
true,
);
});
it("should restore nested invoiceNumberObject", () => {
const compressedData = compressInvoiceData(MOCK_INVOICE_DATA);
const result = decompressInvoiceData(compressedData);
expect(result).toHaveProperty("invoiceNumberObject");
const invoiceNumberObj = result.invoiceNumberObject as Record<
string,
unknown
>;
expect(invoiceNumberObj).toHaveProperty("label", "Invoice Number");
expect(invoiceNumberObj).toHaveProperty("value", "INV-2024-001");
});
it("should restore nested seller data", () => {
const compressedData = compressInvoiceData(MOCK_INVOICE_DATA);
const result = decompressInvoiceData(compressedData);
expect(result).toHaveProperty("seller");
const seller = result.seller;
expect(seller).toHaveProperty("id", "seller-123");
expect(seller).toHaveProperty("name", "ACME Corp");
expect(seller).toHaveProperty("address", "123 Main St, City, 12345");
expect(seller).toHaveProperty("vatNo", "VAT123456789");
expect(seller).toHaveProperty("vatNoFieldIsVisible", true);
expect(seller).toHaveProperty("email", "seller@acme.com");
expect(seller).toHaveProperty("accountNumber", "1234567890123456");
expect(seller).toHaveProperty("accountNumberFieldIsVisible", true);
expect(seller).toHaveProperty("swiftBic", "ABCDUS33XXX");
expect(seller).toHaveProperty("swiftBicFieldIsVisible", true);
});
it("should restore nested buyer data", () => {
const compressedData = compressInvoiceData(MOCK_INVOICE_DATA);
const result = decompressInvoiceData(compressedData);
expect(result).toHaveProperty("buyer");
const buyer = result.buyer;
expect(buyer).toHaveProperty("id", "buyer-456");
expect(buyer).toHaveProperty("name", "XYZ Ltd");
expect(buyer).toHaveProperty("address", "456 Oak Ave, Town, 67890");
expect(buyer).toHaveProperty("vatNo", "VAT987654321");
expect(buyer).toHaveProperty("vatNoFieldIsVisible", true);
expect(buyer).toHaveProperty("email", "buyer@xyz.com");
});
it("should restore items array", () => {
const compressedData = compressInvoiceData(MOCK_INVOICE_DATA);
const result = decompressInvoiceData(compressedData);
expect(result).toHaveProperty("items");
const items = result.items as Record<string, unknown>[];
expect(Array.isArray(items)).toBe(true);
expect(items).toHaveLength(1);
const item = items[0];
expect(item).toHaveProperty("invoiceItemNumberIsVisible", true);
expect(item).toHaveProperty("name", "Product A");
expect(item).toHaveProperty("nameFieldIsVisible", true);
expect(item).toHaveProperty("typeOfGTU", "GTU_01");
expect(item).toHaveProperty("typeOfGTUFieldIsVisible", true);
expect(item).toHaveProperty("amount", 2);
expect(item).toHaveProperty("amountFieldIsVisible", true);
expect(item).toHaveProperty("unit", "pcs");
expect(item).toHaveProperty("unitFieldIsVisible", true);
expect(item).toHaveProperty("netPrice", 100.5);
expect(item).toHaveProperty("netPriceFieldIsVisible", true);
expect(item).toHaveProperty("vat", 23);
expect(item).toHaveProperty("vatFieldIsVisible", true);
expect(item).toHaveProperty("netAmount", 201);
expect(item).toHaveProperty("netAmountFieldIsVisible", true);
expect(item).toHaveProperty("vatAmount", 46.23);
expect(item).toHaveProperty("vatAmountFieldIsVisible", true);
expect(item).toHaveProperty("preTaxAmount", 247.23);
expect(item).toHaveProperty("preTaxAmountFieldIsVisible", true);
});
it("should handle compressed data with unknown keys", () => {
const compressedDataWithUnknownKeys = {
a: "en", // language
unknownKey: "unknown value",
c: "USD", // currency
anotherUnknown: { nested: "data" },
};
const result = decompressInvoiceData(compressedDataWithUnknownKeys);
expect(result).toHaveProperty("language", "en");
expect(result).toHaveProperty("currency", "USD");
expect(result).toHaveProperty("unknownKey", "unknown value");
expect(result).toHaveProperty("anotherUnknown", { nested: "data" });
});
});
describe("roundtrip compression and decompression", () => {
it("should perfectly restore original data after compression and decompression", () => {
const compressedData = compressInvoiceData(MOCK_INVOICE_DATA);
const decompressedData = decompressInvoiceData(compressedData);
expect(decompressedData).toStrictEqual(MOCK_INVOICE_DATA);
});
it("should handle multiple roundtrips without data loss", () => {
let data = MOCK_INVOICE_DATA;
// Perform multiple compression/decompression cycles
for (let i = 0; i < 3; i++) {
const compressed = compressInvoiceData(data);
// @ts-expect-error - does not matter in test
data = decompressInvoiceData(compressed);
}
expect(data).toStrictEqual(MOCK_INVOICE_DATA);
});
it("should handle data with multiple items", () => {
const dataWithMultipleItems = {
...MOCK_INVOICE_DATA,
items: [
MOCK_INVOICE_ITEM_DATA,
{
...MOCK_INVOICE_ITEM_DATA,
name: "Product B",
amount: 3,
netPrice: 50.25,
vat: "NP" as const,
},
{
...MOCK_INVOICE_ITEM_DATA,
name: "Product C",
amount: 1,
netPrice: 200,
vat: "OO" as const,
},
],
};
const compressedData = compressInvoiceData(dataWithMultipleItems);
const decompressedData = decompressInvoiceData(compressedData);
expect(decompressedData).toStrictEqual(dataWithMultipleItems);
const items = decompressedData.items as InvoiceItemData[];
expect(items).toHaveLength(3);
expect(items[0].name).toBe("Product A");
expect(items[1].name).toBe("Product B");
expect(items[1].vat).toBe("NP");
expect(items[1].amount).toBe(3);
expect(items[1].netPrice).toBe(50.25);
expect(items[2].name).toBe("Product C");
expect(items[2].vat).toBe("OO");
expect(items[2].amount).toBe(1);
expect(items[2].netPrice).toBe(200);
});
});
describe("edge cases", () => {
it("should handle null values", () => {
const dataWithNulls = {
...MOCK_INVOICE_DATA,
invoiceNumberObject: null,
logo: null,
notes: null,
};
const compressedData = compressInvoiceData(
dataWithNulls as unknown as InvoiceData,
);
const decompressedData = decompressInvoiceData(compressedData);
expect(decompressedData.invoiceNumberObject).toBeNull();
expect(decompressedData.logo).toBeNull();
expect(decompressedData.notes).toBeNull();
});
it("should handle undefined values", () => {
const dataWithUndefined = {
...MOCK_INVOICE_DATA,
invoiceNumberObject: undefined,
logo: undefined,
notes: undefined,
};
const compressedData = compressInvoiceData(
dataWithUndefined as unknown as InvoiceData,
);
const decompressedData = decompressInvoiceData(compressedData);
expect(decompressedData.invoiceNumberObject).toBeUndefined();
expect(decompressedData.logo).toBeUndefined();
expect(decompressedData.notes).toBeUndefined();
});
it("should handle empty strings", () => {
const dataWithEmptyStrings = {
...MOCK_INVOICE_DATA,
logo: "",
notes: "",
invoiceType: "",
seller: {
...MOCK_INVOICE_DATA.seller,
vatNo: "",
accountNumber: "",
swiftBic: "",
notes: "",
},
};
const compressedData = compressInvoiceData(dataWithEmptyStrings);
const decompressedData = decompressInvoiceData(compressedData);
expect(decompressedData).toEqual(dataWithEmptyStrings);
});
it("should handle nested arrays and objects", () => {
const complexData = {
...MOCK_INVOICE_DATA,
items: [
{
...MOCK_INVOICE_ITEM_DATA,
customData: {
nested: {
deeply: ["array", "of", "strings"],
number: 42,
boolean: true,
},
},
},
],
};
const compressedData = compressInvoiceData(
complexData as unknown as InvoiceData,
);
const decompressedData = decompressInvoiceData(compressedData);
expect(decompressedData).toEqual(complexData);
});
it("should handle primitive values as root", () => {
const compressedString = compressInvoiceData(
"test string" as unknown as InvoiceData,
);
expect(compressedString).toBe("test string");
const compressedNumber = compressInvoiceData(
123 as unknown as InvoiceData,
);
expect(compressedNumber).toBe(123);
const compressedBoolean = compressInvoiceData(
true as unknown as InvoiceData,
);
expect(compressedBoolean).toBe(true);
const compressedNull = compressInvoiceData(
null as unknown as InvoiceData,
);
expect(compressedNull).toBe(null);
});
it("should maintain data types after compression/decompression", () => {
const compressedData = compressInvoiceData(MOCK_INVOICE_DATA);
const decompressedData = decompressInvoiceData(compressedData);
// Check that numbers remain numbers
expect(typeof decompressedData.total).toBe("number");
expect(
typeof (decompressedData.items as InvoiceItemData[])[0].amount,
).toBe("number");
expect(
typeof (decompressedData.items as InvoiceItemData[])[0].netPrice,
).toBe("number");
expect(typeof (decompressedData.items as InvoiceItemData[])[0].vat).toBe(
"number",
);
// Check that booleans remain booleans
expect(typeof decompressedData.vatTableSummaryIsVisible).toBe("boolean");
expect(
typeof (decompressedData.items as InvoiceItemData[])[0]
.nameFieldIsVisible,
).toBe("boolean");
// Check that strings remain strings
expect(typeof decompressedData.language).toBe("string");
expect(typeof decompressedData.currency).toBe("string");
expect(typeof (decompressedData.seller as SellerData).name).toBe(
"string",
);
});
describe("INVOICE_KEY_COMPRESSION_MAP completeness", () => {
it("should include all keys from invoice schema types", () => {
// Get all possible keys from the AllInvoiceKeys type
// We'll do this by creating a comprehensive mock object and checking each key exists in INVOICE_KEY_COMPRESSION_MAP
const invoiceRootKeys = Object.keys(
invoiceSchema.shape,
) as (keyof typeof invoiceSchema.shape)[];
const sellerKeys = Object.keys(
sellerSchema.shape,
) as (keyof typeof sellerSchema.shape)[];
const buyerKeys = Object.keys(
buyerSchema.shape,
) as (keyof typeof buyerSchema.shape)[];
const invoiceItemKeys = Object.keys(
invoiceItemSchema.shape,
) as (keyof typeof invoiceItemSchema.shape)[];
// Combine all keys
const allKeys = [
...invoiceRootKeys,
...sellerKeys,
...buyerKeys,
...invoiceItemKeys,
];
// Check that each key exists in INVOICE_KEY_COMPRESSION_MAP
const missingKeys: string[] = [];
allKeys.forEach((key) => {
if (!(key in INVOICE_KEY_COMPRESSION_MAP)) {
missingKeys.push(key);
}
});
expect(missingKeys).toEqual([]);
if (missingKeys.length > 0) {
console.log(
"Missing keys in INVOICE_KEY_COMPRESSION_MAP:",
missingKeys,
);
}
});
it("should not have duplicate compressed values in INVOICE_KEY_COMPRESSION_MAP", () => {
const compressedValues = Object.values(INVOICE_KEY_COMPRESSION_MAP);
const uniqueValues = Array.from(new Set(compressedValues));
expect(compressedValues).toHaveLength(uniqueValues.length);
if (compressedValues.length !== uniqueValues.length) {
const duplicates = compressedValues.filter(
(value, index) => compressedValues.indexOf(value) !== index,
);
console.log("Duplicate compressed values:", duplicates);
}
});
it("should have a INVOICE_KEY_COMPRESSION_MAP entry for every key used in MOCK_INVOICE_DATA", () => {
// Extract all keys recursively from the mock data
function getAllKeys(
obj: unknown,
keySet = new Set<string>(),
): Set<string> {
if (obj === null || typeof obj !== "object") {
return keySet;
}
if (Array.isArray(obj)) {
obj.forEach((item) => getAllKeys(item, keySet));
return keySet;
}
Object.keys(obj).forEach((key) => {
keySet.add(key);
getAllKeys(obj[key as keyof typeof obj], keySet);
});
return keySet;
}
const allKeysInMockData = getAllKeys(MOCK_INVOICE_DATA);
const missingKeys: string[] = [];
allKeysInMockData.forEach((key) => {
if (!(key in INVOICE_KEY_COMPRESSION_MAP)) {
missingKeys.push(key);
}
});
expect(missingKeys).toStrictEqual([]);
if (missingKeys.length > 0) {
console.log(
"Keys in MOCK_INVOICE_DATA missing from INVOICE_KEY_COMPRESSION_MAP:",
missingKeys,
);
}
});
});
});
describe("size reduction verification", () => {
it("should reduce the size of JSON when compressed", () => {
const originalJson = JSON.stringify(MOCK_INVOICE_DATA);
const compressedData = compressInvoiceData(MOCK_INVOICE_DATA);
const compressedJson = JSON.stringify(compressedData);
expect(compressedJson.length).toBeLessThan(originalJson.length);
// Verify significant size reduction (should be at least 20% smaller)
const reductionRatio =
(originalJson.length - compressedJson.length) / originalJson.length;
expect(reductionRatio).toBeGreaterThan(0.2);
});
it("should demonstrate compression effectiveness with multiple items", () => {
const largeInvoiceData = {
...MOCK_INVOICE_DATA,
items: Array.from({ length: 10 }, (_, index) => ({
...MOCK_INVOICE_ITEM_DATA,
name: `Product ${index + 1}`,
amount: index + 1,
netPrice: (index + 1) * 10.5,
})),
};
const originalJson = JSON.stringify(largeInvoiceData);
const compressedData = compressInvoiceData(largeInvoiceData);
const compressedJson = JSON.stringify(compressedData);
expect(compressedJson.length).toBeLessThan(originalJson.length);
// With more items, compression should be even more effective
const reductionRatio =
(originalJson.length - compressedJson.length) / originalJson.length;
expect(reductionRatio).toBeGreaterThan(0.25);
// Log the size reduction for visibility
console.log(`Original size: ${originalJson.length} characters`);
console.log(`Compressed size: ${compressedJson.length} characters`);
console.log(`Size reduction: ${(reductionRatio * 100).toFixed(1)}%`);
});
});
});

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