mirror of
https://github.com/VladSez/easy-invoice-pdf
synced 2026-04-21 13:37:40 +00:00
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:
parent
7f16ec9938
commit
5a4e9debc1
103 changed files with 4470 additions and 2559 deletions
13
.cursor/prompts/playwright-mcp.md
Normal file
13
.cursor/prompts/playwright-mcp.md
Normal 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
|
||||
|
|
@ -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
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
52
.github/workflows/unit-tests.yml
vendored
Normal 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 }}
|
||||
|
|
@ -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")],
|
||||
};
|
||||
|
||||
|
|
|
|||
28
README.md
28
README.md
|
|
@ -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 🎥
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
299
e2e/generate-invoice-link.test.ts
Normal file
299
e2e/generate-invoice-link.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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=/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -97,5 +97,5 @@ export default tseslint.config(
|
|||
projectService: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
1
knip.ts
1
knip.ts
|
|
@ -15,7 +15,6 @@ const config: KnipConfig = {
|
|||
"@ianvs/prettier-plugin-sort-imports",
|
||||
"react-email",
|
||||
"react-scan",
|
||||
"@stagewise/toolbar-next",
|
||||
],
|
||||
ignore: [
|
||||
"lint-staged.config.js",
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
16
package.json
16
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2842
pnpm-lock.yaml
2842
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -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." };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
261
src/app/(app)/components/dev/dev-local-storage-view.tsx
Normal file
261
src/app/(app)/components/dev/dev-local-storage-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 "Person Authorized to Receive" 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 "Person Authorized to Receive" 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 "Person Authorized to Issue" 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 "Person Authorized to Issue" 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 {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function StripeFooter({
|
|||
const invoiceNumber = `${invoiceNumberValue}`;
|
||||
|
||||
const paymentDueDate = dayjs(invoiceData.paymentDue).format(
|
||||
invoiceData.dateFormat
|
||||
invoiceData.dateFormat,
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
66
src/app/(app)/utils/invoice-number-breaking-change.ts
Normal file
66
src/app/(app)/utils/invoice-number-breaking-change.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -85,7 +85,7 @@ export function LanguageSwitcher({
|
|||
startTransition(() => {
|
||||
const pathnameWithoutLocale = pathname.replace(
|
||||
`/${locale}`,
|
||||
""
|
||||
"",
|
||||
);
|
||||
|
||||
router.push(pathnameWithoutLocale || "/", {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default function Error({ error, reset }: Props) {
|
|||
{
|
||||
closeButton: true,
|
||||
richColors: true,
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [error]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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?*",
|
||||
|
|
|
|||
|
|
@ -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(", ")}`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
40
src/components/github-star-cta.tsx
Normal file
40
src/components/github-star-cta.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const badgeVariants = cva(
|
|||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,6 @@ export function ErrorGeneratingPdfToast() {
|
|||
</span>,
|
||||
{
|
||||
duration: Infinity,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const Video = ({ src, fallbackImg, testId = "" }: VideoProps) => {
|
|||
});
|
||||
}
|
||||
},
|
||||
[inViewRef]
|
||||
[inViewRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
61
src/lib/localStorage-debug-listener.ts
Normal file
61
src/lib/localStorage-debug-listener.ts
Normal 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";
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ declare global {
|
|||
*/
|
||||
export const umamiTrackEvent = (
|
||||
eventName: string,
|
||||
options?: UmamiTrackOptions
|
||||
options?: UmamiTrackOptions,
|
||||
) => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
|
|
|
|||
84
src/utils/__tests__/data.ts
Normal file
84
src/utils/__tests__/data.ts
Normal 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;
|
||||
673
src/utils/__tests__/url-compression.test.ts
Normal file
673
src/utils/__tests__/url-compression.test.ts
Normal 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
Loading…
Reference in a new issue