Compare commits
22 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
059e4b03aa | ||
|
|
dac8829140 | ||
|
|
5ba4cae47b | ||
|
|
79941e0eeb | ||
|
|
87760b5ae7 | ||
|
|
4967bb319c | ||
|
|
0b371690a4 | ||
|
|
4422060542 | ||
|
|
18c01ed42b | ||
|
|
c48b71c5ad | ||
|
|
8a67b2ef32 | ||
|
|
b832952caf | ||
|
|
32cfc46ad8 | ||
|
|
4f95e53e92 | ||
|
|
10b3fb2991 | ||
|
|
a414bda22a | ||
|
|
d14456bd70 | ||
|
|
4c37060883 | ||
|
|
c37d3ac48d | ||
|
|
7dae0e5c28 | ||
|
|
a77a28405f | ||
|
|
89df4480b0 |
72
.agents/skills/caveman/SKILL.md
Normal 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
|
||||
31
.cursor/rules/agent-rules.mdc
Normal 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
|
|
@ -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
|
||||
|
|
@ -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=""
|
||||
|
|
|
|||
BIN
.github/demos/easy-invoice-github-demo.gif
vendored
Normal file
|
After Width: | Height: | Size: 6.4 MiB |
BIN
.github/demos/instant-download.gif
vendored
|
Before Width: | Height: | Size: 2 MiB After Width: | Height: | Size: 1.8 MiB |
BIN
.github/demos/invoice-template.gif
vendored
|
Before Width: | Height: | Size: 1.7 MiB |
BIN
.github/demos/lang-currency.gif
vendored
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 2 MiB |
BIN
.github/demos/live-pdf-preview.gif
vendored
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
.github/demos/live-preview.gif
vendored
|
Before Width: | Height: | Size: 5.3 MiB |
BIN
.github/demos/qr-code-and-multi-page-pdf.gif
vendored
Normal file
|
After Width: | Height: | Size: 6.5 MiB |
BIN
.github/demos/qr-code.gif
vendored
|
Before Width: | Height: | Size: 7.1 MiB |
BIN
.github/demos/share-link.gif
vendored
|
Before Width: | Height: | Size: 2.3 MiB |
BIN
.github/demos/shared-links.gif
vendored
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
.github/demos/tax-custom.gif
vendored
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 2.5 MiB |
41
.github/screenshots/easy-invoice-logo.svg
vendored
|
Before Width: | Height: | Size: 174 KiB |
3
.github/workflows/e2e.yml
vendored
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
[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
|
||||
|
||||
[](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**.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 146 KiB |
BIN
e2e/fixtures/app-logo.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 149 KiB |
|
|
@ -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
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -62,6 +62,7 @@ export default tseslint.config(
|
|||
],
|
||||
},
|
||||
],
|
||||
"@next/next/no-img-element": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
1
knip.ts
|
|
@ -6,7 +6,6 @@ const config: KnipConfig = {
|
|||
"shadcn",
|
||||
"@radix-ui/react-separator",
|
||||
"@types/ua-parser-js",
|
||||
"cmdk",
|
||||
"eslint-plugin-react-hooks",
|
||||
"file-saver",
|
||||
"jszip",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"caveman": {
|
||||
"source": "JuliusBrussee/caveman",
|
||||
"sourceType": "github",
|
||||
"computedHash": "aa7939fc4d1fe31484090290da77f2d21e026aa4b34b329d00e6630feb985d75"
|
||||
},
|
||||
"changelog-maintenance": {
|
||||
"source": "supercent-io/skills-template",
|
||||
"sourceType": "github",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
49
src/app/(components)/header/header-skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
src/app/(components)/header/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`,
|
||||
35
src/app/(components)/header/logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
src/app/(components)/header/mobile-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
50
src/app/changelog/components/changelog-author-byline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||

|
||||

|
||||
|
||||
## 🎉 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!
|
||||
|
||||

|
||||
|
||||
## ✨ 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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
44
src/app/changelog/content/seller-buyer-improvements.mdx
Normal 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)
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
||||

|
||||
<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
|
||||
|
||||

|
||||
- 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 |
|
||||
|--------|-------|
|
||||
|  |  |
|
||||
|  |  |
|
||||
{/* 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
|
||||
|
||||

|
||||
## 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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
64
src/app/changelog/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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 ("the Service") 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 "as is" and "as
|
||||
available" 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||