Compare commits

...

22 commits
v1.0.3 ... main

Author SHA1 Message Date
Vlad Sazonau
059e4b03aa
Update product screenshot dimensions in README
Some checks failed
🔍 Knip / Run Knip (push) Has been cancelled
🧹 ESLint / Run linting (push) Has been cancelled
📝 TypeScript Type Check / Run type check (push) Has been cancelled
🧪 Vitest Unit Tests / Run unit tests (push) Has been cancelled
2026-04-20 11:45:12 +02:00
VladSez
dac8829140 refactor: remove RESEND_AUDIENCE_ID from environment configuration
Some checks are pending
🔍 Knip / Run Knip (push) Waiting to run
🧹 ESLint / Run linting (push) Waiting to run
📝 TypeScript Type Check / Run type check (push) Waiting to run
🧪 Vitest Unit Tests / Run unit tests (push) Waiting to run
- Deleted RESEND_AUDIENCE_ID from both .env.example and src/env.ts to streamline environment variable management.
2026-04-20 02:05:04 +02:00
VladSez
5ba4cae47b fix: enhance Terms of Service data-testid
Some checks failed
🔍 Knip / Run Knip (push) Has been cancelled
📝 TypeScript Type Check / Run type check (push) Has been cancelled
🧪 Vitest Unit Tests / Run unit tests (push) Has been cancelled
🧹 ESLint / Run linting (push) Has been cancelled
2026-04-18 16:45:39 +02:00
VladSez
79941e0eeb fix: update e2e tests for Terms of Service visibility and text consistency
- Modified the e2e tests for the Invoice Generator Page to ensure the Terms of Service text is consistent across mobile and desktop views.
- Updated the test assertions to reflect the revised text and structure for better clarity and accuracy.
- Changed the Playwright test reporter from 'list' to 'line' for improved output formatting.
2026-04-18 16:07:06 +02:00
VladSez
87760b5ae7 fix: update Terms of Service text for consistency in InvoiceClientPage
- Adjusted the Terms of Service text in both mobile and desktop views for clarity, removing the word apply to enhance readability.
- Increased spacing in the footer for better layout and visual appeal.
2026-04-18 13:11:55 +02:00
Vlad Sazonau
4967bb319c
fix: update Terms of Service text for clarity and consistency (#214)
* fix: update Terms of Service text for clarity and consistency

- Modified the Terms of Service text in the invoice form tests to include apply for better accuracy.
- Updated footer descriptions across multiple language files to clarify terms of use and include a link to the Terms of Service.
- Enhanced the About page and Changelog layout to ensure consistent presentation of Terms of Service information.

* i18n fixes
2026-04-18 02:17:29 +02:00
Vlad Sazonau
0b371690a4
feat: implement loading skeletons for various pages (#213)
* feat: implement loading skeletons for various pages

- Added a HeaderSkeleton component to provide a consistent loading state for the header across multiple pages.
- Updated About, Terms of Service, and Changelog loading states to utilize the new HeaderSkeleton for improved user experience during content loading.
- Refactored loading components to enhance visual consistency and maintainability.

* refactor: update loading component styles for improved responsiveness

- Adjusted maximum width settings for loading components in both loading.tsx and page.client.tsx to enhance layout consistency across different screen sizes. The new design accommodates larger displays while maintaining a clean and user-friendly interface.

* fix: minor style issue
2026-04-17 17:12:31 +02:00
VladSez
4422060542 feat: enhance tooltip content for invoice PDF download link
- Updated the tooltip for the invoice PDF download link to provide clearer compliance information, emphasizing user responsibility for local tax and accounting regulations. The new design includes a structured layout for improved readability.
2026-04-17 15:22:08 +02:00
Vlad Sazonau
18c01ed42b
feat: add tos and founder pages, add how it works yt video (#212)
* feat: add tos and founder pages, add how it works yt video

- Updated About page tests to reflect new href attributes for links, including features, FAQ, and Terms of Service.
- Added visibility checks for new links in the footer: Founder and Terms of Service in multiple languages.
- Refactored invoice form tests to include Terms of Service links for both mobile and desktop views.
- Updated translations in multiple languages to include new links for Founder and Terms of Service.
- Replaced video demo URL with YouTube embed in the InvoiceClientPage for improved accessibility.

* refactor: update footer descriptions and improve test cases

- Removed redundant title checks in the changelog test for streamlined validation.
- Updated the Terms of Service text in the invoice form tests for consistency.
- Enhanced footer descriptions across multiple language files to clarify the tool's purpose and terms of use.
- Removed unused founder link references in various components to simplify the codebase.

* fix: footer type

* feat: add tooltip for invoice PDF download link

- Integrated a CustomTooltip component to provide compliance information when downloading invoices.
- Updated the download link structure to include the tooltip, enhancing user experience and clarity.
2026-04-17 05:23:24 +02:00
VladSez
c48b71c5ad fix: update changelog entry layout to improve date display
- Added a new date display section in the changelog entry header for better visibility.
- Removed the previous date display to streamline the layout.
2026-04-14 22:39:15 +02:00
Vlad Sazonau
8a67b2ef32
fix: changelog updates, add founder recognition + minor style fixes (#210)
* fix: changelog updates, add founder recognition + minor style fixes

- Disabled the Next.js rule for using <img> elements in the ESLint configuration.
- Improved the layout of the Founders info section in the InvoiceClientPage for both mobile and desktop views by adding an avatar image and restructuring the HTML for better accessibility and styling.
- Updated the GitHub link text in the ProjectInfoLinks component to Star on GitHub for clarity.

* chore: link fix

* chore: minor readme fix

* fix: e2e test

* fix: e2e test

* refactor: improve image loading and styling across components

- Removed ESLint disable comment for <img> elements in mdx-components.
- Added lazy loading and async decoding attributes to images in InvoiceClientPage and Footer components for better performance.
- Updated GitHub Star CTA button styling for enhanced visual consistency.
- Adjusted backdrop blur effect in ManualPlayVideo component for improved aesthetics.
2026-04-13 18:31:57 +02:00
Vlad Sazonau
b832952caf
fix: update invoice PDF templates cond rendering (#209)
* fix: update invoice PDF templates cond rendering

- Replaced the live preview GIF with a new live PDF preview GIF in the README.
- Added a new demo GIF for GitHub README.
- Updated invoice PDF templates to use conditional rendering for various fields, improving readability and maintainability.

* chore: minor readme fix
2026-04-12 03:56:39 +02:00
Vlad Sazonau
32cfc46ad8
Enhance README with demo image and content updates
Added demo image and updated README content.
2026-04-12 03:22:30 +02:00
Vlad Sazonau
4f95e53e92
feat: update design for Github Star CTA btn, update changelog design (#208)
* feat: update design for Github Star CTA btn, update changelog design

- Introduced a new skill called 'caveman' that allows for ultra-compressed communication, reducing token usage while maintaining technical accuracy.
- Added detailed documentation for the 'caveman' skill, outlining grammar rules, response patterns, and examples.
- Updated skills-lock.json to include the new 'caveman' skill.
- Enhanced UI components for better spacing and visual consistency in various sections of the application.

* chore: update README

- Added a link to the EasyInvoicePDF website in the README logo section.
- Enhanced the README with updated version dates and improved formatting for release notes.
- Corrected the release date for v1.0.0 and v1.0.1 in the changelog files to reflect accurate information.

* chore: minor readme fix

* style: update GitHub Star CTA button to use rounded-full design

- Changed button styling from rounded-lg to rounded-full for improved aesthetics and consistency.

* chore: minor readme fix

* minor changelog clarifications
2026-04-12 01:40:40 +02:00
Vlad Sazonau
10b3fb2991
fix: mobile burger menu, lazy load videos on marketing page, minor css fixes (#207)
* fix: lazy load videos on marketing page, minor css fixes

- Updated .npmrc to include new supply chain security settings.
- Modified package.json to add trust policy for pnpm.
- Updated video component for improved lazy loading and playback control.
- Adjusted marketing feature card video fallback image and cleaned up CSS styles.

* fix: e2e test

* feat: add mobile menu, update translations for changelog links in multiple languages and enhance About page header component

- Changed the term changelog to more localized equivalents in German, Spanish, French, Italian, Dutch, Portuguese, Russian, and Ukrainian.
- Refactored the About page header component to improve structure and added translations for navigation links.
- Introduced new components for logo and mobile menu to enhance code organization and maintainability.
- Updated video component for better lazy loading and playback control.

* feat: enhance video components and update styles for About page

- Introduced AutoPlayVideo and ManualPlayVideo components for improved video playback experience.
- Updated video descriptions in marketing features for better clarity.
- Adjusted CSS styles in the mobile menu for consistent spacing.
- Removed scrollbar-gutter property to simplify global styles.

* refactor: update mobile menu styles and enhance video playback control

- Adjusted mobile menu button styles for improved spacing and visual consistency.
- Implemented video pause functionality when scrolling out of view to optimize resource usage.
- Enhanced e2e tests for the About page to include mobile and desktop navigation checks.
- Removed the unused GitHub star call-to-action header component.
- Added logic to handle mobile and desktop navigation visibility in tests.

* refactor: optimize color selection logic in hero description

- Simplified the color selection mechanism for highlighted text in the hero section by using a modulo operation to cycle through colors.
- Removed the random color selection to ensure a consistent color pattern across multiple spans.
2026-04-06 22:31:53 +02:00
Vlad Sazonau
a414bda22a
Enhance README with tax settings and QR code support
Added sections for customizable tax settings and QR code support with multi-page PDF capabilities.
2026-04-02 02:40:55 +02:00
Vlad Sazonau
d14456bd70
Update feature description for live preview 2026-04-02 02:21:46 +02:00
Vlad Sazonau
4c37060883
Update image sources in README.md 2026-04-02 02:20:31 +02:00
Vlad Sazonau
c37d3ac48d
Replace local image paths with direct links in README
Updated image sources in README.md to use direct links instead of local paths.
2026-04-01 16:15:20 +02:00
Vlad Sazonau
7dae0e5c28
refactor: update README and clean up demo assets (#205)
* refactor: update README and clean up demo assets

- Removed outdated sections from README, including shareable links, QR codes, and customizable tax settings.
- Updated the shareable links section with a new demo image.
- Deleted unused demo assets for QR codes, share links, and tax customization to streamline the project.
- Enhanced the general information section in the invoice form with improved staleness checking logic and documentation.

* chore: update datetime classname
2026-04-01 03:17:54 +02:00
Vlad Sazonau
a77a28405f
refactor: remove button helpers from general information section (#204) 2026-03-30 19:59:10 +02:00
Vlad Sazonau
89df4480b0
chore: improved upload logo e2e test, minor label fix, bump version to 1.0.3, added changelog.md and blog post with 1.0.3 announcement (#202) 2026-03-30 00:11:50 +02:00
105 changed files with 3017 additions and 1172 deletions

View file

@ -0,0 +1,72 @@
---
name: caveman
description: >
Ultra-compressed communication mode. Slash token usage ~75% by speaking like caveman
while keeping full technical accuracy. Use when user says "caveman mode", "talk like caveman",
"use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers
when token efficiency is requested.
---
# Caveman Mode
## Core Rule
Respond like smart caveman. Cut articles, filler, pleasantries. Keep all technical substance.
## Grammar
- Drop articles (a, an, the)
- Drop filler (just, really, basically, actually, simply)
- Drop pleasantries (sure, certainly, of course, happy to)
- Short synonyms (big not extensive, fix not "implement a solution for")
- No hedging (skip "it might be worth considering")
- Fragments fine. No need full sentence
- Technical terms stay exact. "Polymorphism" stays "polymorphism"
- Code blocks unchanged. Caveman speak around code, not in code
- Error messages quoted exact. Caveman only for explanation
## Pattern
```
[thing] [action] [reason]. [next step].
```
Not:
> Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by...
Yes:
> Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:
## Examples
**User:** Why is my React component re-rendering?
**Normal (69 tokens):** "The reason your React component is re-rendering is likely because you're creating a new object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a re-render. I'd recommend using useMemo to memoize the object."
**Caveman (19 tokens):** "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
---
**User:** How do I set up a PostgreSQL connection pool?
**Caveman:**
```
Use `pg` pool:
```
```js
const pool = new Pool({
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
})
```
```
max = concurrent connections. Keep under DB limit. idleTimeout kill stale conn.
```
## Boundaries
- Code: write normal. Caveman English only
- Git commits: normal
- PR descriptions: normal
- User say "stop caveman" or "normal mode": revert immediately

View file

@ -0,0 +1,31 @@
---
description: Agent rules to cut token usage.
alwaysApply: true
---
## Before Writing Code
- Read all relevant files first. Never edit blind.
- Understand the full requirement before writing anything.
## While Writing Code
- Test after writing. Never leave code untested.
- Fix errors before moving on. Never skip failures.
- Prefer editing over rewriting whole files.
- Simplest working solution. No over-engineering.
## Before Declaring Done
- Run the code one final time to confirm it works.
- Never declare done without a passing test.
## Output
- No sycophantic openers or closing fluff.
- No em dashes, smart quotes, or Unicode. ASCII only.
- Be concise. If unsure, say so. Never guess.
## Override Rule
User instructions always override this file.

76
.cursor/rules/caveman.mdc Normal file
View file

@ -0,0 +1,76 @@
---
alwaysApply: true
---
---
name: caveman
description: >
Ultra-compressed communication mode. Slash token usage ~75% by speaking like caveman
while keeping full technical accuracy. Use when user says "caveman mode", "talk like caveman",
"use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers
when token efficiency is requested.
---
# Caveman Mode
## Core Rule
Respond like smart caveman. Cut articles, filler, pleasantries. Keep all technical substance.
## Grammar
- Drop articles (a, an, the)
- Drop filler (just, really, basically, actually, simply)
- Drop pleasantries (sure, certainly, of course, happy to)
- Short synonyms (big not extensive, fix not "implement a solution for")
- No hedging (skip "it might be worth considering")
- Fragments fine. No need full sentence
- Technical terms stay exact. "Polymorphism" stays "polymorphism"
- Code blocks unchanged. Caveman speak around code, not in code
- Error messages quoted exact. Caveman only for explanation
## Pattern
```
[thing] [action] [reason]. [next step].
```
Not:
> Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by...
Yes:
> Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:
## Examples
**User:** Why is my React component re-rendering?
**Normal (69 tokens):** "The reason your React component is re-rendering is likely because you're creating a new object reference on each render cycle. When you pass an inline object as a prop, React's shallow comparison sees it as a different object every time, which triggers a re-render. I'd recommend using useMemo to memoize the object."
**Caveman (19 tokens):** "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
---
**User:** How do I set up a PostgreSQL connection pool?
**Caveman:**
```
Use `pg` pool:
```
```js
const pool = new Pool({
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
})
```
```
max = concurrent connections. Keep under DB limit. idleTimeout kill stale conn.
```
## Boundaries
- Code: write normal. Caveman English only
- Git commits: normal
- PR descriptions: normal
- User say "stop caveman" or "normal mode": revert immediately

View file

@ -10,7 +10,6 @@ NEXT_PUBLIC_SENTRY_DSN=""
# Resend (For emails) Create account and paste API keys in .env.local
RESEND_API_KEY=""
RESEND_AUDIENCE_ID=""
# Upstash Redis (For subscription tokens) Create account and paste API keys in .env.local
UPSTASH_REDIS_REST_URL=""

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 2 MiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

BIN
.github/demos/shared-links.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 174 KiB

View file

@ -52,7 +52,8 @@ jobs:
SENTRY_ENABLED: false
NEXT_PUBLIC_SENTRY_ENABLED: false
continue-on-error: true
run: pnpm exec playwright test --reporter=html,list
# https://playwright.dev/docs/test-reporters#line-reporter
run: pnpm exec playwright test --reporter=html,line
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
name: 🎲 Upload Playwright report

7
.npmrc
View file

@ -2,4 +2,9 @@ save-exact=true
# this specifies the number of minutes that must pass after a version is published before pnpm will install it (to prevent supply chain attacks)
# https://pnpm.io/settings#minimumreleaseage
minimum-release-age=4320 # 3 days,
minimum-release-age=4320 # 3 days,
# https://pnpm.io/supply-chain-security
block-exotic-subdeps=true
trust-policy=no-downgrade

106
CHANGELOG.md Normal file
View file

@ -0,0 +1,106 @@
# Changelog
## [1.0.3] - 2026-03-29
### Added
- Email visibility toggle for seller and buyer sections — control whether the email address appears in the generated PDF
- `ConfirmDiscardDialog` component to warn users about unsaved changes when closing the buyer/seller dialogs (replaces native `window.confirm`)
- `useConfirmDiscard` reusable hook for managing discard confirmation state across buyer and seller dialogs
- Knip CI workflow for automated dead-code and unused-dependency detection
- `update-github-actions` script in `package.json` to streamline GitHub Actions version updates
- Unit tests for the `generate-invoice` API route and core logic (`generate-invoice.ts`, `route.tsx`)
### Changed
- Refactored `generate-invoice` API route: extracted core business logic into a standalone `generate-invoice.ts` module using a dependency-injection pattern for improved testability
- Reworked seller and buyer information form sections with improved layout, locked-state banners, and cleaner field grouping
- Buyer and seller dialogs now reset form values and pre-fill switch to their defaults when closed
- Buyer and seller names are trimmed of whitespace before saving; whitespace-padded duplicates are rejected
- Invalid localStorage entries for buyers and sellers are now validated and silently dropped instead of causing errors
- Out-of-Date dates helper improved with more accurate state detection
- Error message component layout and copy updated for better readability
- Vitest config updated with JSX support (`esbuild.jsx: "automatic"`) to enable unit-testing JSX components
- Restructured buyer and seller dialog components into dedicated feature directories under `sections/components/buyer` and `sections/components/seller`
- GitHub Actions workflows updated to latest action versions; failure handling added to all CI jobs
- Auto-scroll the invoice form on mobile when switching between tabs
### Fixed
- Pre-fill switch in buyer/seller dialogs no longer retains its state after the dialog is closed and reopened
- Rate limit exceeded log upgraded from `console.log` to `console.error` for correct severity
- Loading placeholder display fixed when switching invoice tabs on mobile
## [1.0.2] - 2026-03-10
### Added
- QR code generation for invoices with customizable descriptions and visibility toggles, supported in both default and Stripe templates
- Logo upload for the default invoice template (previously available only in the Stripe template)
- Searchable currency combobox with grouped categories, replacing the native dropdown for faster selection
- Improved multi-page PDF support with automatic pagination and page breaks
### Changed
- Increased QR code size and improved rendering quality for better scannability
- Enhanced invoice template text color and visuals for improved readability
- Reorganized Stripe payment link input position in the form for better flow
- Improved user feedback during invoice item deletion with better toast notification handling
- Enhanced error handling to reset invoice metadata to defaults on errors
- Clearer error messages when invoice sharing fails
- Tooltip on the "Add invoice item" button for contextual guidance
- Sentry error tracking integration for invoice sharing and GitHub stars features
### Fixed
- i18n issue when generating PDF via the API route
- Delete invoice item flow not working correctly
- Item name field validation too strict (now optional for flexibility)
## [1.0.1] - 2026-01-12
### Added
- Stripe-inspired invoice template with professional styling and layout optimizations
- Dynamic template selection in the invoice form
- Logo upload capability for the Stripe template with validation
- Stripe payment URL field for enhanced invoice functionality
- Customizable Tax/VAT label text (e.g., "VAT", "GST", "Sales Tax")
- Customizable Tax Number label in buyer and seller information sections
- Dynamic tax label updates based on selected invoice language
### Changed
- Landing page cleanup: refined About section and footer for better layout and accessibility
- Call-to-action toasts: added custom, randomized CTA toasts encouraging user support
- Added support for more currencies with improved date handling
- Enhanced tooltips with detailed explanations and improved styling
- Enhanced validation for VAT input to accept both numeric values and specific strings
- Improved user interface messages for clarity regarding VAT input requirements
### Fixed
- Bug with accordion component
- Error message for invoice link generation now includes a refresh suggestion
## [1.0.0] - 2025-11-19
### Added
- Initial release of EasyInvoicePDF — a free, open-source invoice generator
- Live preview: invoice updates in real-time as you make changes
- Shareable links: generate secure links to share invoices directly with clients
- Instant PDF download with one click
- Multi-language support (English, Polish, German, Spanish, Portuguese, Russian, Ukrainian, French, Italian, Dutch)
- Support for all major currencies with automatic locale-based formatting
- European VAT calculation and formatting compliant with EU tax requirements
- Complete seller and buyer information management with save-for-future-use
- Detailed invoice items with descriptions, quantities, and pricing
- Automatic tax calculations and totals
- Invoice numbering, dating, and payment terms
- No sign-up required — fully browser-based with no server-side data storage
[1.0.3]: https://github.com/VladSez/easy-invoice-pdf/compare/v1.0.2...v1.0.3
[1.0.2]: https://github.com/VladSez/easy-invoice-pdf/compare/EasyInvoicePDF-1.0.1...v1.0.2
[1.0.1]: https://github.com/VladSez/easy-invoice-pdf/compare/EasyInvoicePDF-v1.0.0...EasyInvoicePDF-1.0.1
[1.0.0]: https://github.com/VladSez/easy-invoice-pdf/releases/tag/EasyInvoicePDF-v1.0.0

153
README.md
View file

@ -1,5 +1,8 @@
<div align="center">
<img src=".github/screenshots/easy-invoice-logo-readme.png" alt="EasyInvoicePDF Logo" width="80" height="80">
<!-- source: .github/screenshots/easy-invoice-logo-readme.png -->
<a href="https://easyinvoicepdf.com/?ref=github">
<img src="https://github.com/user-attachments/assets/cb9bcc91-b4c8-40b1-b406-bc606c5d9315" alt="EasyInvoicePDF Logo" width="80" height="80">
</a>
<h1>EasyInvoicePDF</h1>
<h3>Free & Open-Source Invoice Generator</h3>
<p>Create professional invoices instantly in your browser with <strong>Live Preview</strong>, <strong>Multiple Templates</strong> (including a Stripe-style design). <strong>No Sign-Up Required</strong>.</p>
@ -9,21 +12,27 @@
·
<a href="https://github.com/VladSez/easy-invoice-pdf/releases">Releases</a>
</p>
<a href="https://easyinvoicepdf.com/?template=stripe&ref=github">
<img width="1440" height="769" alt="EasyInvoicePDF Product Screenshot" src=".github/screenshots/stripe-template.png" />
<!-- source: .github/screenshots/stripe-template.png -->
<img width="1920" height="1536" alt="EasyInvoicePDF Product Screenshot" src="https://github.com/user-attachments/assets/6f0e2156-24e0-4f8b-b8f1-1d2bf5ac157a" />
</a>
<hr/>
<!-- source: .github/demos/easy-invoice-github-demo.gif -->
<img src="https://github.com/user-attachments/assets/450fcdc8-32fc-4f41-bc4b-54d6ac96e03c" width="1440" alt="EasyInvoicePDF demo">
</div>
## ✨ Key Features of EasyInvoicePDF:
- 📺 **Live PDF Preview**: See changes in real-time as you type
- ⭐ **No Sign-Up Required**: Start creating invoices immediately without any registration
- 📄 **Instant PDF**: One-click download ready for printing or sending
- ⚡ **Live Preview**: See changes in real-time as you type
- 🔗 **Shareable Links**: Send invoices directly to clients without attachments
- 🎨 **Multiple Templates**: Including modern **Stripe-style design**
- 📱 **Browser Only**: No server uploads, your data stays private
- 💰 **Flexible Tax Support**: VAT, GST, Sales Tax, and custom tax formats with automatic calculations
- 🌍 **Multi-Language & Currency**: Support for 10+ languages and 120+ currencies
- 🖥️ **Browser Only**: No server uploads, your data stays private
- 💰 **Flexible Tax Support**: VAT, GST, Sales Tax, and custom tax formats with automatic calculations
- 📱 **Mobile-Friendly**: Create invoices on the go from any device
- 🏞️ **QR Code Support**: Add payment QR codes with any invoice-related information (payment links, UPI, contact details, custom data)
- 📑 **Multi-Page PDFs**: Seamless multi-page support with automatic pagination and page breaks
@ -36,7 +45,9 @@
### 🎬 Invoice PDF Live Preview
<img src=".github/demos/live-preview.gif" width="800" alt="Live Preview Demo">
<!-- source: .github/demos/live-pdf-preview.gif -->
<img src="https://github.com/user-attachments/assets/bddb4b73-630a-4bc5-981b-d470971cd4c6" width="800" alt="Live Preview Demo">
_See changes in **real-time** as you type_
@ -44,66 +55,120 @@ _See changes in **real-time** as you type_
### 📥 Instant PDF Download
<img src=".github/demos/instant-download.gif" width="800" alt="Instant Download Demo">
<!-- source: .github/demos/instant-download.gif -->
<img src="https://github.com/user-attachments/assets/d85710c5-ac72-4d80-9c43-aeef980d0734" width="800" alt="Instant Download Demo">
_**One-click PDF download** ready for printing or sending_
---
### 🔗 Shareable Links
<img src=".github/demos/share-link.gif" width="800" alt="Shareable Links Demo">
_**Send invoices directly to clients** without attachments_
---
### 📲 QR Codes & Advanced Multi-Page PDF Support
<img src=".github/demos/qr-code.gif" width="800" alt="QR Code Support Demo">
_**Add payment QR codes** with any invoice-related information (payment links, UPI, contact details, custom data) and **seamless multi-page support** with automatic pagination and page breaks for large invoices_
---
### 🏷️ Customizable Tax Settings
<img src=".github/demos/tax-custom.gif" width="800" alt="Customizable Tax Settings Demo">
_**Customize tax labels** (VAT, Sales Tax, IVA, etc.)_
---
### 🌍 Language & Currency
<img src=".github/demos/lang-currency.gif" width="800" alt="Language & Currency Demo">
<!-- source: .github/demos/lang-currency.gif -->
<img src="https://github.com/user-attachments/assets/719f74a1-9be0-4b95-a8c1-a2749246aff3" width="800" alt="Language & Currency Demo">
_**Switch between 10 languages and 120+ currencies instantly** with live PDF preview updates_
---
### 🎨 Professional Invoice Templates
### 🔗 Shareable Links
<img src=".github/demos/invoice-template.gif" width="800" alt="Invoice Templates Demo">
<!-- source: .github/demos/shared-links.gif -->
_**Choose between multiple professional templates** (Default and Stripe) to match your brand and style_
<img src="https://github.com/user-attachments/assets/78f3c560-8dde-4f73-8832-4d74a38a2cee" width="800" alt="Shareable Links Demo">
| Default Invoice Template | Stripe Invoice Template |
| :-----------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------: |
| <img src=".github/screenshots/default-template.png" width="1200" height="960" alt="Default Invoice Template"> | <img src=".github/screenshots/stripe-template.png" width="1200" height="960" alt="Stripe Invoice Template"> |
_**Send invoices directly to clients** without attachments_
---
### 💰 Customizable Tax Settings
<!-- source: .github/demos/tax-custom.gif -->
<img src="https://github.com/user-attachments/assets/dab10991-6e76-4851-ab4f-25f52da2b6ed" width="800" alt="Customizable Tax Settings Demo">
_**Customize tax labels** (VAT, Sales Tax, IVA, etc.)_
---
### 🏞️ QR Codes & Advanced Multi-Page PDF Support
<!-- source: .github/demos/qr-code.gif -->
<img src="https://github.com/user-attachments/assets/2baf6c39-16a8-47c2-8f08-d03de6d9e593" width="800" alt="QR Code Support Demo">
_**Add payment QR codes** with any invoice-related information (payment links, UPI, contact details, custom data) and **seamless multi-page support** with automatic pagination and page breaks for large invoices_
---
<!-- source: .github/screenshots/default-template.png -->
<!-- source: .github/screenshots/stripe-template.png -->
| Default Invoice Template | Stripe Invoice Template |
| :--------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------: |
| <img src="https://github.com/user-attachments/assets/7779cd52-e2a9-442a-8ac8-262387319b3e" width="1200" height="960" alt="Default Invoice Template"> | <img src="https://github.com/user-attachments/assets/ee8d5337-89fc-461c-b1c1-2680287df514" width="1200" height="960" alt="Stripe Invoice Template"> |
</div>
## 📢 What's New
### EasyInvoicePDF v1.0.3 — Seller & Buyer Improvements (March 29, 2026)
- **Seller & Buyer Email visibility toggle** — control whether email addresses appear in the generated PDF
- **Confirm discard dialog** — warns about unsaved changes when closing buyer/seller dialogs
- **Improved seller & buyer forms** — reworked layout, locked-state banners, and cleaner field grouping
- **Out-of-Date dates helper** shows outdated fields and provides a button to update all dates at once
- **Auto-scroll (to the last position) the invoice form on mobile** when switching between tabs (UX improvement)
https://github.com/user-attachments/assets/1b39eb6f-e2be-493f-9825-cbce3dc6fa16
[Full release notes for v1.0.3](https://github.com/VladSez/easy-invoice-pdf/releases/tag/v1.0.3)
---
### EasyInvoicePDF v1.0.2 — QR Codes & Multi-Page PDFs (March 10, 2026)
- **QR code support** — add payment QR codes with custom descriptions to both templates
- **Logo upload for default template** — add a logo to the default invoice template
- **Searchable currency combobox** — search by currency code, symbol, or name, grouped into categories replacing the native dropdown
- **Improved multi-page PDFs** — automatic pagination and page breaks for large invoices
![qr-code](https://github.com/user-attachments/assets/9516a709-3863-48fc-a1e8-fcfbd0bb195b)
[Full release notes for v1.0.2](https://github.com/VladSez/easy-invoice-pdf/releases/tag/v1.0.2)
---
### EasyInvoicePDF v1.0.1 — Customizable Tax/VAT Labels & Major Improvements (January 12, 2026)
- **Customizable tax labels** — set VAT, GST, Sales Tax, or any custom label per invoice language
- **Improved i18n** — dynamic tax label updates and better locale-based currency handling
- **Enhanced VAT validation** — accepts numeric values and specific strings
https://github.com/user-attachments/assets/4eef2b90-678b-4a55-9ee5-8fcf195c993a
[Full release notes for v1.0.1](https://github.com/VladSez/easy-invoice-pdf/releases/tag/EasyInvoicePDF-1.0.1)
---
### EasyInvoicePDF v1.0.0 — Initial Release (November 19, 2025)
- **Live preview** — invoice updates in real-time as you type
- **Instant PDF download** — one-click, no sign-up required
- **Default and Stripe-inspired invoice templates** — choose the look you want
- **Shareable links** — send invoices directly to clients without attachments
- **10 languages & 120+ currencies** — full multi-language and currency support out of the box
https://github.com/user-attachments/assets/23bb5448-c9fb-4ff2-98f3-0b80d75b7683
[Full release notes for v1.0.0](https://github.com/VladSez/easy-invoice-pdf/releases/tag/EasyInvoicePDF-v1.0.0)
## 🌟 Star History
[![RepoStars](https://repostars.dev/api/embed?repo=VladSez%2Feasy-invoice-pdf&theme=dark)](https://repostars.dev/?repos=VladSez%2Feasy-invoice-pdf&theme=dark)
## 📢 News & Updates
- **Mar 10, 2026**: Added QR code support, logo upload for the default template, searchable currency combobox, and improved multi-page PDF support. [Release notes for v1.0.2](https://github.com/VladSez/easy-invoice-pdf/releases/tag/v1.0.2)
- **Jan 11, 2026**: Added customizable tax/VAT labels, improved internationalization (i18n) translations, enhanced overall performance, and fixed multiple bugs. [Release notes for v1.0.1](https://github.com/VladSez/easy-invoice-pdf/releases/tag/EasyInvoicePDF-1.0.1)
- **Nov 19, 2025**: EasyInvoicePDF version 1.0.0 released! Create professional invoices in seconds. Welcome to try EasyInvoicePDF. [Release notes for v1.0.0](https://github.com/VladSez/easy-invoice-pdf/releases/tag/EasyInvoicePDF-v1.0.0)
## 🎥 Demo Video
Watch a quick demo of EasyInvoicePDF in action to see how easy it is to create professional invoices in seconds. The video demonstrates key features like **Live Preview**, **Instant PDF Download**, and **Customization Options**.

View file

@ -1,13 +1,13 @@
import {
GITHUB_URL,
TWITTER_URL,
VIDEO_DEMO_FALLBACK_IMG,
VIDEO_DEMO_URL,
} from "@/config";
/* eslint-disable playwright/no-conditional-expect */
/* eslint-disable playwright/no-conditional-in-test */
import { GITHUB_URL, TWITTER_URL, VIDEO_DEMO_FALLBACK_IMG } from "@/config";
import { expect, test } from "@playwright/test";
test.describe("About page", () => {
test("should display about page content in English", async ({ page }) => {
test("should display about page content in English", async ({
page,
isMobile,
}) => {
await page.goto("/en/about");
// Verify the page is loaded
@ -21,11 +21,59 @@ test.describe("About page", () => {
/* CHECK HEADER ELEMENTS */
// Check language switcher button in header
const languageSwitcher = header.getByRole("button", {
name: "Switch language",
});
await expect(languageSwitcher).toBeVisible();
if (isMobile) {
// Mobile: burger button visible, nav links and language switcher hidden in header
await expect(
header.getByRole("button", { name: "Open menu" }),
).toBeVisible();
await expect(
header.getByRole("button", { name: "Switch language" }),
).toBeHidden();
await expect(
header.getByRole("link", { name: "Features", exact: true }),
).toBeHidden();
} else {
// Desktop: nav links and language switcher visible inline, burger button hidden
await expect(
header.getByRole("button", { name: "Switch language" }),
).toBeVisible();
const featuresLink = header.getByRole("link", {
name: "Features",
exact: true,
});
await expect(featuresLink).toBeVisible();
await expect(featuresLink).toHaveAttribute("href", "/en/about#features");
const faqLink = header.getByRole("link", { name: "FAQ", exact: true });
await expect(faqLink).toBeVisible();
await expect(faqLink).toHaveAttribute("href", "/en/about#faq");
const changelogLink = header.getByRole("link", {
name: "Changelog",
exact: true,
});
await expect(changelogLink).toBeVisible();
await expect(changelogLink).toHaveAttribute("href", "/changelog");
const githubLink = header.getByRole("link", {
name: "View on GitHub",
exact: true,
});
await expect(githubLink).toBeVisible();
await expect(githubLink).toHaveAttribute("href", GITHUB_URL);
// hidden on desktop
await expect(
header.getByRole("button", { name: "Open menu" }),
).toBeHidden();
}
// check app link button in header
await expect(
@ -33,7 +81,7 @@ test.describe("About page", () => {
).toBeVisible();
const goToAppButton = header.getByRole("link", {
name: "Go to app",
name: "Open app",
exact: true,
});
await expect(goToAppButton).toBeVisible();
@ -64,15 +112,7 @@ test.describe("About page", () => {
await expect(video).toHaveAttribute("muted");
await expect(video).toHaveAttribute("loop");
await expect(video).toHaveAttribute("playsinline");
await expect(video).toHaveAttribute("preload", "auto");
await expect(video).toHaveAttribute("autoplay");
const videoSource = video.locator("source");
await expect(videoSource).toHaveAttribute(
"src",
`${VIDEO_DEMO_URL}#t=0.001`,
);
await expect(videoSource).toHaveAttribute("type", "video/mp4");
await expect(video).toHaveAttribute("preload", "none");
// Check Features section
const featuresSection = page.locator("#features");
@ -172,6 +212,24 @@ test.describe("About page", () => {
await expect(changelogLink).toHaveAttribute("href", "/changelog");
await expect(changelogLink).not.toHaveAttribute("target", "_blank");
const founderLink = footerLinks.getByRole("link", {
name: "Founder",
exact: true,
});
await expect(founderLink).toBeVisible();
await expect(founderLink).toHaveAttribute("href", "/founder");
await expect(founderLink).not.toHaveAttribute("target", "_blank");
const termsOfServiceLink = footerLinks.getByRole("link", {
name: "Terms of Service",
exact: true,
});
await expect(termsOfServiceLink).toBeVisible();
await expect(termsOfServiceLink).toHaveAttribute("href", "/tos");
await expect(termsOfServiceLink).not.toHaveAttribute("target", "_blank");
const shareFeedbackLink = footerLinks.getByRole("link", {
name: "Share feedback",
exact: true,
@ -271,6 +329,15 @@ test.describe("About page", () => {
await expect(featuresLink).toBeVisible();
await expect(featuresLink).toHaveAttribute("href", "#features");
await expect(featuresLink).not.toHaveAttribute("target", "_blank");
const termsOfServiceLinkFr = footerLinks.getByRole("link", {
name: "Conditions d'utilisation",
exact: true,
});
await expect(termsOfServiceLinkFr).toBeVisible();
await expect(termsOfServiceLinkFr).toHaveAttribute("href", "/tos");
await expect(termsOfServiceLinkFr).not.toHaveAttribute("target", "_blank");
});
test("should display about page content in German", async ({ page }) => {
@ -333,27 +400,41 @@ test.describe("About page", () => {
await expect(
footerLinks.getByRole("link", { name: "Funktionen", exact: true }),
).toBeVisible();
const termsOfServiceLinkDe = footerLinks.getByRole("link", {
name: "Nutzungsbedingungen",
exact: true,
});
await expect(termsOfServiceLinkDe).toBeVisible();
await expect(termsOfServiceLinkDe).toHaveAttribute("href", "/tos");
await expect(termsOfServiceLinkDe).not.toHaveAttribute("target", "_blank");
});
test("should handle language switching", async ({ page }) => {
test("should handle language switching", async ({ page, isMobile }) => {
// Start with English
await page.goto("/en/about");
await expect(page).toHaveURL("/en/about");
// Switch to French
// On mobile, open the mobile menu first
if (isMobile) await page.getByRole("button", { name: "Open menu" }).click();
// Then switch to French
await page
.getByRole("button", { name: "Switch language", exact: true })
.click();
await page.getByText("Français").click();
await expect(page).toHaveURL("/fr/about");
const header = page.getByRole("banner");
await expect(
header.getByRole("link", {
name: "Ouvrir",
exact: true,
}),
).toBeVisible();
await expect(page).toHaveURL("/fr/about");
});
test("should navigate to app when clicking Go to App button", async ({
@ -366,11 +447,135 @@ test.describe("About page", () => {
const header = page.getByRole("banner");
const headerGoToAppButton = header.getByRole("link", {
name: "Go to app",
name: "Open app",
exact: true,
});
await headerGoToAppButton.click();
await expect(page).toHaveURL("/?template=default");
});
test("should show desktop nav links in header (ON MOBILE TEST WILL BE SKIPPED)", async ({
page,
isMobile,
}) => {
// skip test on mobile
// eslint-disable-next-line playwright/no-skipped-test
test.skip(isMobile, "Desktop nav only exists on desktop viewport");
await page.goto("/en/about");
const header = page.getByRole("banner");
await expect(
header.getByRole("link", { name: "Features", exact: true }),
).toBeVisible();
await expect(
header.getByRole("link", { name: "FAQ", exact: true }),
).toBeVisible();
await expect(
header.getByRole("link", { name: "Changelog", exact: true }),
).toBeVisible();
await expect(
header.getByRole("link", { name: "Terms of Service", exact: true }),
).toBeVisible();
await expect(
header.getByRole("link", { name: "View on GitHub", exact: true }),
).toBeVisible();
// we don't show founder link in header
await expect(
header.getByRole("link", { name: "Founder", exact: true }),
).toBeHidden();
await expect(
header.getByRole("button", { name: "Switch language" }),
).toBeVisible();
// hidden on desktop
await expect(
header.getByRole("button", { name: "Open menu" }),
).toBeHidden();
});
test("should show mobile menu with nav links and language switcher (ON DESKTOP TEST WILL BE SKIPPED)", async ({
page,
isMobile,
}) => {
// skip test on desktop
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!isMobile, "Mobile menu only exists on mobile viewport");
await page.goto("/en/about");
const header = page.getByRole("banner");
const burgerButton = header.getByRole("button", { name: "Open menu" });
// Burger button visible on MOBILE, nav links and language switcher not visible in header
await expect(burgerButton).toBeVisible();
await expect(
header.getByRole("link", { name: "Features", exact: true }),
).toBeHidden();
await expect(
header.getByRole("button", { name: "Switch language" }),
).toBeHidden();
// Open the mobile menu
await burgerButton.click();
const sheet = page.getByRole("dialog", { name: "Mobile Menu" });
const featuresLink = sheet.getByRole("link", {
name: "Features",
exact: true,
});
await expect(featuresLink).toBeVisible();
await expect(featuresLink).toHaveAttribute("href", "/en/about#features");
const faqLink = sheet.getByRole("link", { name: "FAQ", exact: true });
await expect(faqLink).toBeVisible();
await expect(faqLink).toHaveAttribute("href", "/en/about#faq");
const changelogLink = sheet.getByRole("link", {
name: "Changelog",
exact: true,
});
await expect(changelogLink).toBeVisible();
await expect(changelogLink).toHaveAttribute("href", "/changelog");
const termsLinkMobile = sheet.getByRole("link", {
name: "Terms of Service",
exact: true,
});
await expect(termsLinkMobile).toBeVisible();
await expect(termsLinkMobile).toHaveAttribute("href", "/tos");
const githubLink = sheet.getByRole("link", {
name: "View on GitHub",
exact: true,
});
await expect(githubLink).toBeVisible();
await expect(githubLink).toHaveAttribute("href", GITHUB_URL);
// we don't show founder link in mobile menu
const founderLinkMobile = sheet.getByRole("link", {
name: "Founder",
exact: true,
});
await expect(founderLinkMobile).toBeHidden();
await expect(
sheet.getByRole("button", { name: "Switch language" }),
).toBeVisible();
// Close the menu and verify burger button is accessible again
await sheet.getByRole("button", { name: "Close menu" }).click();
await expect(burgerButton).toBeVisible();
});
});

View file

@ -1099,61 +1099,4 @@ test.describe("Buyer management", () => {
expect(parsedData).toHaveLength(1);
expect(parsedData[0].name).toBe("Globex Corp");
});
test("drops invalid localStorage entries and preserves valid ones on save", async ({
page,
}) => {
// Seed: one valid + one corrupt buyer (component already mounted with empty localStorage,
// so "New Buyer" button is still visible — onSubmit reads localStorage fresh)
await page.addInitScript(() => {
const buyers = [
{
id: "pre-seeded-valid",
name: "Pre-seeded Valid Buyer",
address: "1 Valid Avenue",
email: "valid@pre-seeded.com",
emailFieldIsVisible: true,
vatNo: "VATPRE",
vatNoLabelText: "Tax Number",
vatNoFieldIsVisible: true,
notes: "",
notesFieldIsVisible: true,
},
{ corrupt: true, notABuyer: 42 },
];
localStorage.setItem("EASY_INVOICE_PDF_BUYERS", JSON.stringify(buyers));
});
await page.goto("/?template=default");
await expect(page).toHaveURL("/?template=default");
await page.getByRole("button", { name: "New Buyer" }).click();
const manageBuyerDialog = page.getByTestId("manage-buyer-dialog");
await manageBuyerDialog
.getByRole("textbox", { name: "Name" })
.fill("Brand New Buyer");
await manageBuyerDialog
.getByRole("textbox", { name: "Address" })
.fill("99 Fresh Boulevard");
await manageBuyerDialog.getByRole("button", { name: "Save Buyer" }).click();
// Submit succeeds — no error toast
await expect(
page.getByText("Buyer added and applied to invoice", { exact: true }),
).toBeVisible();
// localStorage: valid entry preserved, corrupt entry dropped, new entry added
const storedData = (await page.evaluate(() =>
localStorage.getItem("EASY_INVOICE_PDF_BUYERS"),
)) as string;
const parsedData = JSON.parse(storedData) as BuyerData[];
expect(parsedData).toHaveLength(2);
expect(parsedData.some((b) => b.name === "Pre-seeded Valid Buyer")).toBe(
true,
);
expect(parsedData.some((b) => b.name === "Brand New Buyer")).toBe(true);
});
});

View file

@ -80,10 +80,6 @@ test.describe("Changelog page", () => {
await expect(backLink).toBeVisible();
await expect(backLink).toHaveAttribute("href", "/changelog");
// Check that the entry has a title (h1)
const entryTitle = page.locator("h1").first();
await expect(entryTitle).toBeVisible();
// Check author information
await expect(page.getByTestId("author-info-text")).toHaveText(
"Vlad SazonauFounder, EasyInvoicePDF",
@ -101,14 +97,14 @@ test.describe("Changelog page", () => {
);
await expect(linkedinShareLink).toBeVisible();
// Check "Go to App" CTA button
// Check CTA button
const goToAppButtonContainer = page.getByTestId(
"go-to-app-button-container",
);
const goToAppButton = goToAppButtonContainer.getByRole("link");
await expect(goToAppButton).toBeVisible();
await expect(goToAppButton).toHaveText("Go to App");
const ctaButton = goToAppButtonContainer.getByRole("link");
await expect(ctaButton).toBeVisible();
await expect(ctaButton).toHaveText("Start Invoicing");
});
test("should navigate back to changelog from individual entry", async ({

View file

@ -2,10 +2,7 @@ import { INITIAL_INVOICE_DATA } from "@/app/constants";
import { INVOICE_PDF_TRANSLATIONS } from "@/app/(app)/pdf-i18n-translations/pdf-translations";
import fs from "node:fs";
import path from "node:path";
import {
SMALL_TEST_IMAGE_BASE64,
uploadBase64LogoAsFile,
} from "../stripe-invoice-template/utils";
import { uploadLogoFile } from "../stripe-invoice-template/utils";
// IMPORTANT: we use custom extended test fixture that provides a temporary download directory for each test
import { test, expect } from "../utils/extended-playwright-test";
@ -19,7 +16,7 @@ test.describe("Default Invoice Template", () => {
// we set the system time to a fixed date, so that the invoice number and other dates are consistent across tests
await page.clock.setSystemTime(new Date("2025-12-17T00:00:00Z"));
await page.goto("/");
await page.goto("/?template=default");
await expect(page).toHaveURL("/?template=default");
});
@ -782,14 +779,16 @@ test.describe("Default Invoice Template", () => {
await download.saveAs(pdfFilePath);
const downloadPdfToast = page.getByTestId("download-pdf-toast");
// Verify toast appears after download
await expect(page.getByTestId("download-pdf-toast")).toBeVisible();
await expect(downloadPdfToast).toBeVisible();
await expect(
page.getByRole("link", { name: "Star on GitHub" }),
downloadPdfToast.getByRole("link", { name: "Star on GitHub" }),
).toBeVisible();
await expect(page.getByTestId("toast-cta-btn")).toBeVisible();
await expect(downloadPdfToast.getByTestId("toast-cta-btn")).toBeVisible();
// Convert to absolute path and use proper file URL format
const absolutePath = path.resolve(pdfFilePath);
@ -1579,7 +1578,7 @@ test.describe("Default Invoice Template", () => {
const generalInfoSection = page.getByTestId("general-information-section");
// Upload a valid logo
await page.evaluate(uploadBase64LogoAsFile, SMALL_TEST_IMAGE_BASE64);
await uploadLogoFile(page);
// Verify logo preview is visible
await expect(page.getByText("Logo uploaded successfully!")).toBeVisible();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 146 KiB

BIN
e2e/fixtures/app-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View file

@ -14,7 +14,11 @@ import {
import { expect, test } from "@playwright/test";
import dayjs from "dayjs";
import { INITIAL_INVOICE_DATA } from "../src/app/constants";
import { GITHUB_URL, STATIC_ASSETS_URL, VIDEO_DEMO_URL } from "@/config";
import {
GITHUB_URL,
STATIC_ASSETS_URL,
VIDEO_DEMO_YOUTUBE_URL,
} from "@/config";
test.describe("Invoice Generator Page", () => {
test.beforeEach(async ({ page }) => {
@ -129,18 +133,16 @@ test.describe("Invoice Generator Page", () => {
),
).toBeVisible();
// Check that video is displayed in dialog
// Check that demo embed is displayed in dialog
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
const video = dialog.getByTestId("how-it-works-video");
const embed = dialog.getByTestId("how-it-works-video");
await expect(video).toBeVisible();
await expect(embed).toBeVisible();
await expect(video).toHaveAttribute("src", VIDEO_DEMO_URL);
await expect(video).toHaveAttribute("autoplay", "");
await expect(video).toHaveAttribute("controls", "");
await expect(video).toHaveAttribute("playsInline", "");
await expect(embed).toHaveAttribute("src", VIDEO_DEMO_YOUTUBE_URL);
await expect(embed).toHaveAttribute("title", "EasyInvoicePDF Demo Video");
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
@ -176,12 +178,36 @@ test.describe("Invoice Generator Page", () => {
await expect(page.getByRole("tab", { name: "Edit Invoice" })).toBeVisible();
await expect(page.getByRole("tab", { name: "Preview PDF" })).toBeVisible();
const termsOfServiceMobile = page.getByTestId(
"mobile-terms-of-service-link",
);
const termsOfServiceLinkMobile = termsOfServiceMobile.getByRole("link");
// Check that Terms of Service are displayed
await expect(termsOfServiceMobile).toBeVisible();
await expect(termsOfServiceMobile).toHaveText(
"By using this tool, you agree to the Terms of Service",
);
await expect(termsOfServiceLinkMobile).toHaveAttribute("href", "/tos");
// Test desktop view
await page.setViewportSize({ width: 1280, height: 800 });
// check that tabs are not visible in desktop view
await expect(page.getByRole("tab", { name: "Edit Invoice" })).toBeHidden();
await expect(page.getByRole("tab", { name: "Preview PDF" })).toBeHidden();
const termsOfServiceDesktop = page.getByTestId(
"desktop-terms-of-service-link",
);
const termsOfServiceLinkDesktop = termsOfServiceDesktop.getByRole("link");
// Check that Terms of Service are displayed
await expect(termsOfServiceDesktop).toBeVisible();
await expect(termsOfServiceDesktop).toHaveText(
"By using this tool, you agree to the Terms of Service",
);
await expect(termsOfServiceLinkDesktop).toHaveAttribute("href", "/tos");
});
test("displays initial form state correctly", async ({ page }) => {
@ -1390,12 +1416,6 @@ test.describe("Invoice Generator Page", () => {
generalInfoSection.getByText("Date of issue is not today"),
).toBeVisible();
const dateOfIssueBtn = generalInfoSection.getByRole("button", {
name: "Set date of issue to today (2025-12-01)",
});
await expect(dateOfIssueBtn).toBeVisible();
await expect(dateOfIssueBtn).toBeEnabled();
// Set date of service to a date that's not the last day of current month
const dateOfServiceInput = generalInfoSection.getByLabel("Date of Service");
await dateOfServiceInput.fill("2024-01-20");
@ -1407,12 +1427,6 @@ test.describe("Invoice Generator Page", () => {
),
).toBeVisible();
const dateOfServiceBtn = generalInfoSection.getByRole("button", {
name: "Set date of service to month end (2025-12-31)",
});
await expect(dateOfServiceBtn).toBeVisible();
await expect(dateOfServiceBtn).toBeEnabled();
// Set invoice number to an old month to trigger stale state
const invoiceNumberInput = generalInfoSection.getByLabel("Value");
await invoiceNumberInput.fill("1/01-2024");
@ -1424,12 +1438,6 @@ test.describe("Invoice Generator Page", () => {
),
).toBeVisible();
const invoiceNumberBtn = generalInfoSection.getByRole("button", {
name: "Set invoice number to current month (1/12-2025)",
});
await expect(invoiceNumberBtn).toBeVisible();
await expect(invoiceNumberBtn).toBeEnabled();
// Set payment due to a stale date (date of issue + 1 day instead of + 14 days)
const paymentDueInput = page.getByLabel("Payment Due");
await paymentDueInput.fill("2024-01-16");

View file

@ -1165,67 +1165,4 @@ test.describe("Seller management", () => {
expect(parsedData).toHaveLength(1);
expect(parsedData[0].name).toBe("Acme Corp");
});
test("drops invalid localStorage entries and preserves valid ones on save", async ({
page,
}) => {
// Seed: one valid + one corrupt seller (component already mounted with empty localStorage,
// so "New Seller" button is still visible — onSubmit reads localStorage fresh)
await page.evaluate(() => {
const sellers = [
{
id: "pre-seeded-valid",
name: "Pre-seeded Valid Seller",
address: "1 Valid Road",
email: "valid@pre-seeded.com",
emailFieldIsVisible: true,
vatNo: "VATPRE",
vatNoLabelText: "Tax Number",
vatNoFieldIsVisible: true,
accountNumber: "ACCTPRE",
accountNumberFieldIsVisible: true,
swiftBic: "SWIFTPRE",
swiftBicFieldIsVisible: true,
notes: "",
notesFieldIsVisible: true,
},
{ corrupt: true, notASeller: 42 },
];
localStorage.setItem("EASY_INVOICE_PDF_SELLERS", JSON.stringify(sellers));
});
await page.goto("/?template=default");
await expect(page).toHaveURL("/?template=default");
await page.getByRole("button", { name: "New Seller" }).click();
const manageSellerDialog = page.getByTestId("manage-seller-dialog");
await manageSellerDialog
.getByRole("textbox", { name: "Name" })
.fill("Brand New Seller");
await manageSellerDialog
.getByRole("textbox", { name: "Address" })
.fill("99 Fresh Lane");
await manageSellerDialog
.getByRole("button", { name: "Save Seller" })
.click();
// Submit succeeds — no error toast
await expect(
page.getByText("Seller added and applied to invoice", { exact: true }),
).toBeVisible();
// localStorage: valid entry preserved, corrupt entry dropped, new entry added
const storedData = (await page.evaluate(() =>
localStorage.getItem("EASY_INVOICE_PDF_SELLERS"),
)) as string;
const parsedData = JSON.parse(storedData) as SellerData[];
expect(parsedData).toHaveLength(2);
expect(parsedData.some((s) => s.name === "Pre-seeded Valid Seller")).toBe(
true,
);
expect(parsedData.some((s) => s.name === "Brand New Seller")).toBe(true);
});
});

View file

@ -1,10 +1,10 @@
import { PDF_DATA_LOCAL_STORAGE_KEY, type InvoiceData } from "@/app/schema";
import { expect, test } from "@playwright/test";
import { SMALL_TEST_IMAGE_BASE64, uploadBase64LogoAsFile } from "./utils";
import { uploadLogoFile } from "./utils";
test.describe("Stripe Invoice Sharing Logic", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.goto("/?template=default");
});
test("can share invoice with Stripe template and *WITHOUT* logo", async ({
@ -67,13 +67,13 @@ test.describe("Stripe Invoice Sharing Logic", () => {
// Verify logo upload section is visible (but empty since no logo was shared)
await expect(
newPageGeneralInfoSection.getByText("Company Logo (Optional)"),
newPageGeneralInfoSection.getByText("Company Logo", { exact: true }),
).toBeVisible();
// Verify payment URL section is visible
await expect(
newPageGeneralInfoSection.getByRole("textbox", {
name: "Payment Link URL (Optional)",
name: "Payment Link URL",
}),
).toBeVisible();
@ -109,7 +109,7 @@ test.describe("Stripe Invoice Sharing Logic", () => {
.selectOption("stripe");
// Upload logo
await page.evaluate(uploadBase64LogoAsFile, SMALL_TEST_IMAGE_BASE64);
await uploadLogoFile(page);
// Wait for logo to be uploaded
const generalInfoSection = page.getByTestId("general-information-section");
@ -148,7 +148,7 @@ test.describe("Stripe Invoice Sharing Logic", () => {
.selectOption("stripe");
// Upload logo
await page.evaluate(uploadBase64LogoAsFile, SMALL_TEST_IMAGE_BASE64);
await uploadLogoFile(page);
// Wait debounce timeout
// eslint-disable-next-line playwright/no-wait-for-timeout
@ -223,7 +223,7 @@ test.describe("Stripe Invoice Sharing Logic", () => {
.selectOption("stripe");
// Upload logo
await page.evaluate(uploadBase64LogoAsFile, SMALL_TEST_IMAGE_BASE64);
await uploadLogoFile(page);
// Verify share button becomes disabled
await expect(shareButton).toHaveAttribute("data-disabled", "true");
@ -259,7 +259,7 @@ test.describe("Stripe Invoice Sharing Logic", () => {
await page.waitForURL("/?template=stripe");
// Upload logo
await page.evaluate(uploadBase64LogoAsFile, SMALL_TEST_IMAGE_BASE64);
await uploadLogoFile(page);
// Wait for upload and verify share button is disabled
const generalInfoSection = page.getByTestId("general-information-section");
@ -287,9 +287,7 @@ test.describe("Stripe Invoice Sharing Logic", () => {
const parsedData = JSON.parse(storedData) as InvoiceData;
expect(parsedData).toMatchObject({
logo: SMALL_TEST_IMAGE_BASE64,
} satisfies Pick<InvoiceData, "logo">);
expect(parsedData.logo).toBeTruthy();
// Reload the page
await page.reload();
@ -330,7 +328,7 @@ test.describe("Stripe Invoice Sharing Logic", () => {
.selectOption("stripe");
// Upload logo
await page.evaluate(uploadBase64LogoAsFile, SMALL_TEST_IMAGE_BASE64);
await uploadLogoFile(page);
// Wait for logo to be uploaded
// eslint-disable-next-line playwright/no-wait-for-timeout

View file

@ -6,7 +6,7 @@ import {
} from "@/app/schema";
import fs from "node:fs";
import path from "node:path";
import { SMALL_TEST_IMAGE_BASE64, uploadBase64LogoAsFile } from "./utils";
import { uploadLogoFile } from "./utils";
// IMPORTANT: we use custom extended test fixture that provides a temporary download directory for each test
import { expect, test } from "../utils/extended-playwright-test";
@ -21,7 +21,7 @@ test.describe("Stripe Invoice Template", () => {
// we set the system time to a fixed date, so that the invoice number and other dates are consistent across tests
await page.clock.setSystemTime(new Date("2025-12-17T00:00:00Z"));
await page.goto("/");
await page.goto("/?template=default");
});
test("displays correct OG meta tags for Stripe template", async ({
@ -83,7 +83,7 @@ test.describe("Stripe Invoice Template", () => {
// Logo section is visible on default template
await expect(
generalInfoSection.getByText("Company Logo (Optional)"),
generalInfoSection.getByText("Company Logo", { exact: true }),
).toBeVisible();
await expect(
generalInfoSection.getByTestId("logo-upload-input"),
@ -92,7 +92,7 @@ test.describe("Stripe Invoice Template", () => {
// Payment URL section should not be visible on default template
await expect(
generalInfoSection.getByRole("textbox", {
name: "Payment Link URL (Optional)",
name: "Payment Link URL",
}),
).toBeHidden();
@ -112,7 +112,7 @@ test.describe("Stripe Invoice Template", () => {
).toBeVisible();
await expect(
generalInfoSection.getByText("Company Logo (Optional)"),
generalInfoSection.getByText("Company Logo", { exact: true }),
).toBeVisible();
await expect(
generalInfoSection.getByText("Click to upload your company logo"),
@ -124,7 +124,7 @@ test.describe("Stripe Invoice Template", () => {
// Payment URL section should now be visible on Stripe template
await expect(
generalInfoSection.getByRole("textbox", {
name: "Payment Link URL (Optional)",
name: "Payment Link URL",
}),
).toBeVisible();
@ -135,7 +135,7 @@ test.describe("Stripe Invoice Template", () => {
// Logo section remains visible on default template
await expect(
generalInfoSection.getByText("Company Logo (Optional)"),
generalInfoSection.getByText("Company Logo", { exact: true }),
).toBeVisible();
await expect(
@ -145,7 +145,7 @@ test.describe("Stripe Invoice Template", () => {
// Payment URL section should be hidden again on default template
await expect(
generalInfoSection.getByRole("textbox", {
name: "Payment Link URL (Optional)",
name: "Payment Link URL",
}),
).toBeHidden();
});
@ -226,7 +226,7 @@ test.describe("Stripe Invoice Template", () => {
const generalInfoSection = page.getByTestId("general-information-section");
// Upload a valid small image
await page.evaluate(uploadBase64LogoAsFile, SMALL_TEST_IMAGE_BASE64);
await uploadLogoFile(page);
// Should show success toast
await expect(page.getByText("Logo uploaded successfully!")).toBeVisible();
@ -259,7 +259,7 @@ test.describe("Stripe Invoice Template", () => {
.selectOption("stripe");
// Upload a valid small image
await page.evaluate(uploadBase64LogoAsFile, SMALL_TEST_IMAGE_BASE64);
await uploadLogoFile(page);
const generalInfoSection = page.getByTestId("general-information-section");
@ -304,7 +304,7 @@ test.describe("Stripe Invoice Template", () => {
const generalInfoSection = page.getByTestId("general-information-section");
const paymentUrlInput = generalInfoSection.getByRole("textbox", {
name: "Payment Link URL (Optional)",
name: "Payment Link URL",
});
// Try invalid URL
@ -337,11 +337,11 @@ test.describe("Stripe Invoice Template", () => {
// Add payment URL
await generalInfoSection
.getByRole("textbox", { name: "Payment Link URL (Optional)" })
.getByRole("textbox", { name: "Payment Link URL" })
.fill("https://buy.stripe.com/test_payment_link");
// Upload logo
await page.evaluate(uploadBase64LogoAsFile, SMALL_TEST_IMAGE_BASE64);
await uploadLogoFile(page);
// Wait for logo to be uploaded and PDF to regenerate
await expect(page.getByText("Logo uploaded successfully!")).toBeVisible();
@ -369,9 +369,7 @@ test.describe("Stripe Invoice Template", () => {
const parsedData = JSON.parse(storedData) as InvoiceData;
expect(parsedData).toMatchObject({
logo: SMALL_TEST_IMAGE_BASE64,
} satisfies Pick<InvoiceData, "logo">);
expect(parsedData.logo).toBeTruthy();
// Reload page
await page.reload();
@ -386,7 +384,7 @@ test.describe("Stripe Invoice Template", () => {
// Verify payment URL persists
await expect(
generalInfoSection.getByRole("textbox", {
name: "Payment Link URL (Optional)",
name: "Payment Link URL",
}),
).toHaveValue("https://buy.stripe.com/test_payment_link");
@ -900,7 +898,7 @@ test.describe("Stripe Invoice Template", () => {
await expect(page).toHaveURL("/?template=stripe");
// Upload a valid logo
await page.evaluate(uploadBase64LogoAsFile, SMALL_TEST_IMAGE_BASE64);
await uploadLogoFile(page);
// Wait for logo to be uploaded and PDF to regenerate
await expect(page.getByText("Logo uploaded successfully!")).toBeVisible();
@ -917,7 +915,7 @@ test.describe("Stripe Invoice Template", () => {
// Add payment URL
await generalInfoSection
.getByRole("textbox", { name: "Payment Link URL (Optional)" })
.getByRole("textbox", { name: "Payment Link URL" })
.fill("https://buy.stripe.com/test_payment_link");
// Wait a moment for any debounced localStorage updates
@ -933,9 +931,7 @@ test.describe("Stripe Invoice Template", () => {
const parsedData = JSON.parse(storedData) as InvoiceData;
expect(parsedData).toMatchObject({
logo: SMALL_TEST_IMAGE_BASE64,
} satisfies Pick<InvoiceData, "logo">);
expect(parsedData.logo).toBeTruthy();
const downloadPDFButton = page.getByRole("link", {
name: "Download PDF in English",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 149 KiB

View file

@ -1,51 +1,8 @@
/**
* Create a small test image in base64 format (1x1 pixel PNG)
*/
export const SMALL_TEST_IMAGE_BASE64 =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
import path from "node:path";
import type { Page } from "@playwright/test";
/**
* Upload a base64 image to the logo input
*/
export const uploadBase64LogoAsFile = (base64Data: string) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const fileInput = document.querySelector(
"#logoUpload",
) as HTMLInputElement | null;
const LOGO_FIXTURE_PATH = path.join(__dirname, "../fixtures/app-logo.png");
if (!fileInput) {
throw new Error('Logo upload input "#logoUpload" was not found');
}
if (!base64Data) {
throw new Error("Base64 data is required");
}
// Convert base64 data to binary string by removing metadata and decoding
const byteString = atob(base64Data.split(",")[1]);
// Extract MIME type from base64 metadata (e.g. "image/png")
const mimeString = base64Data.split(",")[0].split(":")[1].split(";")[0];
// Create ArrayBuffer to store binary data
const ab = new ArrayBuffer(byteString.length);
// Create Uint8Array view to write bytes to ArrayBuffer
const ia = new Uint8Array(ab);
// Convert binary string to bytes and write to array
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
// Create File object from ArrayBuffer with name and MIME type
const file = new File([ab], "test-logo.png", { type: mimeString });
// Create DataTransfer to simulate file input
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
// Set file input's files property and trigger change event
fileInput.files = dataTransfer.files;
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
};
export async function uploadLogoFile(page: Page) {
await page.setInputFiles("#logoUpload", LOGO_FIXTURE_PATH);
}

65
e2e/tos.test.ts Normal file
View file

@ -0,0 +1,65 @@
import { expect, test } from "@playwright/test";
test.describe("Terms of Service page", () => {
test("should display terms of service content", async ({ page }) => {
await page.goto("/tos");
await expect(page).toHaveURL("/tos");
await expect(page).toHaveTitle(
"Terms of Service | EasyInvoicePDF - Free & Open-Source Invoice Generator",
);
await expect(
page.getByRole("heading", {
level: 1,
name: "Terms of Service",
exact: true,
}),
).toBeVisible();
await expect(
page.getByRole("heading", {
level: 2,
name: "1. Overview",
exact: true,
}),
).toBeVisible();
await expect(
page.getByText(
'EasyInvoicePDF ("the Service") is a browser-based tool that allows users to generate invoice PDFs. By using the Service, you agree to these Terms.',
{ exact: false },
),
).toBeVisible();
await expect(
page.getByRole("heading", {
level: 2,
name: "8. No Data Storage",
exact: true,
}),
).toBeVisible();
await expect(
page.getByRole("heading", {
level: 2,
name: "12. Contact",
exact: true,
}),
).toBeVisible();
await expect(
page.getByText("Effective date:", { exact: false }),
).toBeVisible();
const contactLink = page.getByRole("link", {
name: "vlad@mail.easyinvoicepdf.com",
});
await expect(contactLink).toBeVisible();
await expect(contactLink).toHaveAttribute(
"href",
"mailto:vlad@mail.easyinvoicepdf.com",
);
});
});

View file

@ -62,6 +62,7 @@ export default tseslint.config(
],
},
],
"@next/next/no-img-element": "off",
},
},
{

View file

@ -6,7 +6,6 @@ const config: KnipConfig = {
"shadcn",
"@radix-ui/react-separator",
"@types/ua-parser-js",
"cmdk",
"eslint-plugin-react-hooks",
"file-saver",
"jszip",

View file

@ -55,12 +55,14 @@
"noSignup": "Keine Anmeldung erforderlich. 100% kostenlos und Open-Source."
},
"footer": {
"description": "Ein kostenloses Open-Source-Tool zur Erstellung professioneller PDF-Rechnungen mit Live-Vorschau.",
"description": "Ein kostenloses Open-Source-Tool zur Erstellung professioneller PDF-Rechnungen mit Live-Vorschau.<br></br><br></br>Keine Buchhaltungssoftware. Keine Compliance-Garantien. Durch die Nutzung dieses Tools stimmen Sie den <tosLink>Nutzungsbedingungen</tosLink> zu.",
"product": "Produkt",
"links": {
"features": "Funktionen",
"github": "GitHub",
"changelog": "Änderungsprotokoll"
"changelog": "Änderungen",
"founder": "Gründer",
"termsOfService": "Nutzungsbedingungen"
},
"createdBy": "Erstellt von"
},
@ -72,15 +74,6 @@
"shareFeedback": "Feedback teilen",
"app": "App",
"startInvoicing": "Zur App gehen"
},
"newsletter": {
"title": "Newsletter abonnieren",
"description": "Erhalten Sie Updates zu neuen Funktionen und Verbesserungen von EasyInvoicePDF.com",
"subscribe": "Abonnieren",
"placeholder": "E-Mail eingeben",
"success": "Vielen Dank für Ihr Abonnement!",
"error": "Abonnement fehlgeschlagen. Bitte versuchen Sie es erneut.",
"emailLanguageInfo": "Alle E-Mails werden in englischer Sprache versendet"
}
},
"FAQ": {

View file

@ -3,7 +3,6 @@
"tagline": "Free Invoice Generator with Live PDF Preview",
"hero": {
"title": "Create professional invoices in seconds",
"description": "EasyInvoicePDF is a <span>free, open-source</span> invoice generator with <span>real-time preview</span>. Create, customize, and download professional invoices. <span>No sign-up required.</span>"
},
"features": {
@ -48,32 +47,25 @@
"noSignup": "No sign-up required. 100% free and open-source."
},
"footer": {
"description": "Create professional invoices in seconds with our free & open-source invoice maker. 100% in-browser, no sign-up required. Includes live PDF preview and a Stripe-style template - perfect for freelancers, startups, and small businesses.",
"description": "Create professional invoices in seconds with our free & open-source invoice maker. 100% in-browser, no sign-up required. Includes live PDF preview and a Stripe-style template - perfect for freelancers, startups, and small businesses.<br></br><br></br>Not accounting software. No compliance guarantees. By using this tool, you agree to the <tosLink>Terms of Service</tosLink>.",
"product": "Product",
"links": {
"features": "Features",
"github": "GitHub",
"changelog": "Changelog"
"changelog": "Changelog",
"founder": "Founder",
"termsOfService": "Terms of Service"
},
"createdBy": "Made by"
},
"buttons": {
"goToApp": "Go to app",
"goToApp": "Open app",
"viewOnGithub": "View on GitHub",
"starOnGithub": "Star on GitHub",
"switchLanguage": "Switch language",
"shareFeedback": "Share feedback",
"app": "App",
"startInvoicing": "Start Invoicing"
},
"newsletter": {
"title": "Subscribe to our newsletter",
"description": "Get updates on new features and improvements from EasyInvoicePDF.com",
"subscribe": "Subscribe",
"placeholder": "Enter your email",
"success": "Thank you for subscribing!",
"error": "Failed to subscribe. Please try again.",
"emailLanguageInfo": "All emails will be sent in English"
}
},
"Metadata": {

View file

@ -55,12 +55,14 @@
"noSignup": "No requiere registro. 100% gratuito y de código abierto."
},
"footer": {
"description": "Una herramienta gratuita de código abierto para crear facturas PDF profesionales con vista previa en tiempo real.",
"description": "Una herramienta gratuita de código abierto para crear facturas PDF profesionales con vista previa en tiempo real.<br></br><br></br>No es software de contabilidad. Sin garantías de cumplimiento. Al usar esta herramienta, aceptas los <tosLink>Términos del servicio</tosLink>.",
"product": "Producto",
"links": {
"features": "Características",
"github": "GitHub",
"changelog": "Registro de cambios"
"changelog": "Novedades",
"founder": "Fundador",
"termsOfService": "Términos del servicio"
},
"createdBy": "Creado por"
},
@ -72,15 +74,6 @@
"shareFeedback": "Compartir comentarios",
"app": "Aplicación",
"startInvoicing": "Comenzar a facturar"
},
"newsletter": {
"title": "Suscríbete a nuestro boletín",
"description": "Recibe actualizaciones sobre nuevas funciones y mejoras de EasyInvoicePDF.com",
"subscribe": "Suscribirse",
"placeholder": "Introduce tu email",
"success": "¡Gracias por suscribirte!",
"error": "Error al suscribirse. Por favor, inténtalo de nuevo.",
"emailLanguageInfo": "Todos los correos se enviarán en inglés"
}
},
"FAQ": {

View file

@ -47,12 +47,14 @@
"noSignup": "Aucune inscription requise. 100% gratuit et open-source."
},
"footer": {
"description": "Un outil gratuit et open-source pour créer des factures PDF professionnelles avec aperçu en temps réel.",
"description": "Un outil gratuit et open-source pour créer des factures PDF professionnelles avec aperçu en temps réel.<br></br><br></br>Pas un logiciel de comptabilité. Aucune garantie de conformité. En utilisant cet outil, vous acceptez les <tosLink>Conditions d'utilisation</tosLink>.",
"product": "Produit",
"links": {
"features": "Fonctionnalités",
"github": "GitHub",
"changelog": "Journal des modifications"
"changelog": "Nouveautés",
"founder": "Fondateur",
"termsOfService": "Conditions d'utilisation"
},
"createdBy": "Créé par"
},
@ -64,15 +66,6 @@
"shareFeedback": "Partager un avis",
"app": "Application",
"startInvoicing": "Commencer la facturation"
},
"newsletter": {
"title": "Abonnez-vous à notre newsletter",
"description": "Recevez des mises à jour sur les nouvelles fonctionnalités et améliorations de EasyInvoicePDF.com",
"subscribe": "S'abonner",
"placeholder": "Entrez votre email",
"success": "Merci de votre abonnement !",
"error": "Échec de l'abonnement. Veuillez réessayer.",
"emailLanguageInfo": "Tous les emails seront envoyés en anglais"
}
},
"Metadata": {

View file

@ -47,12 +47,14 @@
"noSignup": "Nessuna registrazione richiesta. 100% gratuito e open-source."
},
"footer": {
"description": "Uno strumento gratuito e open-source per creare fatture PDF professionali con anteprima in tempo reale.",
"description": "Uno strumento gratuito e open-source per creare fatture PDF professionali con anteprima in tempo reale.<br></br><br></br>Non è un software di contabilità. Nessuna garanzia di conformità. Utilizzando questo strumento, accetti i <tosLink>Termini di servizio</tosLink>.",
"product": "Prodotto",
"links": {
"features": "Funzionalità",
"github": "GitHub",
"changelog": "Registro delle modifiche"
"changelog": "Novità",
"founder": "Fondatore",
"termsOfService": "Termini di servizio"
},
"createdBy": "Creato da"
},
@ -64,15 +66,6 @@
"shareFeedback": "Condividi feedback",
"app": "App",
"startInvoicing": "Inizia fatturazione"
},
"newsletter": {
"title": "Iscriviti alla nostra newsletter",
"description": "Ricevi aggiornamenti sulle nuove funzionalità e i miglioramenti di EasyInvoicePDF.com",
"subscribe": "Iscriviti",
"placeholder": "Inserisci la tua email",
"success": "Grazie per l'iscrizione!",
"error": "Iscrizione fallita. Per favore riprova.",
"emailLanguageInfo": "Tutte le email saranno inviate in inglese"
}
},
"Metadata": {

View file

@ -47,12 +47,14 @@
"noSignup": "Geen registratie vereist. 100% gratis en open-source."
},
"footer": {
"description": "Een gratis, open-source tool voor het maken van professionele PDF-facturen met realtime voorbeeld.",
"description": "Een gratis, open-source tool voor het maken van professionele PDF-facturen met realtime voorbeeld.<br></br><br></br>Geen boekhoudsoftware. Geen nalevingsgaranties. Door deze tool te gebruiken, gaat u akkoord met de <tosLink>Servicevoorwaarden</tosLink>.",
"product": "Product",
"links": {
"features": "Functies",
"github": "GitHub",
"changelog": "Wijzigingslogboek"
"changelog": "Nieuw",
"founder": "Oprichter",
"termsOfService": "Servicevoorwaarden"
},
"createdBy": "Gemaakt door"
},
@ -64,15 +66,6 @@
"shareFeedback": "Feedback delen",
"app": "App",
"startInvoicing": "Begin met factureren"
},
"newsletter": {
"title": "Abonneer op onze nieuwsbrief",
"description": "Ontvang updates over nieuwe functies en verbeteringen van EasyInvoicePDF.com",
"subscribe": "Abonneren",
"placeholder": "Voer je e-mailadres in",
"success": "Bedankt voor je aanmelding!",
"error": "Aanmelding mislukt. Probeer het opnieuw.",
"emailLanguageInfo": "Alle e-mails worden in het Engels verzonden"
}
},
"Metadata": {

View file

@ -55,12 +55,14 @@
"noSignup": "Bez rejestracji. W 100% darmowe i open-source."
},
"footer": {
"description": "Darmowe narzędzie open-source do tworzenia profesjonalnych faktur PDF z podglądem w czasie rzeczywistym.",
"description": "Darmowe narzędzie open-source do tworzenia profesjonalnych faktur PDF z podglądem w czasie rzeczywistym.<br></br><br></br>Nie jest oprogramowaniem księgowym. Brak gwarancji zgodności. Korzystając z tego narzędzia, akceptujesz <tosLink>Regulamin</tosLink>.",
"product": "Produkt",
"links": {
"features": "Funkcje",
"github": "GitHub",
"changelog": "Dziennik zmian"
"changelog": "Dziennik zmian",
"founder": "Założyciel",
"termsOfService": "Regulamin"
},
"createdBy": "Stworzone przez"
},
@ -72,15 +74,6 @@
"shareFeedback": "Podziel się opinią",
"app": "Aplikacja",
"startInvoicing": "Rozpocznij fakturowanie"
},
"newsletter": {
"title": "Zapisz się do newslettera",
"description": "Otrzymuj aktualizacje o nowych funkcjach i ulepszeniach EasyInvoicePDF.com",
"subscribe": "Zapisz się",
"placeholder": "Wpisz swój email",
"success": "Dziękujemy za subskrypcję!",
"error": "Nie udało się zapisać. Spróbuj ponownie.",
"emailLanguageInfo": "Wszystkie wiadomości będą wysyłane w języku angielskim"
}
},
"FAQ": {

View file

@ -55,12 +55,14 @@
"noSignup": "Não requer cadastro. 100% gratuito e de código aberto."
},
"footer": {
"description": "Uma ferramenta gratuita de código aberto para criar faturas PDF profissionais com pré-visualização em tempo real.",
"description": "Uma ferramenta gratuita de código aberto para criar faturas PDF profissionais com pré-visualização em tempo real.<br></br><br></br>Não é um software de contabilidade. Sem garantias de conformidade. Ao usar esta ferramenta, você concorda com os <tosLink>Termos de serviço</tosLink>.",
"product": "Produto",
"links": {
"features": "Recursos",
"github": "GitHub",
"changelog": "Registro de alterações"
"changelog": "Novidades",
"founder": "Fundador",
"termsOfService": "Termos de serviço"
},
"createdBy": "Criado por"
},
@ -72,15 +74,6 @@
"shareFeedback": "Compartilhar feedback",
"app": "App",
"startInvoicing": "Começar a faturar"
},
"newsletter": {
"title": "Inscreva-se em nossa newsletter",
"description": "Receba atualizações sobre novos recursos e melhorias do EasyInvoicePDF.com",
"subscribe": "Inscrever-se",
"placeholder": "Digite seu email",
"success": "Obrigado por se inscrever!",
"error": "Falha na inscrição. Por favor, tente novamente.",
"emailLanguageInfo": "Todos os emails serão enviados em inglês"
}
},
"FAQ": {

View file

@ -55,12 +55,14 @@
"noSignup": "Без регистрации. 100% бесплатно и с открытым исходным кодом."
},
"footer": {
"description": "Бесплатный инструмент с открытым исходным кодом для создания профессиональных PDF-инвойсов с предпросмотром в реальном времени.",
"description": "Бесплатный инструмент с открытым исходным кодом для создания профессиональных PDF-инвойсов с предпросмотром в реальном времени.<br></br><br></br>Не является бухгалтерским программным обеспечением. Никаких гарантий соответствия. Используя этот инструмент, вы соглашаетесь с <tosLink>Условиями использования</tosLink>.",
"product": "Продукт",
"links": {
"features": "Возможности",
"github": "GitHub",
"changelog": "Журнал изменений"
"changelog": "Что нового",
"founder": "Основатель",
"termsOfService": "Условия использования"
},
"createdBy": "Создано"
},
@ -72,15 +74,6 @@
"shareFeedback": "Поделиться отзывом",
"app": "Приложение",
"startInvoicing": "Перейти в приложение"
},
"newsletter": {
"title": "Подпишитесь на нашу рассылку",
"description": "Получайте обновления о новых функциях и улучшениях EasyInvoicePDF.com",
"subscribe": "Подписаться",
"placeholder": "Введите ваш email",
"success": "Спасибо за подписку!",
"error": "Не удалось подписаться. Пожалуйста, попробуйте снова.",
"emailLanguageInfo": "Все письма будут отправляться на английском языке"
}
},
"FAQ": {

View file

@ -55,12 +55,14 @@
"noSignup": "Без реєстрації. 100% безкоштовно та з відкритим кодом."
},
"footer": {
"description": "Безкоштовний інструмент з відкритим кодом для створення професійних PDF-рахунків з попереднім переглядом у реальному часі.",
"description": "Створюйте професійні рахунки за секунди за допомогою нашого безкоштовного інструменту з відкритим кодом. 100% у браузері, без реєстрації. Включає попередній перегляд PDF та шаблон у стилі Stripe - ідеально для фрілансерів, стартапів і малого бізнесу.<br></br><br></br>Не є бухгалтерським програмним забезпеченням. Жодних гарантій відповідності. Використовуючи цей інструмент, ви погоджуєтесь з <tosLink>Умовами використання</tosLink>.",
"product": "Продукт",
"links": {
"features": "Можливості",
"github": "GitHub",
"changelog": "Журнал змін"
"changelog": "Що нового",
"founder": "Засновник",
"termsOfService": "Умови використання"
},
"createdBy": "Створено"
},
@ -72,15 +74,6 @@
"shareFeedback": "Поділитися відгуком",
"app": "Додаток",
"startInvoicing": "Перейти до додатку"
},
"newsletter": {
"title": "Підпишіться на нашу розсилку",
"description": "Отримуйте оновлення про нові функції та покращення EasyInvoicePDF.com",
"subscribe": "Підписатися",
"placeholder": "Введіть ваш email",
"success": "Дякуємо за підписку!",
"error": "Не вдалося підписатися. Будь ласка, спробуйте ще раз.",
"emailLanguageInfo": "Всі листи надсилатимуться англійською мовою"
}
},
"FAQ": {

View file

@ -1,10 +1,11 @@
{
"name": "pdf-invoice-generator",
"version": "1.0.2",
"version": "1.0.3",
"private": true,
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a",
"pnpm": {
"minimumReleaseAge": 4320
"minimumReleaseAge": 4320,
"trustPolicy": "no-downgrade"
},
"scripts": {
"dev": "next dev",

View file

@ -1,6 +1,11 @@
{
"version": 1,
"skills": {
"caveman": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"computedHash": "aa7939fc4d1fe31484090290da77f2d21e026aa4b34b329d00e6630feb985d75"
},
"changelog-maintenance": {
"source": "supercent-io/skills-template",
"sourceType": "github",

View file

@ -11,9 +11,11 @@ import { AlertCircleIcon, FileTextIcon, PencilIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { TWITTER_URL } from "@/config";
import Link from "next/link";
import type { Dispatch, SetStateAction } from "react";
import { getAppMetadata, updateAppMetadata } from "../utils/get-app-metadata";
import { InvoiceForm } from "./invoice-form";
import { InvoicePDFDownloadLink } from "./invoice-pdf-download-link";
import { MobileFormScrollContainer } from "./mobile-form-scroll-container";
@ -271,24 +273,47 @@ export function InvoiceClientPage({
{invoiceLastUpdatedAtFormatted}
</div>
)}
{/** Mobile version */}
{/* Founders info section (Mobile version) */}
<div className="mt-3 flex w-full justify-center">
<span className="inline-block text-xs text-zinc-900 duration-500 animate-in fade-in slide-in-from-bottom-2">
Made by{" "}
<a
href={TWITTER_URL}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-black"
>
Vlad Sazonau
<div className="flex items-center gap-1.5 text-xs text-zinc-900 duration-500 animate-in fade-in slide-in-from-bottom-2">
<a href={TWITTER_URL} target="_blank" rel="noopener noreferrer">
<img
src="https://ik.imagekit.io/fl2lbswwo/avatar.jpeg?updatedAt=1757456439459"
alt="Vlad Sazonau"
className="size-6 rounded-full"
height="24"
width="24"
loading="lazy"
decoding="async"
/>
</a>
</span>
<span>
Made by{" "}
<a
href={TWITTER_URL}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-black"
>
Vlad Sazonau
</a>
</span>
</div>
</div>
<div
className="mt-3 flex flex-wrap justify-center gap-1 text-xs text-zinc-900"
data-testid="mobile-terms-of-service-link"
>
By using this tool, you agree to the{" "}
<Link href="/tos" className="underline hover:text-black">
Terms of Service
</Link>
</div>
</div>
) : (
// Desktop View
<>
{/* Invoice form section i.e. left column (Desktop version) */}
<div className="col-span-4">
<div className="h-[620px] overflow-auto border-b px-3 pl-0 shadow-[0_4px_6px_-4px_rgba(0,0,0,0.1)] 2xl:h-[700px]">
<InvoiceForm
@ -298,23 +323,40 @@ export function InvoiceClientPage({
/>
</div>
<span className="mt-1 inline-block text-end text-xs text-zinc-700 duration-500 animate-in fade-in slide-in-from-bottom-2">
Made by{" "}
<a
href={TWITTER_URL}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-black"
>
Vlad Sazonau
{/* Founders info section (Desktop version) */}
<div className="mt-1 flex items-center gap-1.5 text-xs text-zinc-800 duration-500 animate-in fade-in slide-in-from-bottom-2">
<a href={TWITTER_URL} target="_blank" rel="noopener noreferrer">
<img
src="https://ik.imagekit.io/fl2lbswwo/avatar.jpeg?updatedAt=1757456439459"
alt="Vlad Sazonau"
className="size-6 rounded-full"
height="24"
width="24"
loading="lazy"
decoding="async"
/>
</a>
</span>
<span>
Made by{" "}
<a
href={TWITTER_URL}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-black"
>
Vlad Sazonau
</a>
</span>
</div>
</div>
{/* Invoice preview section i.e. right column (Desktop version) */}
<div className="relative col-span-8 h-[620px] w-full max-w-full 2xl:h-[700px]">
{invoiceLastUpdatedAtFormatted && (
<div className="absolute -top-5 right-0 text-center text-xs text-zinc-700 duration-500 animate-in fade-in slide-in-from-bottom-2 md:-mb-5 lg:text-right">
<span className="font-semibold">Invoice last updated:</span>{" "}
{invoiceLastUpdatedAtFormatted}
<div className="relative">
<div className="absolute -top-5 right-0 text-center text-xs text-zinc-700 duration-500 animate-in fade-in slide-in-from-bottom-2 md:-mb-5 lg:text-right">
<span className="font-semibold">Invoice last updated:</span>{" "}
{invoiceLastUpdatedAtFormatted}
</div>
</div>
)}
<PdfViewer
@ -323,6 +365,15 @@ export function InvoiceClientPage({
isMobile={false}
qrCodeDataUrl={qrCodeDataUrl}
/>
<div
className="absolute -bottom-6 right-0 text-right text-xs text-zinc-800"
data-testid="desktop-terms-of-service-link"
>
By using this tool, you agree to the{" "}
<Link href="/tos" className="underline hover:text-black">
Terms of Service
</Link>
</div>
</div>
</>
)}

View file

@ -5,5 +5,7 @@ export const ErrorMessage = ({ children }: { children: React.ReactNode }) => {
};
export const AlertIcon = () => {
return <AlertTriangle className="mr-1 inline-block h-3 w-3 text-amber-500" />;
return (
<AlertTriangle className="mr-1 inline-block size-3.5 shrink-0 text-amber-500" />
);
};

View file

@ -1,3 +1,7 @@
import {
AlertIcon,
ErrorMessage,
} from "@/app/(app)/components/invoice-form/common";
import { INVOICE_PDF_TRANSLATIONS } from "@/app/(app)/pdf-i18n-translations/pdf-translations";
import {
DEFAULT_DATE_FORMAT,
@ -31,36 +35,8 @@ import {
} from "react-hook-form";
import { toast } from "sonner";
const AlertIcon = () => {
return (
<AlertTriangle className="mr-1 inline-block size-3.5 shrink-0 text-amber-500" />
);
};
const ErrorMessage = ({ children }: { children: React.ReactNode }) => {
return <p className="mt-1 text-xs text-red-600">{children}</p>;
};
const CURRENT_MONTH_AND_YEAR = dayjs().format("MM-YYYY");
// Logo helper functions
const validateImageSize = (file: File): Promise<boolean> => {
return new Promise((resolve) => {
const maxSize = 3 * 1024 * 1024; // 3MB in bytes
resolve(file.size <= maxSize);
});
};
const convertFileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
interface GeneralInformationProps {
control: Control<InvoiceData>;
errors: FieldErrors<InvoiceData>;
@ -455,22 +431,6 @@ export const GeneralInformation = memo(function GeneralInformation({
<AlertIcon />
Invoice number does not match current month
</span>
<ButtonHelper
onClick={() => {
setValue(
"invoiceNumberObject.value",
`1/${CURRENT_MONTH_AND_YEAR}`,
);
}}
>
<span className="text-pretty">
Set invoice number to{" "}
<span className="font-bold">
current month ({`1/${CURRENT_MONTH_AND_YEAR}`})
</span>
</span>
</ButtonHelper>
</InputHelperMessage>
)}
</div>
@ -516,32 +476,6 @@ export const GeneralInformation = memo(function GeneralInformation({
<AlertIcon />
Date of issue is not today
</span>
<ButtonHelper
onClick={() => {
const currentMonth = dayjs().format("YYYY-MM-DD"); // default browser date input format is YYYY-MM-DD
setValue("dateOfIssue", currentMonth);
// Automatically update payment due date to 14 days after the new date of issue for better UX
setValue(
"paymentDue",
dayjs(currentMonth).add(14, "days").format("YYYY-MM-DD"),
);
}}
>
<span className="text-pretty">
Set date of issue to{" "}
<span className="font-bold">
today ({dayjs().format(selectedDateFormat)})
</span>{" "}
and update payment due to{" "}
<span className="font-bold">
+14 days (
{dayjs().add(14, "days").format(selectedDateFormat)})
</span>
</span>
</ButtonHelper>
</InputHelperMessage>
) : null}
</div>
@ -568,28 +502,11 @@ export const GeneralInformation = memo(function GeneralInformation({
<AlertIcon />
Date of service is not the last day of the current month
</span>
<ButtonHelper
onClick={() => {
const lastDayOfCurrentMonth = dayjs()
.endOf("month")
.format("YYYY-MM-DD"); // default browser date input format is YYYY-MM-DD
setValue("dateOfService", lastDayOfCurrentMonth);
}}
>
<span className="text-pretty">
Set date of service to{" "}
<span className="font-bold">
month end (
{dayjs().endOf("month").format(selectedDateFormat)})
</span>
</span>
</ButtonHelper>
</InputHelperMessage>
) : null}
</div>
{/* Out of Date Dates Helper */}
{canShowOutOfDateDatesHelper ? (
<OutOfDateDatesHelper
dateOfIssue={dateOfIssue}
@ -656,7 +573,7 @@ export const GeneralInformation = memo(function GeneralInformation({
{/* Logo Upload */}
<div className="">
<Label htmlFor="logoUpload" className="mb-2">
Company Logo (Optional)
Company Logo
</Label>
{logo ? (
@ -718,7 +635,7 @@ export const GeneralInformation = memo(function GeneralInformation({
{template === "stripe" && (
<div className="">
<Label htmlFor={`stripePayOnlineUrl`} className="">
Payment Link URL (Optional)
Payment Link URL
</Label>
<Controller
@ -806,7 +723,23 @@ function OutOfDateDatesHelper({
.format(selectedDateFormat);
const targetInvoiceNumber = `1/${CURRENT_MONTH_AND_YEAR}`;
const staleItems = [
/**
* Array of items to check for staleness.
* Each item is either false (not stale) or an object containing:
* - label: Display name of the field
* - oldValue: Current/outdated value
* - newValue: Suggested updated value
* - hint: Description of what the new value represents
*/
const ITEMS: (
| boolean
| {
label: string;
oldValue: string;
newValue: string;
hint: string;
}
)[] = [
isDateOfIssueStale && {
label: "Date of issue",
oldValue: formatDate(dateOfIssue),
@ -823,7 +756,7 @@ function OutOfDateDatesHelper({
label: "Invoice number",
oldValue: invoiceNumberValue || "—",
newValue: targetInvoiceNumber,
hint: undefined,
hint: "current month",
},
isPaymentDueStale && {
label: "Payment due",
@ -831,11 +764,13 @@ function OutOfDateDatesHelper({
newValue: targetPaymentDue,
hint: "date of issue + 14 days",
},
].filter(Boolean) as {
];
const staleItems = ITEMS.filter(Boolean) as {
label: string;
oldValue: string;
newValue: string;
hint?: string;
hint: string;
}[];
return (
@ -924,3 +859,21 @@ function OutOfDateDatesHelper({
</div>
);
}
// Logo helper functions
const validateImageSize = (file: File): Promise<boolean> => {
return new Promise((resolve) => {
const maxSize = 3 * 1024 * 1024; // 3MB in bytes
resolve(file.size <= maxSize);
});
};
const convertFileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};

View file

@ -15,7 +15,7 @@ import Link from "next/link";
import { GithubIcon } from "@/components/etc/github-logo";
import { ProjectLogoDescription } from "@/components/project-logo-description";
import { GITHUB_URL, VIDEO_DEMO_URL } from "@/config";
import { GITHUB_URL, VIDEO_DEMO_YOUTUBE_URL } from "@/config";
import { umamiTrackEvent } from "@/lib/umami-analytics-track-event";
import { cn } from "@/lib/utils";
import { AlertCircleIcon, HeartIcon, LinkIcon } from "lucide-react";
@ -179,7 +179,7 @@ export function InvoicePageHeader({
) : null} */}
</div>
</div>
<div className="mb-3 mt-1 flex flex-row items-center justify-center lg:mb-0 lg:mt-4 lg:justify-start xl:mt-1">
<div className="mb-2.5 flex flex-row items-center justify-center lg:-mb-1.5 lg:mt-4 lg:justify-start xl:mt-1">
<ProjectInfoLinks />
</div>
@ -199,6 +199,10 @@ export function InvoicePageHeader({
);
}
/**
* Renders project information links including demo video, feedback, and GitHub links.
* Manages video dialog state for the "How it works" demo.
*/
function ProjectInfoLinks() {
const [isVideoDialogOpen, setIsVideoDialogOpen] = useState(false);
@ -209,32 +213,39 @@ function ProjectInfoLinks() {
return (
<>
<div className="relative bottom-0 flex flex-wrap items-center justify-center gap-1 text-center text-sm text-gray-900 lg:bottom-3">
<div className="relative bottom-0 flex flex-wrap items-center justify-center gap-1.5 text-center text-sm text-gray-900 lg:bottom-4">
<button
onClick={handleWatchDemoClick}
className="inline-flex items-center gap-1.5 transition-colors hover:text-blue-600 hover:underline"
className="inline-flex cursor-pointer items-center transition duration-200 hover:text-blue-600 hover:underline active:scale-[0.96]"
>
<span>How it works</span>
How it works
</button>
{" | "}
<span className="h-3 w-px bg-slate-500" aria-hidden="true" />
<a
href="https://dub.sh/easy-invoice-pdf-feedback"
className="transition-colors hover:text-blue-600 hover:underline"
className="inline-flex items-center transition duration-200 hover:text-blue-600 hover:underline active:scale-[0.96]"
target="_blank"
>
Share your feedback
</a>
{" | "}
<span className="h-3 w-px bg-slate-500" aria-hidden="true" />
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="group inline-flex items-center gap-1 transition-colors hover:text-blue-600 hover:underline"
>
<GithubIcon className="size-4 transition-transform group-hover:fill-blue-600" />
<span className="group-hover:text-blue-600">View on GitHub</span>
</a>
<div className="relative overflow-hidden">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="group relative flex items-center gap-1.5 overflow-visible rounded-full border border-slate-300/80 bg-white px-3 py-1 text-xs shadow-sm transition-[colors,transform,border-color] duration-200 hover:border-slate-400/50 hover:bg-slate-50 hover:text-black active:scale-[0.96]"
>
<div className="border-glow-mask z-10" aria-hidden="true">
<div className="border-glow-shine animate-rotate-shine" />
</div>
<GithubIcon className="size-4 transition-[transform,fill] duration-200 group-hover:scale-105 group-hover:fill-blue-600" />
<span className="transition-colors duration-200 group-hover:text-blue-600">
Star on GitHub
</span>
</a>
</div>
</div>
<Dialog open={isVideoDialogOpen} onOpenChange={setIsVideoDialogOpen}>
@ -247,13 +258,13 @@ function ProjectInfoLinks() {
</DialogDescription>
</DialogHeader>
<div className="aspect-video w-full overflow-hidden">
<video
src={VIDEO_DEMO_URL}
muted
controls
autoPlay
playsInline
className="h-full w-full object-cover"
<iframe
src={VIDEO_DEMO_YOUTUBE_URL}
title="EasyInvoicePDF Demo Video"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
className="h-full w-full border-0"
data-testid="how-it-works-video"
/>
</div>

View file

@ -16,6 +16,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { LOADING_BUTTON_TEXT, LOADING_BUTTON_TIMEOUT } from "./invoice-form";
import { CustomTooltip } from "@/components/ui/tooltip";
import { useDeviceContext } from "@/contexts/device-context";
import { isTelegramInAppBrowser } from "@/utils/is-telegram-in-app-browser";
import { updateAppMetadata } from "../utils/get-app-metadata";
@ -245,25 +246,44 @@ export function InvoicePDFDownloadLink({
]);
return (
<>
<a
translate="no"
href={url || "#"}
download={url ? filename : undefined}
onClick={handleDownloadPDFClick}
className={cn(
"h-[36px] w-full rounded-lg bg-slate-900 px-4 py-2 text-center text-sm font-medium text-white",
"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} />
</a>
</>
<CustomTooltip
content={
<div className="flex items-center gap-3 p-2">
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900">
Your Responsibility
</p>
<p className="text-pretty text-xs leading-relaxed text-slate-700">
Ensure this invoice complies with your local tax and accounting
regulations before sending to clients.
</p>
</div>
</div>
}
delayDuration={0}
trigger={
<a
translate="no"
href={url || "#"}
download={url ? filename : undefined}
onClick={handleDownloadPDFClick}
className={cn(
"h-[36px] w-full rounded-lg bg-slate-900 px-4 py-2 text-center text-sm font-medium text-white",
"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}
/>
</a>
}
/>
);
}

View file

@ -9,6 +9,7 @@ import { InvoicePaymentTotals } from "./invoice-payment-totals";
import { InvoiceSellerBuyerInfo } from "./invoice-seller-buyer-info";
import { InvoiceVATSummaryTable } from "./invoice-vat-summary-table";
import type { PDF_DEFAULT_TEMPLATE_STYLES } from ".";
import { InvoiceQRCode } from "@/app/(app)/components/invoice-templates/common/invoice-qr-code";
import dayjs from "dayjs";
import "dayjs/locale/en";
@ -21,7 +22,6 @@ import "dayjs/locale/uk";
import "dayjs/locale/fr";
import "dayjs/locale/it";
import "dayjs/locale/nl";
import { InvoiceQRCode } from "@/app/(app)/components/invoice-templates/common/invoice-qr-code";
export const InvoiceBody = ({
invoiceData,
@ -85,7 +85,7 @@ export const InvoiceBody = ({
<InvoicePaymentInfo invoiceData={invoiceData} styles={styles} />
</View>
{vatTableSummaryIsVisible && (
{vatTableSummaryIsVisible ? (
<View style={{ width: "50%" }}>
<InvoiceVATSummaryTable
invoiceData={invoiceData}
@ -93,7 +93,7 @@ export const InvoiceBody = ({
styles={styles}
/>
</View>
)}
) : null}
</View>
{/** To pay, paid, left to pay and amount in words fields */}
@ -110,13 +110,13 @@ export const InvoiceBody = ({
</View>
{/* Signature section */}
{signatureSectionIsVisible && (
{signatureSectionIsVisible ? (
<View
style={styles.signatureSection}
wrap={false}
minPresenceAhead={50}
>
{invoiceData.personAuthorizedToReceiveFieldIsVisible && (
{invoiceData.personAuthorizedToReceiveFieldIsVisible ? (
<View style={styles.signatureColumn}>
{invoiceData.personAuthorizedToReceiveName ? (
<Text style={[styles.signatureText, { marginTop: -13 }]}>
@ -128,8 +128,8 @@ export const InvoiceBody = ({
{t.personAuthorizedToReceive}
</Text>
</View>
)}
{invoiceData.personAuthorizedToIssueFieldIsVisible && (
) : null}
{invoiceData.personAuthorizedToIssueFieldIsVisible ? (
<View style={styles.signatureColumn}>
{invoiceData.personAuthorizedToIssueName ? (
<Text style={[styles.signatureText, { marginTop: -13 }]}>
@ -141,16 +141,16 @@ export const InvoiceBody = ({
{t.personAuthorizedToIssue}
</Text>
</View>
)}
) : null}
</View>
)}
) : null}
{/* Notes */}
{invoiceData.notesFieldIsVisible && (
{invoiceData.notesFieldIsVisible ? (
<View style={{ marginTop: 10 }}>
<Text style={styles.fontSize8}>{invoiceData?.notes}</Text>
</View>
)}
) : null}
{/* QR Code - centered below notes */}
{isQrCodeVisible ? (

View file

@ -34,12 +34,12 @@ export function InvoiceFooter({
<View style={styles.footer} fixed>
<View style={styles.spaceBetween}>
<View style={[styles.row, { gap: 3 }]}>
{invoiceNumberValue && (
{invoiceNumberValue ? (
<>
<Text style={[styles.fontSize8]}>{invoiceNumberValue}</Text>
<Text style={[styles.fontSize8]}>·</Text>
</>
)}
) : null}
<Text style={[styles.fontSize8]}>
{formattedInvoiceTotal} {t.stripe.due} {paymentDueDate}
</Text>

View file

@ -26,14 +26,14 @@ export function InvoicePaymentInfo({
return (
<View style={{ maxWidth: "250px" }}>
{paymentMethodIsVisible && (
{paymentMethodIsVisible ? (
<Text style={styles.fontSize7}>
{t.paymentInfo.paymentMethod}:{" "}
<Text style={[styles.boldText, styles.fontSize8]}>
{invoiceData?.paymentMethod}
</Text>
</Text>
)}
) : null}
<Text
style={[
styles.fontSize7,

View file

@ -13,14 +13,6 @@ export function InvoiceSellerBuyerInfo({
const language = invoiceData.language;
const t = INVOICE_PDF_TRANSLATIONS[language];
const swiftBicFieldIsVisible = invoiceData.seller.swiftBicFieldIsVisible;
const sellerVatNoFieldIsVisible = invoiceData.seller.vatNoFieldIsVisible;
const buyerVatNoFieldIsVisible = invoiceData.buyer.vatNoFieldIsVisible;
const sellerAccountNumberFieldIsVisible =
invoiceData.seller.accountNumberFieldIsVisible;
const sellerNotesFieldIsVisible = invoiceData.seller.notesFieldIsVisible;
const buyerNotesFieldIsVisible = invoiceData.buyer.notesFieldIsVisible;
return (
<View
style={{
@ -43,49 +35,50 @@ export function InvoiceSellerBuyerInfo({
</Text>
<View style={{ marginTop: 2 }}>
{sellerVatNoFieldIsVisible && (
{invoiceData.seller.vatNoFieldIsVisible ? (
<Text style={[styles.fontSize7]}>
{invoiceData.seller.vatNoLabelText}:{" "}
<Text style={[styles.boldText, styles.fontSize8]}>
{invoiceData?.seller.vatNo}
</Text>
</Text>
)}
{invoiceData.seller.emailFieldIsVisible && (
) : null}
{invoiceData.seller.emailFieldIsVisible ? (
<Text style={styles.fontSize7}>
{t.seller.email}:{" "}
<Text style={[styles.boldText, styles.fontSize8]}>
{invoiceData?.seller.email}
</Text>
</Text>
)}
) : null}
</View>
</View>
<View style={{ marginTop: 10 }}>
{sellerAccountNumberFieldIsVisible && (
{invoiceData.seller.accountNumberFieldIsVisible ? (
<Text style={styles.fontSize8}>
{t.seller.accountNumber} -{" "}
<Text style={[styles.boldText, styles.fontSize8]}>
{invoiceData?.seller.accountNumber}
</Text>
</Text>
)}
{swiftBicFieldIsVisible && (
) : null}
{invoiceData.seller.swiftBicFieldIsVisible ? (
<Text style={styles.fontSize8}>
{t.seller.swiftBic}:{" "}
<Text style={[styles.boldText, styles.fontSize8]}>
{invoiceData?.seller.swiftBic}
</Text>
</Text>
)}
{sellerNotesFieldIsVisible && invoiceData?.seller.notes && (
) : null}
{invoiceData.seller.notesFieldIsVisible &&
invoiceData?.seller.notes ? (
<View style={{ marginTop: 10 }}>
<Text style={[styles.fontSize8]}>
{invoiceData?.seller.notes}
</Text>
</View>
)}
) : null}
</View>
</View>
@ -102,29 +95,29 @@ export function InvoiceSellerBuyerInfo({
</Text>
<View style={{ marginTop: 2 }}>
{buyerVatNoFieldIsVisible && (
{invoiceData.buyer.vatNoFieldIsVisible ? (
<Text style={styles.fontSize7}>
{invoiceData.buyer.vatNoLabelText}:{" "}
<Text style={[styles.boldText, styles.fontSize8]}>
{invoiceData?.buyer.vatNo}
</Text>
</Text>
)}
{invoiceData.buyer.emailFieldIsVisible && (
) : null}
{invoiceData.buyer.emailFieldIsVisible ? (
<Text style={styles.fontSize7}>
{t.buyer.email}:{" "}
<Text style={[styles.boldText, styles.fontSize8]}>
{invoiceData?.buyer.email}
</Text>
</Text>
)}
) : null}
</View>
{buyerNotesFieldIsVisible && invoiceData?.buyer.notes && (
{invoiceData.buyer.notesFieldIsVisible && invoiceData?.buyer.notes ? (
<View style={{ marginTop: 20 }}>
<Text style={[styles.fontSize8]}>{invoiceData?.buyer.notes}</Text>
</View>
)}
) : null}
</View>
</View>
);

View file

@ -296,7 +296,7 @@ export const StripeInvoicePdfTemplate = memo(function StripeInvoicePdfTemplate({
/>
{/* Notes */}
{invoiceData.notesFieldIsVisible && invoiceData.notes && (
{invoiceData.notesFieldIsVisible && invoiceData.notes ? (
<View
style={[STRIPE_TEMPLATE_STYLES.mt24]}
wrap={false}
@ -306,7 +306,7 @@ export const StripeInvoicePdfTemplate = memo(function StripeInvoicePdfTemplate({
{invoiceData.notes}
</Text>
</View>
)}
) : null}
{/* QR Code - centered below notes */}
{isQrCodeVisible ? (

View file

@ -29,12 +29,12 @@ export function StripeFooter({
<View style={styles.footer} fixed>
<View style={styles.spaceBetween}>
<View style={[styles.row, { gap: 3 }]}>
{invoiceNumber && (
{invoiceNumber ? (
<>
<Text style={[styles.fontSize8]}>{invoiceNumber}</Text>
<Text style={[styles.fontSize8]}>·</Text>
</>
)}
) : null}
<Text style={[styles.fontSize8]}>
{formattedInvoiceTotal} {t.stripe.due} {paymentDueDate}
</Text>

View file

@ -79,11 +79,11 @@ export function StripeInvoiceInfo({
</View>
{/* Header Notes */}
{invoiceData.invoiceType && invoiceData.invoiceTypeFieldIsVisible && (
{invoiceData.invoiceType && invoiceData.invoiceTypeFieldIsVisible ? (
<View style={[styles.mb1, styles.row, { alignItems: "baseline" }]}>
<Text style={[styles.fontSize9]}>{invoiceData.invoiceType}</Text>
</View>
)}
) : null}
</View>
);
}

View file

@ -28,32 +28,32 @@ export function StripeSellerBuyerInfo({
<Text style={[styles.fontSize9, styles.mb3]}>
{invoiceData.seller.address}
</Text>
{invoiceData.seller.emailFieldIsVisible && (
{invoiceData.seller.emailFieldIsVisible ? (
<Text style={[styles.fontSize9, styles.mb3]}>
{invoiceData.seller.email}
</Text>
)}
{invoiceData.seller.vatNoFieldIsVisible && (
) : null}
{invoiceData.seller.vatNoFieldIsVisible ? (
<Text style={[styles.fontSize9, styles.mb3]}>
{invoiceData.seller.vatNoLabelText}: {invoiceData.seller.vatNo}
</Text>
)}
{invoiceData.seller.accountNumberFieldIsVisible && (
) : null}
{invoiceData.seller.accountNumberFieldIsVisible ? (
<Text style={[styles.fontSize9, styles.mb3]}>
{t.seller.accountNumber}: {invoiceData.seller.accountNumber}
</Text>
)}
{invoiceData.seller.swiftBicFieldIsVisible && (
) : null}
{invoiceData.seller.swiftBicFieldIsVisible ? (
<Text style={[styles.fontSize9, styles.mb3]}>
{t.seller.swiftBic}: {invoiceData.seller.swiftBic}
</Text>
)}
) : null}
{invoiceData.seller.notesFieldIsVisible && (
{invoiceData.seller.notesFieldIsVisible ? (
<Text style={[styles.fontSize9, styles.mb3]}>
{invoiceData.seller.notes}
</Text>
)}
) : null}
</View>
{/* Buyer info */}
@ -67,23 +67,23 @@ export function StripeSellerBuyerInfo({
<Text style={[styles.fontSize9, styles.mb3]}>
{invoiceData.buyer.address}
</Text>
{invoiceData.buyer.emailFieldIsVisible && (
{invoiceData.buyer.emailFieldIsVisible ? (
<Text style={[styles.fontSize9, styles.mb3]}>
{invoiceData.buyer.email}
</Text>
)}
) : null}
{invoiceData.buyer.vatNoFieldIsVisible && (
{invoiceData.buyer.vatNoFieldIsVisible ? (
<Text style={[styles.fontSize9, styles.mb3]}>
{invoiceData.buyer.vatNoLabelText}: {invoiceData.buyer.vatNo}
</Text>
)}
) : null}
{invoiceData.buyer.notesFieldIsVisible && (
{invoiceData.buyer.notesFieldIsVisible ? (
<Text style={[styles.fontSize9, styles.mb3]}>
{invoiceData.buyer.notes}
</Text>
)}
) : null}
</View>
</View>
);

View file

@ -75,7 +75,7 @@ export function StripeVatSummaryTableTotals({
</View>
</View>
{hasNumericVat && (
{hasNumericVat ? (
<>
{/* Total excluding tax */}
<View
@ -132,7 +132,7 @@ export function StripeVatSummaryTableTotals({
);
})}
</>
)}
) : null}
{/* Total */}
<View

View file

@ -1,7 +1,7 @@
export default function Loading() {
return (
<div className="flex flex-col items-center justify-start bg-gray-100 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 className="w-full max-w-[62rem] bg-white p-3 shadow-lg sm:mb-0 sm:rounded-lg sm:p-6 sm:pb-1 xl:max-w-7xl 2xl:max-w-[1680px]">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12">
{/* Left side - Form */}
<div className="col-span-8 w-full space-y-6 lg:col-span-4">

View file

@ -698,7 +698,7 @@ export function AppPageClient({
return (
<TooltipProvider delayDuration={0}>
<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 sm:pb-1 2xl:max-w-[1680px]">
<div className="w-full max-w-[62rem] bg-white p-3 shadow-lg sm:mb-0 sm:rounded-lg sm:p-6 sm:pb-1 xl:max-w-7xl 2xl:max-w-[1680px]">
<InvoicePageHeader
canShareInvoice={canShareInvoice}
handleShareInvoice={handleShareInvoice}
@ -731,19 +731,25 @@ export function AppPageClient({
</div>
<Footer
translations={{
footerDescription:
"Create professional invoices in seconds with our free & open-source invoice maker. 100% in-browser, no sign-up required. Includes live PDF preview and a Stripe-style template - perfect for freelancers, startups, and small businesses.",
footerDescription: (
<>
Create professional invoices in seconds with our free &
open-source invoice maker. 100% in-browser, no sign-up required.
Includes live PDF preview and a Stripe-style template - perfect
for freelancers, startups, and small businesses.
<br /> <br />
Not accounting software. No compliance guarantees. By using this
tool, you agree to the{" "}
<Link
href="/tos"
className="text-slate-700 underline hover:text-slate-900"
>
Terms of Service
</Link>
</>
),
footerCreatedBy: "Made by",
product: "Product",
newsletterTitle: "Subscribe to our newsletter",
newsletterDescription:
"Get the latest updates and news from EasyInvoicePDF.com",
newsletterSubscribe: "Subscribe",
newsletterPlaceholder: "Enter your email",
newsletterSuccessMessage: "Thank you for subscribing!",
newsletterErrorMessage: "Failed to subscribe. Please try again.",
newsletterEmailLanguageInfo: "All emails will be sent in English",
}}
links={
<ul className="space-y-2">
@ -755,14 +761,6 @@ export function AppPageClient({
About
</Link>
</li>
<li>
<Link
href="/changelog"
className="text-sm text-slate-500 hover:text-slate-900"
>
Changelog
</Link>
</li>
<li>
<Link
href={GITHUB_URL}
@ -773,6 +771,22 @@ export function AppPageClient({
GitHub
</Link>
</li>
<li>
<Link
href="/changelog"
className="text-sm text-slate-500 hover:text-slate-900"
>
Changelog
</Link>
</li>
<li>
<Link
href="/tos"
className="text-sm text-slate-500 hover:text-slate-900"
>
Terms of Service
</Link>
</li>
<li>
<Link
href="https://pdfinvoicegenerator.userjot.com/?cursor=1&order=top&limit=10"
@ -783,10 +797,18 @@ export function AppPageClient({
Share feedback
</Link>
</li>
<li>
<Link
href="/founder"
className="text-sm text-slate-500 hover:text-slate-900"
>
Founder
</Link>
</li>
</ul>
}
/>
<div className="fixed right-2 top-2 z-50 duration-500 animate-in fade-in slide-in-from-top-4">
<div className="fixed right-1.5 top-1.5 z-50 duration-500 animate-in fade-in slide-in-from-top-4">
<GitHubStarCTA githubStarsCount={githubStarsCount} />
</div>
</TooltipProvider>

View file

@ -0,0 +1,49 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Header skeleton component for loading state.
* Displays placeholder skeletons while the actual header content is loading.
* Matches the layout and structure of the Header component.
*/
export function HeaderSkeleton() {
return (
<header className="sticky top-0 z-50 w-full border-b bg-white shadow-sm">
<div className="flex items-center justify-center">
<div className="container h-auto px-4 py-2 sm:h-16 sm:py-0 md:px-6">
<div className="flex h-full flex-row flex-wrap items-center justify-between gap-2">
<div className="w-[53%] sm:w-auto">
<div className="flex items-center gap-1.5 md:gap-2">
{/* Placeholder for logo icon*/}
<div className="flex items-center justify-center rounded-md">
<Skeleton className="size-6 md:size-7" />
</div>
{/* Placeholder for app name and description on desktop */}
<div className="hidden sm:block">
<div className="flex flex-col space-y-1">
{/* Placeholder for app name */}
<Skeleton className="h-6 w-36 lg:w-40" />
{/* Placeholder for app description */}
<Skeleton className="mt-1.5 h-3 w-48 rounded" />
</div>
</div>
{/* Placeholder for app name on mobile */}
<div className="block sm:hidden">
{/* Placeholder for app name on mobile*/}
<Skeleton className="h-7 w-28" />
</div>
</div>
</div>
<div className="flex items-center gap-1 sm:gap-2">
{/* Placeholder for language switcher on desktop */}
<Skeleton className="hidden h-9 w-[100px] rounded-md sm:block" />
{/* Placeholder for language switcher on mobile */}
<Skeleton className="h-9 w-9 rounded-md" />
{/* Placeholder for go to app button */}
<Skeleton className="h-9 min-w-[110px] max-w-[110px] rounded-md" />
</div>
</div>
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,157 @@
"use client";
import { BlackAnimatedGoToAppBtn } from "@/components/animated-go-to-app-btn";
import { GithubIcon } from "@/components/etc/github-logo";
import { GITHUB_URL } from "@/config";
import { cn } from "@/lib/utils";
import { type Locale } from "next-intl";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { LanguageSwitcher } from "./language-switcher";
import { Logo } from "./logo";
import { MobileMenu } from "./mobile-menu";
export interface HeaderProps {
locale: Locale;
translations: {
navLinks: {
features: string;
faq: string;
github: string;
githubUrl: string;
};
switchLanguageText: string;
goToAppText: string;
startInvoicingButtonText: string;
changelogLinkText: string;
termsOfServiceLinkText: string;
};
hideLanguageSwitcher?: boolean;
}
export function Header({
locale,
translations,
hideLanguageSwitcher = false,
}: HeaderProps) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const isChangelogActive = pathname === "/changelog";
const isTosActive = pathname === "/tos";
const desktopNavLinkClass =
"rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:bg-slate-100/90 hover:text-black";
return (
<header className="sticky top-0 z-50 w-full border-b bg-white shadow-sm">
<div className="flex items-center justify-center">
<div className="container h-16 px-4 md:px-6">
<div className="flex h-full items-center justify-between gap-4">
{/* Logo - hidden from header when sheet is open */}
<div
className={cn(open && "pointer-events-none invisible")}
aria-hidden={open ? true : undefined}
tabIndex={open ? -1 : undefined}
>
<Logo />
</div>
{/* Right side actions */}
<div className="flex items-center gap-1">
{/* Desktop nav */}
<nav className="hidden items-center justify-end gap-1 lg:flex">
{/* a href this is only reliable way to scroll to a section on route navigation*/}
<a
href={`/${locale}/about#features`}
className={cn(desktopNavLinkClass, "text-slate-600")}
>
{translations.navLinks.features}
</a>
{/* a href this is only reliable way to scroll to a section on route navigation*/}
<a
href={`/${locale}/about#faq`}
className={cn(desktopNavLinkClass, "text-slate-600")}
>
{translations.navLinks.faq}
</a>
<Link
href="/changelog"
className={cn(
desktopNavLinkClass,
isChangelogActive
? "bg-slate-100 text-black"
: "text-slate-600",
)}
>
{translations.changelogLinkText}
</Link>
<Link
href="/tos"
className={cn(
desktopNavLinkClass,
isTosActive ? "bg-slate-100 text-black" : "text-slate-600",
)}
>
{translations.termsOfServiceLinkText}
</Link>
<Link
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
aria-label="View on GitHub"
>
<GithubIcon className="size-4" />
{translations.navLinks.github}
</Link>
</nav>
{/* Language switcher -- desktop only */}
<div className="hidden min-w-[36px] lg:block">
{!hideLanguageSwitcher && (
<LanguageSwitcher
locale={locale}
buttonText={translations.switchLanguageText}
/>
)}
</div>
{/* CTA button - hidden from header on mobile when sheet is open */}
<div
className={cn(
open &&
"pointer-events-none invisible lg:pointer-events-auto lg:visible",
)}
aria-hidden={open ? true : undefined}
>
<BlackAnimatedGoToAppBtn>
{translations.goToAppText}
</BlackAnimatedGoToAppBtn>
</div>
{/* Burger menu -- mobile only; hidden when sheet is open (X is inside sheet) */}
<div
className={cn(
"relative lg:hidden",
open && "pointer-events-none invisible",
)}
aria-hidden={open ? true : undefined}
>
<MobileMenu
open={open}
onOpenChange={setOpen}
locale={locale}
translations={translations}
hideLanguageSwitcher={hideLanguageSwitcher}
/>
</div>
</div>
</div>
</div>
</div>
</header>
);
}

View file

@ -38,11 +38,13 @@ type LanguageLabel = (typeof MAP_LOCALE_TO_LANGUAGE)[SupportedLocale];
interface LanguageSwitcherProps {
locale: SupportedLocale;
buttonText: string;
onSelect?: () => void;
}
export function LanguageSwitcher({
locale,
buttonText,
onSelect,
}: LanguageSwitcherProps) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
@ -82,6 +84,7 @@ export function LanguageSwitcher({
<DropdownMenuItem
key={itemLocale}
onClick={() => {
onSelect?.();
startTransition(() => {
const pathnameWithoutLocale = pathname.replace(
`/${locale}`,

View file

@ -0,0 +1,35 @@
"use client";
import { FinalProjectLogo } from "@/components/etc/final-project-logo";
import { ProjectLogoDescription } from "@/components/project-logo-description";
import { useTranslations } from "next-intl";
export function Logo() {
const t = useTranslations("About");
return (
<div>
<div className="flex items-center gap-1.5 md:gap-2">
<FinalProjectLogo className="size-6 md:size-7" />
{/* show app logo and description on desktop */}
<div className="hidden sm:block">
<ProjectLogoDescription>{t("tagline")}</ProjectLogoDescription>
</div>
{/* show only app name on mobile (to save space) */}
<div className="block sm:hidden">
<p className="text-balance text-center text-xl font-bold text-zinc-800 sm:mt-0 sm:text-2xl lg:mr-5 lg:text-left">
<a
href="https://easyinvoicepdf.com"
target="_blank"
rel="noopener noreferrer"
>
EasyInvoicePDF
</a>
</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,194 @@
"use client";
import { GithubIcon } from "@/components/etc/github-logo";
import { BlackAnimatedGoToAppBtn } from "@/components/animated-go-to-app-btn";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import type { Locale } from "next-intl";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ArrowRightIcon, Menu, X } from "lucide-react";
import { LanguageSwitcher } from "./language-switcher";
import { Logo } from "./logo";
import type { HeaderProps } from ".";
interface MobileMenuProps {
open: boolean;
onOpenChange: (open: boolean) => void;
locale: Locale;
translations: HeaderProps["translations"];
hideLanguageSwitcher?: boolean;
}
export function MobileMenu({
open,
onOpenChange,
locale,
translations,
hideLanguageSwitcher,
}: MobileMenuProps) {
const pathname = usePathname();
const isChangelogActive = pathname === "/changelog";
const isTosActive = pathname === "/tos";
const mobileNavLinkClass =
"flex items-center rounded-lg px-4 py-4 text-lg font-medium transition-colors hover:bg-slate-100/90 hover:text-black";
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetTrigger asChild>
<Button
size="icon"
variant="ghost"
className="rounded-full shadow-none"
aria-label="Open menu"
>
<Menu size={20} />
</Button>
</SheetTrigger>
<SheetContent
side="top"
animation="fade"
className="bottom-0 top-0 flex flex-col border-b-0 bg-white px-0 py-0"
showCloseButton={false}
>
<SheetTitle className="sr-only">Mobile Menu</SheetTitle>
<SheetDescription className="sr-only">
Navigation menu with links to features, FAQ, changelog, terms of
service, and language settings
</SheetDescription>
{/* Faux header row mirroring the page header */}
<header className="w-full border-b bg-white shadow-sm">
<div className="flex items-center justify-center">
<div className="container h-16 px-4 md:px-6">
<div className="flex h-full items-center justify-between gap-4">
<Logo />
<div className="flex items-center gap-1">
<BlackAnimatedGoToAppBtn>
{translations.goToAppText}
</BlackAnimatedGoToAppBtn>
<SheetClose asChild>
<Button
size="icon"
variant="ghost"
className="rounded-full shadow-none"
aria-label="Close menu"
>
<X size={20} />
</Button>
</SheetClose>
</div>
</div>
</div>
</div>
</header>
<nav className="container flex flex-col gap-1 px-6 py-4 md:px-10">
<SheetClose asChild>
{/* this is only reliable way to scroll to a section on route navigation*/}
<a
href={`/${locale}/about#features`}
className={cn(mobileNavLinkClass, "text-slate-700")}
>
{translations.navLinks.features}
</a>
</SheetClose>
<SheetClose asChild>
{/* this is only reliable way to scroll to a section on route navigation*/}
<a
href={`/${locale}/about#faq`}
className={cn(mobileNavLinkClass, "text-slate-700")}
>
{translations.navLinks.faq}
</a>
</SheetClose>
<SheetClose asChild>
<Link
href="/changelog"
className={cn(
mobileNavLinkClass,
isChangelogActive
? "bg-slate-100 text-black"
: "text-slate-700",
)}
>
{translations.changelogLinkText}
</Link>
</SheetClose>
<SheetClose asChild>
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
<a
href="/tos"
className={cn(
mobileNavLinkClass,
isTosActive ? "bg-slate-100 text-black" : "text-slate-700",
)}
>
{translations.termsOfServiceLinkText}
</a>
</SheetClose>
<SheetClose asChild>
<Link
href={translations.navLinks.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-lg px-4 py-4 text-lg font-medium text-slate-700 transition-colors hover:bg-slate-100 hover:text-slate-900"
aria-label="View on GitHub"
>
<GithubIcon className="size-5" />
{translations.navLinks.github}
</Link>
</SheetClose>
{/* Start Invoicing CTA Button */}
<div className="w-fit pt-4" onClick={() => onOpenChange(false)}>
<Button
size="lg"
variant="outline"
className={
"group relative overflow-hidden bg-zinc-900 px-5 py-6 text-lg text-white transition-all duration-300 hover:scale-[1.02] hover:bg-zinc-800 hover:text-white active:scale-[0.98] sm:px-8"
}
asChild
>
<Link
href="/?template=default"
scroll={false}
className="flex items-center"
>
<ArrowRightIcon className="mr-2 size-6 group-hover:scale-110" />
{translations.startInvoicingButtonText}
</Link>
</Button>
</div>
</nav>
{!hideLanguageSwitcher && (
<div className="mt-auto border-t border-slate-200 px-6 pb-6 pt-5">
<div className="flex items-center gap-2 px-2">
<span className="text-sm text-slate-700">
{translations.switchLanguageText}
</span>
<LanguageSwitcher
locale={locale}
buttonText={translations.switchLanguageText}
onSelect={() => onOpenChange(false)}
/>
</div>
</div>
)}
</SheetContent>
</Sheet>
);
}

View file

@ -1,6 +1,12 @@
import { fetchGithubStars } from "@/actions/fetch-github-stars";
import { GITHUB_URL } from "@/config";
/**
* Server component that renders a GitHub star CTA button for the marketing/about page.
* Fetches the current GitHub star count and displays it alongside a call-to-action.
*
* @returns A styled button linking to the GitHub repository with star count
*/
export async function GithubStarCtaMarketingPageBody() {
const githubStarsCount = await fetchGithubStars();

View file

@ -1,12 +0,0 @@
import { fetchGithubStars } from "@/actions/fetch-github-stars";
import { GitHubStarCTA } from "@/components/github-star-cta";
export async function GithubStarCtaMarketingPageHeader() {
const githubStarsCount = await fetchGithubStars();
return (
<div className="overflow-hidden duration-500 animate-in fade-in slide-in-from-top-4">
<GitHubStarCTA githubStarsCount={githubStarsCount} />
</div>
);
}

View file

@ -1,46 +1,10 @@
import { HeaderSkeleton } from "@/app/(components)/header/header-skeleton";
import { Skeleton } from "@/components/ui/skeleton";
export default function AboutLoading() {
return (
<div className="flex min-h-screen flex-col bg-slate-50">
<header className="sticky top-0 z-50 w-full border-b bg-white shadow-sm">
<div className="flex items-center justify-center">
<div className="container h-auto px-4 py-2 sm:h-16 sm:py-0 md:px-6">
<div className="flex h-full flex-row flex-wrap items-center justify-between gap-2">
<div className="w-[53%] sm:w-auto">
<div className="flex items-center gap-1.5 md:gap-2">
{/* Placeholder for logo icon*/}
<div className="flex items-center justify-center rounded-md">
<Skeleton className="size-6 md:size-7" />
</div>
{/* Placeholder for app name and description on desktop */}
<div className="hidden sm:block">
<div className="flex flex-col space-y-1">
{/* Placeholder for app name */}
<Skeleton className="h-6 w-36 lg:w-40" />
{/* Placeholder for app description */}
<Skeleton className="mt-1.5 h-3 w-48 rounded" />
</div>
</div>
{/* Placeholder for app name on mobile */}
<div className="block sm:hidden">
{/* Placeholder for app name on mobile*/}
<Skeleton className="h-7 w-28" />
</div>
</div>
</div>
<div className="flex items-center gap-1 sm:gap-2">
{/* Placeholder for language switcher on desktop */}
<Skeleton className="hidden h-9 w-[100px] rounded-md sm:block" />
{/* Placeholder for language switcher on mobile */}
<Skeleton className="h-9 w-9 rounded-md" />
{/* Placeholder for go to app button */}
<Skeleton className="h-9 min-w-[110px] max-w-[110px] rounded-md" />
</div>
</div>
</div>
</div>
</header>
<HeaderSkeleton />
<main>
{/* Hero Section */}

View file

@ -1,5 +1,4 @@
import { GithubIcon } from "@/components/etc/github-logo";
// import { ProjectLogo } from "@/components/etc/project-logo";
import { Footer } from "@/components/footer";
import {
BlackGoToAppButton,
@ -7,9 +6,8 @@ import {
} from "@/components/go-to-app-button-cta";
import { Button } from "@/components/ui/button";
import { BlackAnimatedGoToAppBtn } from "@/components/animated-go-to-app-btn";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Video } from "@/components/video";
import { AutoPlayVideo, ManualPlayVideo } from "@/components/video";
import {
GITHUB_URL,
MARKETING_FEATURES_CARDS,
@ -23,12 +21,8 @@ import { useTranslations, type Locale } from "next-intl";
import { setRequestLocale } from "next-intl/server";
import Link from "next/link";
import { type Graph } from "schema-dts";
import { LanguageSwitcher } from "./components/language-switcher";
import { GithubStarCtaMarketingPageBody } from "@/app/[locale]/about/components/github-star-cta-body";
import { GithubStarCtaMarketingPageHeader } from "@/app/[locale]/about/components/github-star-cta-header";
import { FinalProjectLogo } from "@/components/etc/final-project-logo";
import { ProjectLogoDescription } from "@/components/project-logo-description";
// import { ProjectLogoDescription } from "@/components/project-logo-description";
import { Header } from "@/app/(components)/header";
// statically generate the pages for all locales
export function generateStaticParams() {
@ -42,15 +36,21 @@ export default function AboutPage({ params }: { params: { locale: Locale } }) {
setRequestLocale(locale);
const t = useTranslations("About");
const tNewsletter = useTranslations("About.newsletter");
const newsletterTitle = tNewsletter("title");
const newsletterDescription = tNewsletter("description");
const newsletterSubscribe = tNewsletter("subscribe");
const newsletterPlaceholder = tNewsletter("placeholder");
const newsletterSuccessMessage = tNewsletter("success");
const newsletterErrorMessage = tNewsletter("error");
const newsletterEmailLanguageInfo = tNewsletter("emailLanguageInfo");
const navLinks = {
features: t("footer.links.features"),
faq: "FAQ",
github: t("footer.links.github"),
githubUrl: GITHUB_URL,
};
const switchLanguageText = t("buttons.switchLanguage");
const goToAppText = t("buttons.goToApp");
const startInvoicingButtonText = t("buttons.startInvoicing");
const changelogLinkText = t("footer.links.changelog");
const termsOfServiceLinkText = t("footer.links.termsOfService");
return (
<TooltipProvider>
@ -62,7 +62,18 @@ export default function AboutPage({ params }: { params: { locale: Locale } }) {
}}
/>
<div className="flex min-h-screen flex-col bg-slate-50">
<Header locale={locale} />
<Header
locale={locale}
// we need to pass the translations to the header to avoid stale translations issue during language switching
translations={{
navLinks,
switchLanguageText,
goToAppText,
startInvoicingButtonText,
changelogLinkText,
termsOfServiceLinkText,
}}
/>
<main>
<HeroSection />
<FeaturesSection />
@ -83,6 +94,16 @@ export default function AboutPage({ params }: { params: { locale: Locale } }) {
{t("buttons.app")}
</Link>
</li>
<li>
<Link
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-500 hover:text-slate-900"
>
{t("footer.links.github")}
</Link>
</li>
<li>
<Link
href="#features"
@ -107,6 +128,15 @@ export default function AboutPage({ params }: { params: { locale: Locale } }) {
{t("footer.links.changelog")}
</Link>
</li>
<li>
<Link
href="/tos"
className="text-sm text-slate-500 hover:text-slate-900"
>
{t("footer.links.termsOfService")}
</Link>
</li>
<li>
<Link
href="https://pdfinvoicegenerator.userjot.com/?cursor=1&order=top&limit=10"
@ -119,28 +149,28 @@ export default function AboutPage({ params }: { params: { locale: Locale } }) {
</li>
<li>
<Link
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
href="/founder"
className="text-sm text-slate-500 hover:text-slate-900"
>
{t("footer.links.github")}
{t("footer.links.founder")}
</Link>
</li>
</ul>
}
translations={{
footerDescription: t("footer.description"),
footerDescription: t.rich("footer.description", {
br: () => <br />,
tosLink: (chunks) => (
<Link
href="/tos"
className="text-slate-700 underline hover:text-slate-900"
>
{chunks}
</Link>
),
}),
footerCreatedBy: t("footer.createdBy"),
product: t("footer.product"),
newsletterTitle: newsletterTitle,
newsletterDescription: newsletterDescription,
newsletterSubscribe: newsletterSubscribe,
newsletterPlaceholder: newsletterPlaceholder,
newsletterSuccessMessage: newsletterSuccessMessage,
newsletterErrorMessage: newsletterErrorMessage,
newsletterEmailLanguageInfo: newsletterEmailLanguageInfo,
}}
/>
</div>
@ -148,43 +178,13 @@ export default function AboutPage({ params }: { params: { locale: Locale } }) {
);
}
function Header({ locale }: { locale: Locale }) {
const t = useTranslations("About.buttons");
return (
<header className="sticky top-0 z-50 w-full border-b bg-white shadow-sm">
<div className="flex items-center justify-center">
<div className="container h-auto px-4 py-2 sm:h-16 sm:py-0 md:px-6">
<div className="flex h-full flex-row flex-wrap items-center justify-between gap-2">
<div className="w-[53%] sm:w-auto">
<Logo />
</div>
<div className="flex items-center sm:mt-0 sm:gap-2">
{/** show only on desktop */}
<div className="hidden sm:block">
<GithubStarCtaMarketingPageHeader />
</div>
<LanguageSwitcher
locale={locale}
buttonText={t("switchLanguage")}
/>
<BlackAnimatedGoToAppBtn>{t("goToApp")}</BlackAnimatedGoToAppBtn>
</div>
</div>
</div>
</div>
</header>
);
}
function HeroSection() {
const t = useTranslations("About");
return (
<section
id="hero"
className="relative flex w-full items-center justify-center overflow-hidden bg-gradient-to-b from-white to-slate-50 py-10 md:py-16 lg:py-24"
className="relative flex w-full items-center justify-center overflow-hidden bg-gradient-to-b from-white to-slate-50 py-10 md:py-16 xl:py-24"
>
{/* Background decorative elements */}
<div className="absolute inset-0 overflow-hidden">
@ -203,13 +203,31 @@ function HeroSection() {
<div className="flex justify-center xl:justify-start">
<p className="text-pretty px-4 text-center text-base text-slate-600 md:max-w-[500px] md:text-lg lg:px-0 xl:text-left xl:text-lg">
{t.rich("hero.description", {
span: (chunks) => (
<span className="bg-yellow-300 px-0.5 font-bold text-slate-900 dark:bg-yellow-600">
{chunks}
</span>
),
})}
{(() => {
let colorIndex = 0;
return t.rich("hero.description", {
span: (chunks) => {
const colors = [
"bg-yellow-300 dark:bg-yellow-600 text-slate-900 dark:text-slate-900",
"bg-blue-500 dark:bg-blue-500 text-white dark:text-white",
"bg-purple-500 dark:bg-purple-500 text-white dark:text-white",
] as const;
// Get the current color from the array using modulo to cycle through colors
// colorIndex starts at 0 and increments with each <span> element
const color = colors[colorIndex % colors.length];
// Increment for the next span element
colorIndex++;
return (
<span className={`${color} px-0.5 font-bold`}>
{chunks}
</span>
);
},
});
})()}
</p>
</div>
</div>
@ -255,10 +273,11 @@ function HeroSection() {
</div>
{/* Video container */}
<div className="relative aspect-video w-full">
<Video
<AutoPlayVideo
src={VIDEO_DEMO_URL}
fallbackImg={VIDEO_DEMO_FALLBACK_IMG}
posterImg={VIDEO_DEMO_FALLBACK_IMG}
testId="hero-about-page-video"
description="How to create and download an invoice as a PDF in EasyInvoicePDF.com"
/>
</div>
</div>
@ -278,7 +297,7 @@ function FeaturesSection() {
id="features"
className="mt-6 flex w-full items-center justify-center bg-slate-50 py-4 lg:py-8 xl:mt-16 xl:py-16"
>
<div className="container">
<div className="container lg:max-w-[53rem] xl:max-w-[1280px] 2xl:max-w-[1536px]">
{/* Features section title and description */}
<div className="flex flex-col items-center justify-center space-y-8 px-4 text-center md:px-6">
<div className="space-y-5">
@ -314,7 +333,8 @@ function FeaturesSection() {
isEven ? "xl:flex-row" : "xl:flex-row-reverse", // swap the video and text content for even index
)}
>
<div className="mb-[-5px] flex-1 px-8 pt-6 xl:mb-0 xl:py-4">
{/* text content */}
<div className="mb-[-5px] max-w-[700px] flex-1 px-8 pt-6 md:pt-7 xl:mb-0 xl:py-4">
<h3 className="text-balance pb-4 text-xl font-semibold leading-tight tracking-tight text-slate-900 sm:text-2xl">
{title}
</h3>
@ -323,7 +343,8 @@ function FeaturesSection() {
</p>
</div>
<div className="relative w-full max-w-[800px] px-2 pb-3 lg:p-0 xl:mx-0">
{/* video container */}
<div className="relative w-full max-w-[800px] px-2 pb-3 lg:px-0 lg:pb-4 xl:mx-0 xl:pb-0">
{/* Mac OS Frame around the video */}
<div className="relative overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg md:rounded-2xl md:shadow-xl">
{/* Browser chrome bar */}
@ -338,9 +359,20 @@ function FeaturesSection() {
</div>
{/* Video container */}
<div className="relative aspect-[16.6/8.9] h-full w-full lg:aspect-[16.99/9.1]">
<Video
{/* Auto play video for desktop */}
<AutoPlayVideo
className="hidden xl:block"
src={feature.videoSrc}
fallbackImg={feature.videoFallbackImg}
posterImg={feature.videoFallbackImg}
description={feature.videoDescription}
testId={`${feature.translationKey}-demo-video`}
/>
{/* Manual play video for mobile for better UX */}
<ManualPlayVideo
className="xl:hidden"
src={feature.videoSrc}
posterImg={feature.videoFallbackImg}
description={feature.videoDescription}
testId={`${feature.translationKey}-demo-video`}
/>
</div>
@ -482,36 +514,6 @@ function CtaSection() {
);
}
function Logo() {
const t = useTranslations("About");
return (
<div>
<div className="flex items-center gap-1.5 md:gap-2">
<FinalProjectLogo className="size-6 md:size-7" />
{/* show app logo and description on desktop */}
<div className="hidden sm:block">
<ProjectLogoDescription>{t("tagline")}</ProjectLogoDescription>
</div>
{/* show only app name on mobile (to save space) */}
<div className="block sm:hidden">
<p className="text-balance text-center text-xl font-bold text-zinc-800 sm:mt-0 sm:text-2xl lg:mr-5 lg:text-left">
<a
href="https://easyinvoicepdf.com"
target="_blank"
rel="noopener noreferrer"
>
EasyInvoicePDF
</a>
</p>
</div>
</div>
</div>
);
}
const JSON_LD: Graph = {
"@context": "https://schema.org",
"@graph": [

View file

@ -11,6 +11,8 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { ChangelogAuthorByline } from "../components/changelog-author-byline";
import { ChangelogVersionBadgeLink } from "../components/changelog-version-badge-link";
import { DateTime } from "../components/date-time";
import { BlackGoToAppButton } from "@/components/go-to-app-button-cta";
import * as Sentry from "@sentry/nextjs";
@ -158,65 +160,27 @@ export default async function ChangelogEntryPage({
{/* Entry header */}
<article className="prose prose-gray max-w-none dark:prose-invert">
<header className="not-prose mb-4 sm:mb-8">
<div className="mb-4 flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<DateTime dateTime={entry.metadata.date}>
{formattedDate}
</DateTime>
</div>
<div className="mb-4 flex items-center gap-3">
<h1 className="text-pretty text-3xl font-bold text-gray-900 dark:text-gray-100 sm:text-4xl">
{entry.metadata.title || `Update ${formattedDate}`}
</h1>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<DateTime dateTime={entry.metadata.date}>
{formattedDate}
</DateTime>
</div>
{entry.metadata.version ? (
<div className="mt-2 flex items-center gap-2">
<ChangelogVersionBadgeLink version={entry.metadata.version} />
</div>
) : null}
</header>
{/* Author and social sharing */}
<div className="my-4 flex flex-col justify-between sm:my-8 sm:flex-row sm:items-center">
<div className="flex items-center gap-x-8">
<div
className="flex items-center space-x-3"
data-testid="author-info-img"
>
<Link
href="https://dub.sh/vldszn-x.com"
target="_blank"
rel="noopener noreferrer"
className="group"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt="Vlad Sazonau"
loading="lazy"
width="36"
height="36"
decoding="async"
className="rounded-full blur-0 transition-all group-hover:brightness-95"
src={
"https://ik.imagekit.io/fl2lbswwo/avatar.jpeg?updatedAt=1757456439459"
}
/>
</Link>
<div className="flex flex-col" data-testid="author-info-text">
<Link
href="https://dub.sh/vldszn-x.com"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-neutral-800 no-underline hover:underline hover:underline-offset-2 dark:text-neutral-300"
>
Vlad Sazonau
</Link>
<span className="text-sm text-slate-500 dark:text-neutral-400">
Founder,{" "}
<Link
href="/?template=default"
className="text-slate-500 no-underline hover:underline hover:underline-offset-2 dark:text-neutral-400"
>
{" "}
EasyInvoicePDF
</Link>
</span>
</div>
</div>
<ChangelogAuthorByline />
</div>
<div className="flex items-center gap-x-6">
{/* Twitter/X share */}
@ -330,7 +294,7 @@ export default async function ChangelogEntryPage({
data-testid="go-to-app-button-container"
>
<BlackGoToAppButton className="w-full py-6 text-lg">
Go to App
Start Invoicing
</BlackGoToAppButton>
</div>

View file

@ -0,0 +1,50 @@
import Link from "next/link";
/**
* Displays author information for changelog entries.
* Shows avatar, name, and role with links to social profiles.
*
* @returns Author byline component with avatar and text information
*/
export function ChangelogAuthorByline() {
return (
<div className="flex items-center space-x-3" data-testid="author-info-img">
<Link
href="https://x.com/vladsazonau"
target="_blank"
rel="noopener noreferrer"
className="group"
>
<img
alt="Vlad Sazonau"
loading="lazy"
width="36"
height="36"
decoding="async"
className="rounded-full blur-0 transition-all group-hover:brightness-95"
src="https://ik.imagekit.io/fl2lbswwo/avatar.jpeg?updatedAt=1757456439459"
/>
</Link>
<div className="flex flex-col" data-testid="author-info-text">
<Link
href="https://x.com/vladsazonau"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-neutral-800 no-underline hover:underline hover:underline-offset-2 dark:text-neutral-300"
>
Vlad Sazonau
</Link>
<span className="text-sm text-slate-500 dark:text-neutral-400">
Founder,{" "}
<Link
href="/?template=default"
className="text-slate-500 no-underline hover:underline hover:underline-offset-2 dark:text-neutral-400"
>
{" "}
EasyInvoicePDF
</Link>
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,30 @@
import { getChangelogReleaseNotesUrl } from "@/app/changelog/utils";
interface ChangelogVersionBadgeLinkProps {
version: string;
}
export function ChangelogVersionBadgeLink({
version,
}: ChangelogVersionBadgeLinkProps) {
if (!version) {
return null;
}
const versionUrl = getChangelogReleaseNotesUrl(version);
if (!versionUrl) {
return null;
}
return (
<a
href={versionUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center rounded-full border border-slate-300 bg-slate-50 px-2.5 py-1 text-sm font-medium text-slate-600 transition-colors hover:border-slate-400 hover:bg-slate-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:bg-gray-700"
>
v{version}
</a>
);
}

View file

@ -7,7 +7,7 @@ export function DateTime({
}) {
return (
<time
className="inline-flex items-center rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm font-medium text-gray-800 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-950 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:hover:text-gray-50"
className="inline-flex items-center rounded-md bg-blue-50/50 px-3 py-1.5 text-sm font-medium text-blue-800 shadow-sm ring-1 ring-blue-200 transition-colors hover:bg-blue-50 hover:text-blue-800/90 dark:bg-blue-950 dark:text-blue-100 dark:ring-blue-800 dark:hover:bg-blue-900 dark:hover:text-blue-50"
dateTime={dateTime}
>
{children}

View file

@ -1,22 +1,28 @@
export const metadata = {
title: "EasyInvoicePDF Launch - Free & Open-Source Invoice Generator",
title: "EasyInvoicePDF v1.0.0 - Free & Open-Source Invoice Generator",
description:
"Create professional invoices instantly with real-time preview and shareable links. No registration needed.",
date: "2025-03-03",
"Create professional invoices instantly with real-time preview, default and Stripe-style templates, and shareable links. No registration needed.",
date: "2025-11-19",
version: "1.0.0",
type: "major",
};
![EasyInvoicePDF Launch](https://ik.imagekit.io/fl2lbswwo/CHANGELOG/easy-invoice-pdf-launch/launch.png?updatedAt=1751028191862)
![Default Invoice Template](https://github.com/user-attachments/assets/7779cd52-e2a9-442a-8ac8-262387319b3e)
## 🎉 Initial Release
### Welcome to EasyInvoicePDF!
We're excited to launch **EasyInvoicePDF** - a free, open-source tool that helps you create professional invoices instantly with real-time preview. No sign-up required, completely free to use!
We're excited to launch **EasyInvoicePDF** - a free, open-source tool that helps you create professional invoices instantly with real-time preview. Pick the **default layout** or a **Stripe-inspired invoice template** so PDFs match the look you want. No sign-up required, completely free to use!
![Stripe Invoice Template](https://github.com/user-attachments/assets/ee8d5337-89fc-461c-b1c1-2680287df514)
## ✨ Core Features
### 💳 Stripe-inspired template
Switch to a **Stripe-style invoice template** for a clean, familiar layout (in addition to the default template). Same fields and PDF download; only the visual design changes.
### 🔴 Live Preview
See your invoice update in real-time as you make changes, ensuring it looks exactly how you want before downloading.
@ -59,6 +65,7 @@ Automatically calculate European VAT rates and totals for your invoices. Complia
### 🏢 Professional Invoice Features
- Default and Stripe-inspired PDF templates
- Complete seller and buyer information management
- Detailed invoice items with descriptions, quantities, and pricing
- Automatic tax calculations and totals
@ -73,3 +80,5 @@ Automatically calculate European VAT rates and totals for your invoices. Complia
Start creating professional invoices in seconds with our free, open-source tool at [easyinvoicepdf.com](https://easyinvoicepdf.com)
**GitHub Repository**: [VladSez/easy-invoice-pdf](https://github.com/VladSez/easy-invoice-pdf)
[View Release Notes for v1.0.0 on GitHub](https://github.com/VladSez/easy-invoice-pdf/releases/tag/EasyInvoicePDF-v1.0.0)

View file

@ -1,6 +1,6 @@
export const metadata = {
title:
"QR Code Support, Logo Upload for the Default Invoice Template and Many More",
"EasyInvoicePDF v1.0.2 - QR Code Support, Logo Upload for the Default Invoice Template and Many More",
description:
"QR code generation for invoices, logo upload for the default template, searchable currency combobox, and improved multi-page PDF support",
date: "2026-03-10",
@ -13,8 +13,8 @@ export const metadata = {
## ✨ Highlights
- **QR code generation** for invoices with customizable descriptions and visibility toggles, supported in both default and Stripe templates
- **Logo upload for the default invoice template** (previously available only in the Stripe template)
- **Searchable currency combobox** with grouped categories, replacing the native dropdown for faster selection
- **Logo upload for the default invoice template** add a logo to the default invoice template
- **Searchable currency combobox** search by currency code, symbol, or name, grouped into categories replacing the native dropdown
- **Improved multi-page PDF support** with automatic pagination and page breaks
---
@ -36,24 +36,8 @@ export const metadata = {
- Delete invoice item flow not working correctly
- Item name field validation too strict (now optional for flexibility)
### What's Changed
- feat: add QR code functionality to invoice templates and other improvements + bug fixes by @VladSez in [#165](https://github.com/VladSez/easy-invoice-pdf/pull/165)
- improvement: update QR code images and enhance invoice template visuals by @VladSez in [#172](https://github.com/VladSez/easy-invoice-pdf/pull/172)
- test: enhance URL verification in e2e tests for various pages by @VladSez in [#174](https://github.com/VladSez/easy-invoice-pdf/pull/174)
- test: update navigation wait strategy in e2e tests for improved reliability by @VladSez in [#175](https://github.com/VladSez/easy-invoice-pdf/pull/175)
- feat: update Playwright configuration and enhance e2e tests by @VladSez in [#176](https://github.com/VladSez/easy-invoice-pdf/pull/176)
- chore: adjust Playwright configuration and e2e test settings for stability by @VladSez in [#177](https://github.com/VladSez/easy-invoice-pdf/pull/177)
- chore: set timezone environment variable in e2e workflow to fix timezone issue in node js server env by @VladSez in [#178](https://github.com/VladSez/easy-invoice-pdf/pull/178)
- docs: add Star History section to README and improve e2e test navigation strategy by @VladSez in [#179](https://github.com/VladSez/easy-invoice-pdf/pull/179)
- fix: delete invoice item flow by @VladSez in [#180](https://github.com/VladSez/easy-invoice-pdf/pull/180)
- fix: fix i18n issue with generating pdf via api + other improvements by @VladSez in [#181](https://github.com/VladSez/easy-invoice-pdf/pull/181)
- refactor: rename item removal handler and enhance user feedback in invoice form by @VladSez in [#182](https://github.com/VladSez/easy-invoice-pdf/pull/182)
- refactor: currency searchable combobox by @VladSez in [#183](https://github.com/VladSez/easy-invoice-pdf/pull/183)
- feat: expand TODO list with discounts feature and improve e2e workflow by @VladSez in [#186](https://github.com/VladSez/easy-invoice-pdf/pull/186)
- feat: upload logo for default invoice templates, improve add invoice btn styles, add more e2e tests by @VladSez in [#189](https://github.com/VladSez/easy-invoice-pdf/pull/189)
- fix: update README logo and refactor invoice form for Stripe payment link by @VladSez in [#190](https://github.com/VladSez/easy-invoice-pdf/pull/190)
---
**Full Changelog**: [`EasyInvoicePDF-1.0.1...v1.0.2`](https://github.com/VladSez/easy-invoice-pdf/compare/EasyInvoicePDF-1.0.1...v1.0.2)
[View Release Notes for v1.0.2 on GitHub](https://github.com/VladSez/easy-invoice-pdf/releases/tag/v1.0.2)

View file

@ -0,0 +1,44 @@
export const metadata = {
title:
"EasyInvoicePDF v1.0.3 - Email visibility toggle for seller and buyer, reworked seller/buyer sections, confirm dialog to discard changes, UX improvements",
description:
"Email visibility toggle for seller/buyer sections, reworked form layouts with locked-state banners, ConfirmDiscardDialog, and auto-scroll on mobile",
date: "2026-03-29",
version: "1.0.3",
type: "minor",
};
## ✨ Highlights
- **Seller & Buyer Email visibility toggle** — control whether the email address appears in the generated PDF
- **`ConfirmDiscardDialog`** component to warn users about unsaved changes when closing the buyer/seller dialogs
- **Reworked seller and buyer information form sections** with improved layout, locked-state banners, and cleaner field grouping
- **Auto-scroll the invoice form on mobile** when switching between tabs (UX improvement)
- **Out-of-Date dates helper** shows outdated fields and provides a button to update all dates at once
<video
src="https://github.com/user-attachments/assets/1b39eb6f-e2be-493f-9825-cbce3dc6fa16"
controls
style={{ width: "100%", borderRadius: "8px" }}
/>
---
### Changed
- Invalid localStorage entries for buyers and sellers are now validated and silently dropped instead of causing errors
- Error message component layout and copy updated for better readability
- GitHub Actions workflows updated to latest action versions; failure handling added to all CI jobs
- Added knip GitHub CI job for automated dead-code and unused-dependency detection
### Fixed
- Pre-fill switch in buyer/seller dialogs no longer retains its state after the dialog is closed and reopened
- Buyer and seller dialogs now reset form values and pre-fill switch to their defaults when closed
- Buyer and seller names are trimmed of whitespace before saving; whitespace-padded duplicates are rejected
---
**Full Changelog**: [`v1.0.2...v1.0.3`](https://github.com/VladSez/easy-invoice-pdf/compare/v1.0.2...v1.0.3)
[View Release Notes for v1.0.3 on GitHub](https://github.com/VladSez/easy-invoice-pdf/releases/tag/v1.0.3)

View file

@ -1,47 +1,54 @@
export const metadata = {
title: "Stripe Invoice Template & Landing Page Improvements",
title:
"EasyInvoicePDF v1.0.1 - Customizable Tax/VAT Labels & Major Improvements",
description:
"Introducing a modern, Stripe-inspired invoice template with professional styling and layout optimizations",
date: "2025-06-28",
"Customizable tax and VAT labels, per-language tax labels, stronger VAT validation, localization updates, PDF performance, e2e and CI improvements, and bug fixes.",
date: "2026-01-12",
version: "1.0.1",
type: "major",
type: "minor",
};
![New Stripe Invoice Template](https://ik.imagekit.io/fl2lbswwo/CHANGELOG/stripe-pdf-template/stripe-template.png?updatedAt=1751032495134)
<video
src="https://github.com/user-attachments/assets/4eef2b90-678b-4a55-9ee5-8fcf195c993a"
controls
style={{ width: "100%", borderRadius: "8px" }}
/>
## 🚀 New Features
### ✨ New Features
### Stripe-inspired Invoice Template
- **Customizable tax label**: Users can personalize the tax/VAT label text on invoices (for example VAT, GST, Sales Tax)
- **Customizable tax number label**: Support for custom tax number labels in buyer and seller information sections
- **Dynamic tax label per language**: Tax labels update automatically based on the selected invoice language
- 🤩 **Stripe template support**: Introduced a modern, Stripe-inspired invoice template with professional styling and layout optimizations
- **Dynamic template selection**: Introduced template selection functionality in the invoice form
- **Logo upload**: Added logo upload capability for the **Stripe** template with validation
- **Payment links**: Integrated Stripe payment URL field for enhanced invoice functionality
### 🛠️ Improvements
![Landing Page](https://ik.imagekit.io/fl2lbswwo/CHANGELOG/stripe-pdf-template/landing.png?updatedAt=1751032507925)
- Enhanced validation for VAT input to accept both numeric values and specific strings
- Improved user interface messages for clarity regarding VAT input requirements
- Updated README with enhanced project description, links, and feature presentation
- Better localization across all supported languages (EN, DE, ES, FR, IT, NL, PL, PT, RU, UK)
- Streamlined PDF font handling with improved caching for better performance
- Enhanced invoice PDF rendering quality
### User Experience & Design
### 🧪 Testing & Infrastructure
- **Landing page cleanup**: Refined About section and footer for better layout and accessibility
- **Call-to-action toasts**: Added custom, randomized CTA toasts encouraging user support (40-second timer)
- **Currency expansion**: Added support for more currencies with improved date handling
- **Improved tooltips**: Enhanced tooltips with detailed explanations and improved styling
- Added comprehensive E2E tests for new tax label features
- Improved Playwright test stability with refined screenshot assertions
- Updated CI/CD to use macOS runners for consistent snapshot generation
- Added static assets tests for better reliability
{/* prettier-ignore-start */}
| Before | After |
|--------|-------|
| ![before](https://ik.imagekit.io/fl2lbswwo/CHANGELOG/stripe-pdf-template/before1.png?updatedAt=1751028191085) | ![after](https://ik.imagekit.io/fl2lbswwo/CHANGELOG/stripe-pdf-template/after1.png?updatedAt=1751028191085) |
| ![before2](https://ik.imagekit.io/fl2lbswwo/CHANGELOG/stripe-pdf-template/before2.png?updatedAt=1751028191085) | ![after2](https://ik.imagekit.io/fl2lbswwo/CHANGELOG/stripe-pdf-template/after2.png?updatedAt=1751028191085) |
{/* prettier-ignore-end */}
### 🐛 Bug Fixes
- **Invoice deletion**: Added confirmation dialog for invoice item deletion
- **Form validation**: Relaxed date validations for better user experience
- **Improved PDF viewers**: Enhanced PDF rendering with better error handling and fallback UI
- **Accessibility**: Improved button component accessibility with proper disabled state handling
- **Detailed Invoice Footers**: Added comprehensive invoice footer and payment info sections
- Fixed accordion bug
- Fixed invoice link generation error message to include a refresh suggestion
- Improved invoice item tax label helper text
- Various UI polish and consistency fixes
![Invoice Footer](https://ik.imagekit.io/fl2lbswwo/CHANGELOG/stripe-pdf-template/invoice-footer.png?updatedAt=1751028191085)
## What's Changed
- feat: customizable tax/VAT label text + a lot of other improvements and bug fixes by [@VladSez](https://github.com/VladSez) in https://github.com/VladSez/easy-invoice-pdf/pull/163
---
**Full Changeset**: [`63b097b...7ae733b`](https://github.com/VladSez/easy-invoice-pdf/compare/63b097b...7ae733b)
**Full Changelog**: [EasyInvoicePDF-v1.0.0...EasyInvoicePDF-1.0.1](https://github.com/VladSez/easy-invoice-pdf/compare/EasyInvoicePDF-v1.0.0...EasyInvoicePDF-1.0.1)
[View release notes for v1.0.1 on GitHub](https://github.com/VladSez/easy-invoice-pdf/releases/tag/EasyInvoicePDF-1.0.1)

View file

@ -1,8 +1,5 @@
import { ProjectLogo } from "@/components/etc/project-logo";
import { Header } from "@/app/(components)/header";
import { Footer } from "@/components/footer";
import { BlackGoToAppButton } from "@/components/go-to-app-button-cta";
import { ProjectLogoDescription } from "@/components/project-logo-description";
import { Button } from "@/components/ui/button";
import { GITHUB_URL, STATIC_ASSETS_URL } from "@/config";
import type { Metadata } from "next";
import Link from "next/link";
@ -85,23 +82,46 @@ interface ChangelogLayoutProps {
export default function ChangelogLayout({ children }: ChangelogLayoutProps) {
return (
<>
<Header />
<Header
locale={"en"}
translations={{
navLinks: {
features: "Features",
faq: "FAQ",
github: "GitHub",
githubUrl: GITHUB_URL,
},
switchLanguageText: "Switch Language",
goToAppText: "Open app",
startInvoicingButtonText: "Start Invoicing",
changelogLinkText: "Changelog",
termsOfServiceLinkText: "Terms of Service",
}}
hideLanguageSwitcher={true}
/>
{children}
<Footer
translations={{
footerDescription:
"Create professional invoices in seconds with our free & open-source invoice maker. 100% in-browser, no sign-up required. Includes live PDF preview and a Stripe-style template - perfect for freelancers, startups, and small businesses.",
footerDescription: (
<>
Create professional invoices in seconds with our free &
open-source invoice maker. 100% in-browser, no sign-up required.
Includes live PDF preview and a Stripe-style template - perfect
for freelancers, startups, and small businesses.
<br /> <br />
Not accounting software. No compliance guarantees. By using this
tool, you agree to the{" "}
<Link
href="/tos"
className="text-slate-700 underline hover:text-slate-900"
>
Terms of Service
</Link>
.
</>
),
footerCreatedBy: "Made by",
product: "Product",
newsletterTitle: "Subscribe to our newsletter",
newsletterDescription:
"Get the latest updates and news from EasyInvoicePDF.com",
newsletterSubscribe: "Subscribe",
newsletterPlaceholder: "Enter your email",
newsletterSuccessMessage: "Thank you for subscribing!",
newsletterErrorMessage: "Failed to subscribe. Please try again.",
newsletterEmailLanguageInfo: "All emails will be sent in English",
}}
links={
<ul className="space-y-2">
@ -132,6 +152,15 @@ export default function ChangelogLayout({ children }: ChangelogLayoutProps) {
GitHub
</Link>
</li>
<li>
<Link
href="/tos"
className="text-sm text-slate-500 hover:text-slate-900"
>
Terms of Service
</Link>
</li>
<li>
<Link
href="https://pdfinvoicegenerator.userjot.com/?cursor=1&order=top&limit=10"
@ -142,55 +171,17 @@ export default function ChangelogLayout({ children }: ChangelogLayoutProps) {
Share feedback
</Link>
</li>
<li>
<Link
href="/founder"
className="text-sm text-slate-500 hover:text-slate-900"
>
Founder
</Link>
</li>
</ul>
}
/>
</>
);
}
function Header() {
return (
<header className="sticky top-0 z-50 w-full border-b bg-white shadow-sm">
<div className="flex items-center justify-center">
<div className="container h-auto px-3 py-2 sm:h-16 sm:py-0">
<div className="flex h-full flex-row flex-wrap items-center justify-between gap-2">
<div className="w-[53%] sm:w-auto">
<Logo />
</div>
<div className="flex items-center sm:mt-0 sm:gap-2">
<Button variant="ghost" className="hidden lg:inline-flex" asChild>
<Link href="/en/about">About product</Link>
</Button>
<Button variant="ghost" className="hidden lg:inline-flex" asChild>
<Link
href="https://pdfinvoicegenerator.userjot.com/?cursor=1&order=top&limit=10"
target="_blank"
rel="noopener noreferrer"
>
Share feedback
</Link>
</Button>
<BlackGoToAppButton className="px-3 sm:px-8">
Go to App
</BlackGoToAppButton>
</div>
</div>
</div>
</div>
</header>
);
}
function Logo() {
return (
<div>
<div className="flex items-center gap-1 sm:gap-2">
<ProjectLogo className="h-7 w-7 flex-shrink-0 sm:h-8 sm:w-8" />
<ProjectLogoDescription>
Free Invoice Generator with Live PDF Preview
</ProjectLogoDescription>
</div>
</div>
);
}

View file

@ -0,0 +1,64 @@
export default function Loading() {
return (
<div className="min-h-screen bg-white dark:bg-gray-900">
<div className="relative z-0">
{/* Hero */}
<div className="relative mb-8 pt-16 text-center sm:mb-16">
<div className="absolute bottom-0 h-full w-full bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:16px_16px] [mask-image:radial-gradient(ellipse_50%_50%_at_50%_50%,#000_70%,transparent_100%)] sm:bottom-auto" />
<div className="relative z-10 mx-auto h-10 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700 sm:h-12 sm:w-64" />
<div className="relative z-10 mx-auto mt-4 h-5 w-72 animate-pulse rounded bg-gray-200 dark:bg-gray-700 sm:w-96" />
</div>
{/* Timeline Grid */}
<div className="border-slate-200 sm:border-t">
<div className="mx-auto max-w-6xl border-slate-200 px-4 dark:border-gray-700 sm:px-12 xl:border-x">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="grid pb-20 pt-4 sm:pt-12 md:grid-cols-4">
{/* Date column - desktop */}
<div className="sticky top-28 hidden self-start md:col-span-1 md:mt-[6px] md:block">
<div className="h-4 w-28 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</div>
{/* Content column */}
<div className="flex flex-col md:col-span-3">
{/* Date - mobile */}
<div className="mb-3 h-4 w-28 animate-pulse rounded bg-gray-200 dark:bg-gray-700 md:hidden" />
{/* Title */}
<div className="mt-2 h-7 w-3/4 animate-pulse rounded bg-gray-200 dark:bg-gray-700 sm:mt-0" />
{/* Version badge */}
<div className="mt-2 h-5 w-16 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
{/* Author byline */}
<div className="mb-2 mt-4 flex items-center gap-2">
<div className="h-6 w-6 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</div>
{/* Article content */}
<div className="space-y-3">
<div className="h-4 w-full animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-5/6 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-4/6 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="mt-4 space-y-2 pl-4">
{Array.from({ length: 4 }).map((_, j) => (
<div
key={j}
className="h-4 animate-pulse rounded bg-gray-200 dark:bg-gray-700"
style={{ width: `${80 - j * 8}%` }}
/>
))}
</div>
<div className="h-4 w-full animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-3/4 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View file

@ -6,12 +6,22 @@ import {
import Link from "next/link";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { ChangelogAuthorByline } from "./components/changelog-author-byline";
import { ChangelogVersionBadgeLink } from "./components/changelog-version-badge-link";
import { DateTime } from "./components/date-time";
// Enable static generation for this page
export const dynamic = "force-static";
// https://nextjs.org/docs/app/guides/mdx
/**
* Changelog page component that displays all changelog entries in a timeline layout.
* Fetches and renders changelog entries with dates, titles, and content.
* Returns 404 if no entries are found.
*
* @returns The changelog page with all entries
*/
export default async function ChangelogPage() {
const entries = await getChangelogEntries();
@ -45,13 +55,19 @@ export default async function ChangelogPage() {
);
}
/**
* Renders a single changelog entry card with date, title, and content.
* On desktop, the date is sticky in the left column. On mobile, it appears above the title.
*
* @param entry - The changelog entry to display
*/
function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
const formattedDate = formatChangelogDate(entry.metadata.date);
return (
<div className="grid pb-20 pt-4 sm:pt-12 md:grid-cols-4">
{/* Sticky Date Column - Desktop Only */}
<div className="sticky top-28 hidden self-start md:col-span-1 md:block">
<div className="sticky top-28 hidden self-start md:col-span-1 md:mt-[6px] md:block">
<Link href={`/changelog/${entry.slug}`}>
<DateTime dateTime={entry.metadata.date}>{formattedDate}</DateTime>
</Link>
@ -71,6 +87,17 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
</h2>
</Link>
{/* Version Badge */}
{entry.metadata.version ? (
<div className="mt-2 flex items-center gap-2">
<ChangelogVersionBadgeLink version={entry.metadata.version} />
</div>
) : null}
<div className="not-prose mb-2 mt-4">
<ChangelogAuthorByline />
</div>
{/* Article Content */}
<article
data-testid="changelog-entry-card"

View file

@ -1,7 +1,29 @@
import { GITHUB_URL } from "@/config";
import { readdir } from "fs/promises";
import { join } from "path";
import { z } from "zod";
/** Maps metadata `version` (e.g. "1.0.1") to the GitHub release tag name. */
const changelogVersionToReleaseTag: Record<string, string> = {
"1.0.0": "EasyInvoicePDF-v1.0.0",
"1.0.1": "EasyInvoicePDF-1.0.1",
"1.0.2": "v1.0.2",
"1.0.3": "v1.0.3",
};
/**
* GitHub release URL for changelog entries. Unknown versions default to `v{version}`.
*/
export function getChangelogReleaseNotesUrl(version: string) {
const tag = changelogVersionToReleaseTag[version];
if (!tag) {
return null;
}
return `${GITHUB_URL}/releases/tag/${tag}`;
}
interface ChangelogMetadata {
title: string;
description: string;

167
src/app/founder/page.tsx Normal file
View file

@ -0,0 +1,167 @@
import { Header } from "@/app/(components)/header";
import { GITHUB_URL, LINKEDIN_URL, STATIC_ASSETS_URL, TWITTER_URL } from "@/config";
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Vlad Sazonau | Founder of EasyInvoicePDF",
description:
"Meet Vlad Sazonau, the founder of EasyInvoicePDF.com. Product engineer and design enthusiast with 8+ years of experience building beautiful, functional products.",
keywords: [
"Vlad Sazonau",
"EasyInvoicePDF founder",
"product engineer",
"indie developer",
"easyinvoicepdf",
],
authors: [{ name: "Uladzislau Sazonau" }],
creator: "Uladzislau Sazonau",
publisher: "Uladzislau Sazonau",
alternates: {
canonical: "https://easyinvoicepdf.com/founder",
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
openGraph: {
title: "Vlad Sazonau | Founder of EasyInvoicePDF",
description:
"Meet Vlad Sazonau, the founder of EasyInvoicePDF.com. Product engineer and design enthusiast with 8+ years of experience building beautiful, functional products.",
siteName: "EasyInvoicePDF.com | Free Invoice Generator",
type: "profile",
locale: "en_US",
images: [
{
url: `${STATIC_ASSETS_URL}/easy-invoice-opengraph-image.png?v=1755773879597`,
type: "image/png",
width: 1200,
height: 630,
alt: "EasyInvoicePDF.com - Free Invoice Generator with Live PDF Preview",
},
],
},
twitter: {
card: "summary_large_image",
title: "Vlad Sazonau | Founder of EasyInvoicePDF",
description:
"Meet Vlad Sazonau, the founder of EasyInvoicePDF.com. Product engineer and design enthusiast.",
creator: "@vlad_sazon",
images: [
{
url: `${STATIC_ASSETS_URL}/easy-invoice-opengraph-image.png?v=1755773879597`,
type: "image/png",
width: 1200,
height: 630,
alt: "EasyInvoicePDF.com - Free Invoice Generator with Live PDF Preview",
},
],
},
};
export default function ContactPage() {
return (
<>
<Header
locale={"en"}
translations={{
navLinks: {
features: "Features",
faq: "FAQ",
github: "GitHub",
githubUrl: GITHUB_URL,
},
switchLanguageText: "Switch Language",
goToAppText: "Open app",
startInvoicingButtonText: "Start Invoicing",
changelogLinkText: "Changelog",
termsOfServiceLinkText: "Terms of Service",
}}
hideLanguageSwitcher={true}
/>
<div className="bg-gradient-to-b from-slate-50 to-white py-12 md:py-24 lg:min-h-screen">
<div className="container mx-auto px-4 md:px-6">
<div className="flex flex-col items-center">
{/* Avatar */}
<div className="mb-8 overflow-hidden rounded-full border-4 border-slate-200 shadow-lg">
<img
src="https://ik.imagekit.io/fl2lbswwo/avatar.jpeg?updatedAt=1757456439459"
alt="Vlad Sazonau"
width={160}
height={160}
loading="lazy"
decoding="async"
className="size-40 object-cover"
/>
</div>
{/* Name and Title */}
<h1 className="text-center text-4xl font-bold tracking-tight text-slate-900 md:text-5xl">
Vlad Sazonau
</h1>
<p className="mt-3 text-center text-xl text-slate-600 md:text-2xl">
Product Engineer & Design Enthusiast
</p>
{/* Bio */}
<p className="mx-auto mt-8 max-w-xl text-center text-lg leading-relaxed text-slate-700">
I&apos;m a product-minded generalist with 8+ years of experience
building beautiful, functional products. I enjoy solving complex
problems at the intersection of engineering and design.
</p>
{/* Social Links */}
<div className="mt-12 flex flex-wrap justify-center gap-4 md:gap-6">
<Link
href="https://vladsazon.com"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-slate-900 underline transition-all hover:opacity-80"
aria-label="Visit website"
>
Website
</Link>
<Link
href={TWITTER_URL}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-slate-900 underline transition-all hover:opacity-80"
aria-label="Visit Twitter"
>
Twitter
</Link>
<Link
href={LINKEDIN_URL}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-slate-900 underline transition-all hover:opacity-80"
aria-label="Visit LinkedIn"
>
LinkedIn
</Link>
<Link
href={"https://github.com/VladSez"}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-slate-900 underline transition-all hover:opacity-80"
aria-label="Visit GitHub"
>
GitHub
</Link>
</div>
</div>
</div>
</div>
</>
);
}

View file

@ -23,7 +23,8 @@ body {
position: absolute;
inset: -1px;
padding: 1px;
-webkit-mask-image: linear-gradient(#000, #000), linear-gradient(#000, #000);
-webkit-mask-image:
linear-gradient(#000, #000), linear-gradient(#000, #000);
mask-image: linear-gradient(#000, #000), linear-gradient(#000, #000);
-webkit-mask-clip: content-box, border-box;
mask-clip: content-box, border-box;
@ -38,8 +39,14 @@ body {
width: 500px;
height: 500px;
transform-origin: 0 0;
background: conic-gradient(transparent 0%, #7d5bfff2 10%, #7d5bff59, transparent 20%);
filter: drop-shadow(0 0 6px rgba(125, 91, 255, 0.7)) drop-shadow(0 0 14px rgba(125, 91, 255, 0.45));
background: conic-gradient(
transparent 0%,
#7d5bfff2 10%,
#7d5bff59,
transparent 20%
);
filter: drop-shadow(0 0 6px rgba(125, 91, 255, 0.7))
drop-shadow(0 0 14px rgba(125, 91, 255, 0.45));
}
}
@ -47,4 +54,15 @@ body {
:root {
--radius: 0.5rem;
}
/* Custom text selection styling
* ::selection targets text highlighted by the user
* background-color: Sets the highlight color to brand purple (rgba(125, 91, 255))
* color: Sets the selected text color to white (#ffffff) for contrast
* This creates a branded selection experience across the entire site
*/
::selection {
background-color: rgba(125, 91, 255);
color: #ffffff;
}
}

View file

@ -66,6 +66,8 @@ const aboutSchema = z
features: z.string(),
github: z.string(),
changelog: z.string(),
founder: z.string(),
termsOfService: z.string(),
})
.strict(),
createdBy: z.string(),
@ -82,17 +84,6 @@ const aboutSchema = z
startInvoicing: z.string(),
})
.strict(),
newsletter: z
.object({
title: z.string(),
description: z.string(),
subscribe: z.string(),
placeholder: z.string(),
success: z.string(),
error: z.string(),
emailLanguageInfo: z.string(),
})
.strict(),
})
.strict();

42
src/app/tos/loading.tsx Normal file
View file

@ -0,0 +1,42 @@
import { HeaderSkeleton } from "@/app/(components)/header/header-skeleton";
export default function Loading() {
return (
<div className="min-h-screen bg-white dark:bg-gray-900">
<HeaderSkeleton />
<div className="container mx-auto max-w-3xl px-4 py-12 md:px-6 md:py-16">
<div className="space-y-8">
{/* Title */}
<div className="h-9 w-56 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
{/* Effective date */}
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
{/* Sections */}
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="space-y-4">
<div className="h-px w-full bg-gray-200 dark:bg-gray-700" />
<div className="h-6 w-40 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="space-y-2">
<div className="h-4 w-full animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-5/6 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-4/6 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</div>
{i % 2 === 0 && (
<div className="space-y-2 pl-4">
{Array.from({ length: 3 }).map((_, j) => (
<div
key={j}
className="h-4 animate-pulse rounded bg-gray-200 dark:bg-gray-700"
style={{ width: `${75 + j * 5}%` }}
/>
))}
</div>
)}
</div>
))}
</div>
</div>
</div>
);
}

255
src/app/tos/page.tsx Normal file
View file

@ -0,0 +1,255 @@
import { Header } from "@/app/(components)/header";
import { GITHUB_URL, STATIC_ASSETS_URL } from "@/config";
import type { Metadata } from "next";
import Link from "next/link";
export const dynamic = "force-static";
export const metadata: Metadata = {
title:
"Terms of Service | EasyInvoicePDF - Free & Open-Source Invoice Generator",
description:
"Terms of Service for EasyInvoicePDF.com. Browser-based invoice PDF tool; no server-side invoice data storage.",
keywords: [
"terms of service",
"easyinvoicepdf",
"invoice generator",
"legal",
],
authors: [{ name: "Uladzislau Sazonau" }],
creator: "Uladzislau Sazonau",
publisher: "Uladzislau Sazonau",
alternates: {
canonical: "https://easyinvoicepdf.com/tos",
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
openGraph: {
title: "Terms of Service | EasyInvoicePDF",
description:
"Terms of Service for EasyInvoicePDF.com. Browser-based invoice PDF tool.",
siteName: "EasyInvoicePDF.com | Free Invoice Generator",
type: "website",
locale: "en_US",
images: [
{
url: `${STATIC_ASSETS_URL}/easy-invoice-opengraph-image.png?v=1755773879597`,
type: "image/png",
width: 1200,
height: 630,
alt: "EasyInvoicePDF.com - Free Invoice Generator with Live PDF Preview",
},
],
},
twitter: {
card: "summary_large_image",
title: "Terms of Service | EasyInvoicePDF",
description:
"Terms of Service for EasyInvoicePDF.com. Browser-based invoice PDF tool.",
creator: "@vlad_sazon",
images: [
{
url: `${STATIC_ASSETS_URL}/easy-invoice-opengraph-image.png?v=1755773879597`,
type: "image/png",
width: 1200,
height: 630,
alt: "EasyInvoicePDF.com - Free Invoice Generator with Live PDF Preview",
},
],
},
};
export default function TermsOfServicePage() {
return (
<>
<Header
locale={"en"}
translations={{
navLinks: {
features: "Features",
faq: "FAQ",
github: "GitHub",
githubUrl: GITHUB_URL,
},
switchLanguageText: "Switch Language",
goToAppText: "Open app",
startInvoicingButtonText: "Start Invoicing",
changelogLinkText: "Changelog",
termsOfServiceLinkText: "Terms of Service",
}}
hideLanguageSwitcher={true}
/>
<div className="min-h-screen bg-white dark:bg-gray-900">
<div className="container mx-auto max-w-3xl px-4 py-12 md:px-6 md:py-16">
<article className="prose prose-gray max-w-none dark:prose-invert prose-headings:scroll-mt-20 prose-headings:font-semibold prose-headings:text-gray-900 prose-h1:text-3xl prose-h2:text-xl prose-p:leading-relaxed prose-p:text-gray-600 prose-strong:text-gray-900 prose-li:text-gray-600 prose-hr:border-gray-200 dark:prose-headings:text-gray-100 dark:prose-p:text-gray-300 dark:prose-strong:text-gray-100 dark:prose-li:text-gray-300 dark:prose-hr:border-gray-700">
<h1>Terms of Service</h1>
<p>
<strong>Effective date:</strong> April 17, 2026
</p>
<h2>1. Overview</h2>
<p>
EasyInvoicePDF (&quot;the Service&quot;) is a browser-based tool
that allows users to generate invoice PDFs. By using the Service,
you agree to these Terms.
</p>
<hr />
<h2>2. Nature of the Service</h2>
<p>
The Service is <strong>not</strong> accounting, bookkeeping,
invoicing, or tax compliance software. It is a simple PDF
generation tool.
</p>
<p>The Service:</p>
<ul>
<li>
does not guarantee compliance with any local, national, or
international laws or tax regulations
</li>
<li>
does not store or process user data on servers (all data is
handled locally in your browser)
</li>
<li>does not act as a system of record for invoices</li>
<li>does not provide accounting, tax, or legal advice</li>
</ul>
<hr />
<h2>3. User Responsibility</h2>
<p>You are solely responsible for:</p>
<ul>
<li>
ensuring that any generated invoices comply with applicable laws
and regulations
</li>
<li>
verifying the accuracy and completeness of all data entered
</li>
<li>maintaining your own records, backups, and documentation</li>
<li>
determining whether the output is suitable for your intended use
</li>
</ul>
<hr />
<h2>4. No Professional Relationship</h2>
<p>
Use of the Service does not create any accountant-client,
advisor-client, legal, or fiduciary relationship between you and
the Service or its operator.
</p>
<hr />
<h2>5. No Warranties</h2>
<p>
The Service is provided &quot;as is&quot; and &quot;as
available&quot; without warranties of any kind, express or
implied, including but not limited to:
</p>
<ul>
<li>fitness for a particular purpose</li>
<li>accuracy, reliability, or availability</li>
<li>compliance with legal, tax, or regulatory requirements</li>
</ul>
<hr />
<h2>6. Limitation of Liability</h2>
<p>To the maximum extent permitted by law:</p>
<ul>
<li>
The Service and its operator shall not be liable for any direct,
indirect, incidental, special, consequential, or punitive
damages arising from use or inability to use the Service.
</li>
<li>
This includes, but is not limited to, financial loss, business
interruption, tax penalties, regulatory fines, or data loss.
</li>
</ul>
<p>
If liability is found despite these terms, it shall be limited to
the amount you paid to use the Service (if any).
</p>
<hr />
<h2>7. Indemnification</h2>
<p>
You agree to indemnify, defend, and hold harmless the Service and
its operator from and against any claims, liabilities, damages,
losses, and expenses (including reasonable legal fees) arising out
of:
</p>
<ul>
<li>
your use of the Service in violation of applicable laws or
regulations
</li>
<li>your violation of these Terms</li>
<li>
any data, content, or information you input into the Service
</li>
<li>your use of any documents generated through the Service</li>
</ul>
<hr />
<h2>8. No Data Storage</h2>
<p>
All data entered into the Service remains in your browser. We do
not store, transmit, or retain invoice data on any servers.
</p>
<p>You are responsible for saving and backing up your data.</p>
<hr />
<h2>9. Acceptable Use</h2>
<p>You agree not to:</p>
<ul>
<li>
use the Service for unlawful, fraudulent, or misleading purposes
</li>
<li>
attempt to reverse engineer, disrupt, or misuse the Service
</li>
<li>
use the Service in a way that violates applicable laws or
regulations
</li>
</ul>
<hr />
<h2>10. Changes to the Service</h2>
<p>
We may modify, suspend, or discontinue the Service at any time
without notice or liability.
</p>
<hr />
<h2>11. Changes to Terms</h2>
<p>
We may update these Terms from time to time. Continued use of the
Service constitutes acceptance of the updated Terms.
</p>
<hr />
<h2>12. Contact</h2>
<p>
For questions, contact:{" "}
<Link href="mailto:vlad@mail.easyinvoicepdf.com">
vlad@mail.easyinvoicepdf.com
</Link>
</p>
</article>
</div>
</div>
</>
);
}

View file

@ -2,26 +2,15 @@ import { ProjectLogo } from "@/components/etc/project-logo";
import { GITHUB_URL, TWITTER_URL } from "@/config";
import Link from "next/link";
export function Footer({
links,
translations,
}: {
interface FooterProps {
links: React.ReactNode;
translations: {
footerDescription: string;
footerDescription: React.ReactNode;
footerCreatedBy: string;
product: string;
newsletterTitle: string;
newsletterDescription: string;
newsletterSubscribe: string;
newsletterPlaceholder: string;
newsletterSuccessMessage: string;
newsletterErrorMessage: string;
newsletterEmailLanguageInfo: string;
};
}) {
}
export function Footer({ links, translations }: FooterProps) {
return (
<footer className="w-full border-t border-slate-200 bg-white py-12 md:py-16">
<div className="container mx-auto px-4 md:px-6">
@ -97,12 +86,13 @@ export function Footer({
href="https://startupfa.me/s/easyinvoicepdf?utm_source=easyinvoicepdf.com"
target="_blank"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="https://startupfa.me/badges/featured-badge-small.webp"
alt="Featured on Startup Fame"
width="224"
height="36"
loading="lazy"
decoding="async"
/>
</a>
</div>

View file

@ -23,28 +23,33 @@ export function GitHubStarCTA({
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="relative flex items-center gap-2 overflow-visible rounded-full border border-slate-300/70 bg-white px-3 py-1.5 text-sm shadow-sm transition-colors hover:border-slate-400/50 hover:bg-slate-50 hover:text-black"
className="group relative inline-flex h-9 items-center overflow-hidden rounded-full bg-black bg-gradient-to-r from-stone-800 via-stone-900 to-stone-950 text-sm font-medium text-white shadow-sm shadow-black/10 transition-[transform,opacity] duration-200 ease-out hover:opacity-95 active:scale-[0.96]"
onClick={handleStarClick}
aria-label="Star project on GitHub"
data-testid="github-star-cta-button"
>
<div className="border-glow-mask z-10" aria-hidden="true">
<div className="border-glow-shine animate-rotate-shine" />
</div>
<GithubIcon className="size-4 transition-all duration-300 ease-in-out" />
{githubStarsCount > 0 ? (
<span className="inline-flex items-center">
<CountUpNumber number={githubStarsCount} />
<span className="flex items-center gap-1.5 px-4">
{/* Icon container with relative positioning for layered animation */}
<span className="relative size-4">
{/* GitHub icon - visible by default, fades out and shrinks on hover */}
<GithubIcon className="absolute inset-0 size-4 fill-white transition-[opacity,transform,filter] duration-200 ease-out group-hover:scale-75 group-hover:opacity-0 group-hover:blur-[4px]" />
{/* Star icon - hidden by default, scales up and fades in on hover to replace GitHub icon */}
<Star className="absolute inset-0 size-4 scale-[0.25] fill-yellow-400 text-yellow-400 opacity-0 blur-[4px] transition-[opacity,transform,filter] duration-200 ease-out group-hover:scale-100 group-hover:opacity-100 group-hover:blur-0" />
</span>
) : (
"View on GitHub"
)}
{githubStarsCount > 0 ? (
<>
<CountUpNumber number={githubStarsCount} />
</>
) : (
<span>Star on GitHub</span>
)}
</span>
</a>
</TooltipTrigger>
<TooltipContent>
<p className="flex items-center gap-1.5">
<Star className="size-4 fill-yellow-400 text-yellow-500" />
Give us a star on GitHub
Star on GitHub
</p>
</TooltipContent>
</Tooltip>

View file

@ -13,7 +13,7 @@ export function CountUpNumber({ number }: Props) {
const formattedNumber = formatter.format(number).toLowerCase();
return (
<span className="min-w-[27px] text-center text-sm font-medium tabular-nums text-slate-950">
<span className="min-w-[27px] text-center text-sm font-medium tabular-nums text-slate-50">
{formattedNumber}
</span>
);

View file

@ -49,7 +49,7 @@ const DialogContent = React.forwardRef<
title="Close"
>
<X
size={16}
size={18}
strokeWidth={2}
className="opacity-60 transition-opacity group-hover:opacity-100"
/>

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