feat: update dependencies and enhance invoice form functionality (#14)

* feat: update dependencies and enhance invoice form functionality

- Added new dependencies: @radix-ui/react-label, @radix-ui/react-slot, class-variance-authority, clsx, sonner, tailwind-merge, and tailwindcss-animate.
- Updated package.json and pnpm-lock.yaml to reflect new versions.
- Enhanced the invoice form with improved error handling and UI components.
- Integrated a toast notification system for better user feedback.
- Updated the PDF download link to include invoice language in the filename.
- Improved layout and styling in the invoice form and PDF template components.
- Refactored state management for invoice data to streamline data loading from URL and local storage.

* feat: add Prettier configuration and update project files

- Introduced a Prettier configuration file (.prettierrc.js) for consistent code formatting.
- Updated package.json to include Prettier and its Tailwind CSS plugin as dependencies.
- Made minor formatting adjustments across various components and configuration files for improved readability and consistency.
- Enhanced the loading and invoice form components with better styling and layout adjustments.
- Ensured all JSON files and configuration files have proper formatting and structure.

* feat: enhance invoice sharing and download experience

- Updated toast notification to include rich text formatting for better user guidance on sharing invoices.
- Increased toast duration for improved visibility.
- Refactored layout of the invoice regeneration and download button for better mobile responsiveness.
- Added conditional rendering for the PDF download link based on mobile view.
- Improved styling consistency across components.

* feat: implement responsive PDF download link and add media query hook

- Introduced a custom hook `useMediaQuery` to manage responsive behavior.
- Updated the `InvoicePDFDownloadLink` component to remove mobile-specific prop and enhance styling.
- Modified the main page to conditionally render the PDF download link based on desktop view.
- Enabled server-side rendering for the PDF download link for improved performance.

* feat: enhance invoice PDF layout and translations

- Added a new center style to align content in the invoice items table.
- Updated the invoice items table to apply the center style for better visual alignment of headers.
- Modified translations for 'netAmount' and 'netPrice' to include line breaks for improved readability in the PDF output.

* fix: update link in footer to point to new personal website

* feat: add contact link to footer for user engagement
This commit is contained in:
Vlad Sazonau 2025-01-13 22:38:54 +01:00 committed by GitHub
parent bd470ba447
commit a342935e78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 3746 additions and 1958 deletions

8
.prettierrc.js Normal file
View file

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

View file

@ -4,12 +4,11 @@
## TODO
- [ ] Add OG images
- [ ] Test with big values/numbers on the invoice, e.g. 1 000 000 (check if pdf is displayed correctly)
- [ ] Add error handling and error pages/messages
- [ ] Create a name for the project
- [ ] TEST EVERYTHING on chrome, safari, firefox and maybe on mobile?? + introduce beta testers (colleagues, friends, family)
- [ ] Add email to contact us
- [ ] Add link to github repo and make it open source
- Check https://x.com/levelsio/status/1860015822472905091
(Payments, not sure if we need this)
@ -19,8 +18,6 @@
(BUGS and improvements, double check everything also if we need this)
- [ ] BindingError issue https://github.com/diegomura/react-pdf/issues/2892
- [ ] Add a button to create a shareable link to invoice. Fix current logic, because now we always put all invoice data in url.
- [ ] do not save those things (in page.tsx) in local storage, because they are not needed to be saved, we need them to be recalculated every time for better UX:
const today = dayjs().format("YYYY-MM-DD");
@ -28,6 +25,10 @@
const invoiceCurrentMonthAndYear = dayjs().format("MM-YYYY");
const paymentDue = dayjs(today).add(14, "days").format("YYYY-MM-DD");
if data is in url, always use data from url
if no data in url, use data from local storage
if local storage data is last month, update it with today's data
(OTHER)
- [ ] cleanup code

21
components.json Normal file
View file

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View file

@ -6,13 +6,18 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"prettify": "prettier --write --cache '**/*.{ts?(x),json,js,mjs,yml,yaml,md}'"
},
"dependencies": {
"@hookform/resolvers": "3.9.0",
"@loglib/tracker": "0.8.0",
"@radix-ui/react-label": "2.1.1",
"@radix-ui/react-slot": "1.1.1",
"@react-pdf/layout": "4.0.1",
"@react-pdf/renderer": "4.0.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"dayjs": "1.11.13",
"lucide-react": "0.460.0",
"lz-string": "1.5.0",
@ -22,6 +27,9 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hook-form": "7.53.1",
"sonner": "1.7.1",
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7",
"use-debounce": "10.0.4",
"zod": "3.23.8"
},
@ -32,6 +40,8 @@
"eslint": "^8",
"eslint-config-next": "14.2.15",
"postcss": "^8",
"prettier": "3.4.2",
"prettier-plugin-tailwindcss": "0.6.9",
"tailwindcss": "3.4.14",
"typescript": "5.6.3"
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,25 @@
import { PDFDownloadLink } from "@react-pdf/renderer";
import type { InvoiceData } from "@/app/schema";
import dayjs from "dayjs";
import { InvoicePdfTemplate } from "./invoice-pdf-template";
import { loglib } from "@loglib/tracker";
import { cn } from "@/lib/utils";
export function InvoicePDFDownloadLink({
invoiceData,
}: {
invoiceData: InvoiceData;
}) {
const invoiceDate = dayjs().format("DD-MM-YYYY");
const filename = `invoice-${
invoiceData.language
}-${invoiceData.invoiceNumber.replace("/", "-")}.pdf`;
return (
<PDFDownloadLink
document={<InvoicePdfTemplate invoiceData={invoiceData} />}
fileName={`invoice-${invoiceDate}.pdf`}
className="mb-4 lg:mb-0 w-full lg:w-[180px] text-center px-4 py-2 text-white bg-blue-500 rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
fileName={filename}
className={cn(
"mb-4 w-full rounded-lg bg-slate-900 px-4 py-2 text-center text-sm font-medium text-slate-50 shadow-sm shadow-black/5 outline-offset-2 hover:bg-slate-900/90 focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90 lg:mb-0 lg:w-[180px]"
)}
onClick={() => {
loglib.track("download_invoice");
}}

View file

@ -169,6 +169,11 @@ export const styles = StyleSheet.create({
fontSize: 8,
color: "#000",
},
center: {
display: "flex",
justifyContent: "center",
alignItems: "center",
},
} as const);
// PDF Document component
@ -187,8 +192,10 @@ export const InvoicePdfTemplate = ({
const language = invoiceData.language;
const t = translations[language];
const invoiceDocTitle = `${PROD_WEBSITE_URL} - ${t.invoiceNumber} ${invoiceData.invoiceNumber}`;
return (
<Document>
<Document title={invoiceDocTitle}>
<Page size="A4" style={styles.page}>
<InvoiceHeader invoiceData={invoiceData} />
<InvoiceSellerBuyerInfo invoiceData={invoiceData} />
@ -239,10 +246,10 @@ export const InvoicePdfTemplate = ({
{/* Footer */}
<View style={styles.footer}>
<Text style={[styles.fontSize7]}>
<Text style={[styles.fontSize9]}>
Created with{" "}
<Link
style={[styles.fontSize7, { color: "black" }]}
style={[styles.fontSize9, { color: "blue" }]}
src={PROD_WEBSITE_URL}
>
{PROD_WEBSITE_URL}

View file

@ -18,46 +18,48 @@ export function InvoiceItemsTable({
<View style={styles.table}>
{/* Table header */}
<View style={styles.tableRow}>
<View style={[styles.tableCol, styles.colNo]}>
<View style={[styles.tableCol, styles.colNo, styles.center]}>
<Text style={styles.tableCellBold}>{t.invoiceItemsTable.no}</Text>
</View>
<View style={[styles.tableCol, styles.colName]}>
<View style={[styles.tableCol, styles.colName, styles.center]}>
<Text style={styles.tableCellBold}>
{t.invoiceItemsTable.nameOfGoodsService}
</Text>
</View>
<View style={[styles.tableCol, styles.colGTU]}>
<View style={[styles.tableCol, styles.colGTU, styles.center]}>
<Text style={styles.tableCellBold}>
{t.invoiceItemsTable.typeOfGTU}
</Text>
</View>
<View style={[styles.tableCol, styles.colAmount]}>
<View style={[styles.tableCol, styles.colAmount, styles.center]}>
<Text style={[styles.tableCellBold, {}]}>
{t.invoiceItemsTable.amount}
</Text>
</View>
<View style={[styles.tableCol, styles.colUnit]}>
<View style={[styles.tableCol, styles.colUnit, styles.center]}>
<Text style={styles.tableCellBold}>{t.invoiceItemsTable.unit}</Text>
</View>
<View style={[styles.tableCol, styles.colNetPrice]}>
<View style={[styles.tableCol, styles.colNetPrice, styles.center]}>
<Text style={styles.tableCellBold}>
{t.invoiceItemsTable.netPrice}
</Text>
</View>
<View style={[styles.tableCol, styles.colVAT]}>
<View style={[styles.tableCol, styles.colVAT, styles.center]}>
<Text style={styles.tableCellBold}>{t.invoiceItemsTable.vat}</Text>
</View>
<View style={[styles.tableCol, styles.colNetAmount]}>
<View style={[styles.tableCol, styles.colNetAmount, styles.center]}>
<Text style={styles.tableCellBold}>
{t.invoiceItemsTable.netAmount}
</Text>
</View>
<View style={[styles.tableCol, styles.colVATAmount]}>
<View style={[styles.tableCol, styles.colVATAmount, styles.center]}>
<Text style={styles.tableCellBold}>
{t.invoiceItemsTable.vatAmount}
</Text>
</View>
<View style={[styles.tableCol, styles.colPreTaxAmount]}>
<View
style={[styles.tableCol, styles.colPreTaxAmount, styles.center]}
>
<Text style={[styles.tableCellBold]}>
{t.invoiceItemsTable.preTaxAmount}
</Text>

View file

@ -79,7 +79,7 @@ export const translations = {
unit: "Unit",
netPrice: "Net price",
vat: "VAT",
netAmount: "Net Amount",
netAmount: "Net\n Amount",
vatAmount: "VAT Amount",
preTaxAmount: "Pre-tax amount",
sum: "SUM",
@ -127,9 +127,9 @@ export const translations = {
typeOfGTU: "Typ GTU",
amount: "Ilość",
unit: "Jm",
netPrice: "Cena netto",
netPrice: "Cena\n netto",
vat: "VAT",
netAmount: "Kwota netto",
netAmount: "Kwota\n netto",
vatAmount: "Kwota VAT",
preTaxAmount: "Kwota brutto",
sum: "SUMA",

View file

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

View file

@ -2,21 +2,7 @@
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
@ -25,3 +11,9 @@ body {
text-wrap: balance;
}
}
@layer base {
:root {
--radius: 0.5rem;
}
}

View file

@ -3,6 +3,7 @@ import { NuqsAdapter } from "nuqs/adapters/next/app";
import Loglib from "@loglib/tracker/react";
import "./globals.css";
import { Toaster } from "sonner";
export const metadata: Metadata = {
title: "Free PDF Invoice Generator",
@ -63,8 +64,10 @@ export default function RootLayout({
<Loglib
config={{
id: "pdf-invoice-editor",
webVitals: true,
}}
/>
<Toaster />
</body>
</html>
);

View file

@ -1,17 +1,17 @@
export default function Loading() {
return (
<div className="min-h-screen p-8 bg-gray-50">
<div className="flex flex-col lg:flex-row gap-8 max-w-[1400px] mx-auto">
<div className="min-h-screen bg-gray-50 p-8">
<div className="mx-auto flex max-w-[1400px] flex-col gap-8 lg:flex-row">
{/* Left side - Form */}
<div className="w-full lg:w-1/3 space-y-6">
<div className="h-8 w-full lg:w-64 bg-gray-200 rounded animate-pulse" />
<div className="w-full space-y-6 lg:w-1/3">
<div className="h-8 w-full animate-pulse rounded bg-gray-200 lg:w-64" />
{/* Form fields skeleton */}
<div className="space-y-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="space-y-2">
<div className="h-4 w-32 bg-gray-200 rounded animate-pulse" />
<div className="h-10 bg-gray-200 rounded animate-pulse" />
<div className="h-4 w-32 animate-pulse rounded bg-gray-200" />
<div className="h-10 animate-pulse rounded bg-gray-200" />
</div>
))}
</div>
@ -19,12 +19,12 @@ export default function Loading() {
{/* Right side - PDF Preview */}
<div className="w-full lg:w-2/3">
<div className="bg-gray-200 rounded-lg h-[700px] animate-pulse">
<div className="flex justify-between items-center p-4 bg-gray-300 rounded-t-lg">
<div className="h-6 w-full lg:w-48 bg-gray-400 rounded animate-pulse" />
<div className="gap-2 hidden lg:flex">
<div className="h-8 w-32 bg-gray-400 rounded animate-pulse" />
<div className="h-8 w-32 bg-blue-400 rounded animate-pulse" />
<div className="h-[700px] animate-pulse rounded-lg bg-gray-200">
<div className="flex items-center justify-between rounded-t-lg bg-gray-300 p-4">
<div className="h-6 w-full animate-pulse rounded bg-gray-400 lg:w-48" />
<div className="hidden gap-2 lg:flex">
<div className="h-8 w-32 animate-pulse rounded bg-gray-400" />
<div className="h-8 w-32 animate-pulse rounded bg-blue-400" />
</div>
</div>
</div>

View file

@ -5,21 +5,24 @@ import {
SUPPORTED_LANGUAGES,
type InvoiceData,
} from "@/app/schema";
import { startTransition, useEffect } from "react";
import { useEffect, useState } from "react";
import {
InvoiceForm,
PDF_DATA_FORM_ID,
PDF_DATA_LOCAL_STORAGE_KEY,
} from "./components/invoice-form";
import { SUPPORTED_CURRENCIES } from "@/app/schema";
import dayjs from "dayjs";
import { useQueryState } from "nuqs";
import dynamic from "next/dynamic";
import { InvoicePdfTemplate } from "./components/invoice-pdf-template";
import {
compressToEncodedURIComponent,
decompressFromEncodedURIComponent,
} from "lz-string";
import { loglib } from "@loglib/tracker";
import { useSearchParams, useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useMediaQuery } from "@/lib/hooks/use-media-query";
const InvoicePDFDownloadLink = dynamic(
() =>
@ -28,12 +31,15 @@ const InvoicePDFDownloadLink = dynamic(
),
{
ssr: false,
loading: () => (
<div className="mb-4 lg:mb-0 w-full lg:w-[180px] text-center px-4 py-2 text-white bg-blue-500 rounded hover:bg-blue-600">
Loading document...
</div>
),
ssr: true,
loading: () => {
// fake button styles
return (
<div className="mb-4 w-full rounded-lg bg-slate-900 px-4 py-2 text-center text-sm font-medium text-slate-50 shadow-sm shadow-black/5 hover:bg-slate-900/90 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90 lg:mb-0 lg:w-[180px]">
Loading document...
</div>
);
},
}
);
@ -46,9 +52,9 @@ const InvoicePDFViewer = dynamic(
{
ssr: false,
loading: () => (
<div className="h-full w-full flex items-center justify-center bg-gray-200 border border-gray-200">
<div className="flex h-full w-full items-center justify-center border border-gray-200 bg-gray-200">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full mx-auto mb-4" />
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
<p className="text-gray-600">Loading PDF viewer...</p>
</div>
</div>
@ -104,129 +110,238 @@ const initialInvoiceData = {
} as const satisfies InvoiceData;
export default function Home() {
const [invoiceDataParam, setInvoiceDataParam] = useQueryState("data", {
parse: (value: string) => {
try {
// Decompress the URL parameter before parsing
const decompressed = decompressFromEncodedURIComponent(value);
const router = useRouter();
const searchParams = useSearchParams();
const [invoiceDataState, setInvoiceDataState] = useState<InvoiceData | null>(
null
);
const isDesktop = useMediaQuery("(min-width: 1024px)");
// Initialize data from URL or localStorage on mount
useEffect(() => {
const urlData = searchParams.get("data");
if (urlData) {
try {
const decompressed = decompressFromEncodedURIComponent(urlData);
const parsed = JSON.parse(decompressed);
const validated = invoiceSchema.parse(parsed);
return validated;
} catch {
return null;
}
},
serialize: (value: InvoiceData | null) => {
if (!value) return "";
const stringified = JSON.stringify(value);
// Compress the data before adding to URL
const compressed = compressToEncodedURIComponent(stringified);
return compressed;
},
defaultValue: null,
});
// Initialize data from localStorage only if URL param is not present
useEffect(() => {
if (!invoiceDataParam) {
try {
const savedData = localStorage.getItem(PDF_DATA_LOCAL_STORAGE_KEY);
if (savedData) {
const parsedData = invoiceSchema.parse(JSON.parse(savedData));
setInvoiceDataParam(parsedData);
} else {
setInvoiceDataParam(initialInvoiceData);
}
setInvoiceDataState(validated);
} catch (error) {
console.error("Failed to load saved invoice data:", error);
alert("Failed to load saved invoice data");
setInvoiceDataParam(initialInvoiceData);
// fallback to local storage
console.error("Failed to parse URL data:", error);
loadFromLocalStorage();
}
} else {
// if no data in url, load from local storage
loadFromLocalStorage();
}
}, [invoiceDataParam, setInvoiceDataParam]); // Only run on mount
}, [searchParams]);
// Sync URL data to localStorage whenever URL params change
useEffect(() => {
if (invoiceDataParam) {
try {
localStorage.setItem(
PDF_DATA_LOCAL_STORAGE_KEY,
JSON.stringify(invoiceDataParam)
);
} catch (error) {
console.error("Failed to save invoice data:", error);
alert("Failed to save invoice data");
// Helper function to load from localStorage
const loadFromLocalStorage = () => {
try {
const savedData = localStorage.getItem(PDF_DATA_LOCAL_STORAGE_KEY);
if (savedData) {
const json = JSON.parse(savedData);
const parsedData = invoiceSchema.parse(json);
// const today = dayjs();
// const isInvoiceFromPreviousMonth = dayjs(
// parsedData?.dateOfIssue
// ).isBefore(today, "month");
// if (isInvoiceFromPreviousMonth) {
// parsedData.dateOfIssue = today.format("YYYY-MM-DD");
// parsedData.dateOfService = today.endOf("month").format("YYYY-MM-DD");
// parsedData.paymentDue = today.add(14, "days").format("YYYY-MM-DD");
// parsedData.invoiceNumber = `1/${today.format("MM-YYYY")}`;
// }
setInvoiceDataState(parsedData);
// setInvoiceDataState(restOfInvoiceData);
} else {
// if no data in local storage, set initial data
setInvoiceDataState(initialInvoiceData);
}
}
}, [invoiceDataParam]);
} catch (error) {
console.error("Failed to load saved invoice data:", error);
const handleInvoiceDataChange = (updatedData: InvoiceData) => {
startTransition(() => {
setInvoiceDataParam(updatedData);
});
setInvoiceDataState(initialInvoiceData);
}
};
if (!invoiceDataParam) {
// Save to localStorage whenever data changes on form update
useEffect(() => {
if (invoiceDataState) {
try {
const newInvoiceDataValidated = invoiceSchema.parse(invoiceDataState);
localStorage.setItem(
PDF_DATA_LOCAL_STORAGE_KEY,
JSON.stringify(newInvoiceDataValidated)
);
// Check if URL has data and current data is different
const urlData = searchParams.get("data");
if (urlData) {
try {
const decompressed = decompressFromEncodedURIComponent(urlData);
const urlParsed = JSON.parse(decompressed);
const urlValidated = invoiceSchema.parse(urlParsed);
if (
JSON.stringify(urlValidated) !==
JSON.stringify(newInvoiceDataValidated)
) {
toast.info(
<p>
<span className="font-semibold">Changes detected:</span> This
invoice differs from the shared link version. To share your
updated invoice, please click &apos;Generate a link to
invoice&apos; to create a new shareable link.
</p>,
{
duration: 10000,
closeButton: true,
richColors: true,
}
);
// Clean URL if data differs
router.replace("/");
}
} catch (error) {
console.error("Failed to compare with URL data:", error);
}
}
} catch (error) {
console.error("Failed to save invoice data:", error);
toast.error("Failed to save invoice data");
}
}
}, [invoiceDataState, router, searchParams]);
const handleInvoiceDataChange = (updatedData: InvoiceData) => {
setInvoiceDataState(updatedData);
};
const handleShareInvoice = async () => {
if (invoiceDataState) {
try {
const newInvoiceDataValidated = invoiceSchema.parse(invoiceDataState);
const stringified = JSON.stringify(newInvoiceDataValidated);
const compressed = compressToEncodedURIComponent(stringified);
// Use push instead of replace to maintain history
router.push(`/?data=${compressed}`);
const newFullUrl = `${window.location.origin}/?data=${compressed}`;
await navigator.clipboard.writeText(newFullUrl);
toast.success("URL copied to clipboard!");
} catch (error) {
console.error("Failed to share invoice:", error);
toast.error("Failed to generate shareable link");
}
}
};
// we only want to render the page on client side
if (!invoiceDataState) {
return null;
}
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100 p-4">
<div className="bg-white p-6 rounded-lg shadow-lg w-full max-w-7xl">
<div className="flex flex-row flex-wrap lg:flex-nowrap items-center justify-between w-full">
<h1 className="text-2xl font-bold mb-4 w-full text-center lg:text-left">
Free Invoice PDF Generator {" "}
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 sm:p-4">
<div className="w-full max-w-7xl rounded-lg bg-white p-3 shadow-lg sm:p-6">
<div className="flex w-full flex-row flex-wrap items-center justify-between lg:flex-nowrap">
<h1 className="mb-6 mt-6 w-full text-balance text-center text-xl font-bold sm:mb-4 sm:mt-0 sm:text-2xl lg:text-left">
Free Invoice PDF Generator
</h1>
<div className="mb-1 flex w-full flex-wrap justify-center gap-3 lg:flex-nowrap lg:justify-end">
<Button
onClick={handleShareInvoice}
variant="outline"
className="w-full lg:w-auto"
>
Generate a link to invoice
</Button>
{isDesktop ? (
<InvoicePDFDownloadLink invoiceData={invoiceDataState} />
) : null}
</div>
</div>
<div className="mb-4 flex flex-row items-center justify-center lg:mb-0 lg:justify-start">
<span className="relative bottom-0 text-sm text-gray-900 lg:bottom-3">
Made by{" "}
<a
href="https://dub.sh/vldzn.me"
className="underline transition-colors hover:text-blue-600"
target="_blank"
>
Vlad Sazonau
</a>
{" | "}
<a
href="https://github.com/VladSez/pdf-invoice-generator"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 hover:underline"
className="group inline-flex items-center gap-2"
title="View on GitHub"
>
Open Source <GithubIcon />
<span className="transition-all group-hover:text-blue-600 group-hover:underline">
Open Source
</span>
<GithubIcon />
</a>
</h1>
<div className="w-full flex justify-center flex-wrap lg:flex-nowrap lg:justify-end gap-3 mb-1">
<button
onClick={() => {
navigator.clipboard
.writeText(window.location.href)
.then(() => {
alert("URL copied to clipboard!");
})
.catch((err) => {
console.error("Failed to copy URL:", err);
alert("Failed to copy URL to clipboard");
});
loglib.track("copy_link_to_invoice");
}}
className="inline-flex w-full lg:w-auto justify-center items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 rounded-md"
{" | "}
<a
href="mailto:vladsazon27@gmail.com"
className="transition-colors hover:text-blue-600 hover:underline"
target="_blank"
>
Copy link to invoice
</button>
<InvoicePDFDownloadLink invoiceData={invoiceDataParam} />
</div>
Contact me
</a>
</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
<div className="lg:col-span-4">
<div className="h-[400px] lg:h-[600px] overflow-auto p-3">
<div className="h-[400px] overflow-auto p-3 lg:h-[600px]">
<InvoiceForm
invoiceData={invoiceDataParam}
invoiceData={invoiceDataState}
onInvoiceDataChange={handleInvoiceDataChange}
/>
</div>
<hr className="my-4 block w-full lg:hidden" />
<div className="flex flex-col gap-3">
<Button
type="submit"
form={PDF_DATA_FORM_ID}
variant="outline"
className="mt-2 w-full"
>
Regenerate invoice
</Button>
{/* We show the pdf download link here only on mobile/tables */}
{isDesktop ? null : (
<InvoicePDFDownloadLink invoiceData={invoiceDataState} />
)}
</div>
<hr className="my-2 block w-full lg:hidden" />
</div>
<div className="lg:col-span-8 h-[600px] lg:h-[630px] w-full max-w-full">
<div className="h-[600px] w-full max-w-full lg:col-span-8 lg:h-[630px]">
<InvoicePDFViewer>
<InvoicePdfTemplate invoiceData={invoiceDataParam} />
<InvoicePdfTemplate invoiceData={invoiceDataState} />
</InvoicePDFViewer>
</div>
</div>
@ -241,7 +356,7 @@ const GithubIcon = () => {
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
className="h-5 w-5 transition-all group-hover:fill-blue-600"
>
<title>View on GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />

View file

@ -0,0 +1,58 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-colors outline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-slate-900 text-slate-50 shadow-sm shadow-black/5 hover:bg-slate-900/90 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90",
destructive:
"bg-red-500 text-slate-50 shadow-sm shadow-black/5 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90",
outline:
"border border-gray-200 bg-white shadow shadow-black/5 hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50",
secondary:
"bg-slate-100 text-slate-900 shadow-sm shadow-black/5 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
ghost:
"hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50",
link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-lg px-3 text-xs",
lg: "h-10 rounded-lg px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View file

@ -0,0 +1,25 @@
import { cn } from "@/lib/utils";
import * as React from "react";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"h-8 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-slate-950 shadow-sm shadow-black/5 transition-shadow placeholder:text-slate-500/70 focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:placeholder:text-slate-400/70 dark:focus-visible:border-slate-300 dark:focus-visible:ring-slate-300/20",
type === "search" &&
"[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none",
type === "file" &&
"p-0 pr-3 italic text-slate-500/70 file:me-3 file:h-full file:border-0 file:border-r file:border-solid file:border-slate-200 file:bg-transparent file:px-3 file:text-sm file:font-medium file:not-italic file:text-slate-950 dark:text-slate-400/70 dark:file:border-slate-800 dark:file:text-slate-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View file

@ -0,0 +1,22 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"block text-balance text-xs font-medium text-gray-700 peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-slate-50",
className
)}
{...props}
/>
));
Label.displayName = "Label";
export { Label };

View file

@ -0,0 +1,38 @@
import { cn } from "@/lib/utils";
import { ChevronDown } from "lucide-react";
import * as React from "react";
export interface SelectPropsNative
extends React.SelectHTMLAttributes<HTMLSelectElement> {
children: React.ReactNode;
}
const SelectNative = React.forwardRef<HTMLSelectElement, SelectPropsNative>(
({ className, children, ...props }, ref) => {
return (
<div className="relative">
<select
className={cn(
"peer inline-flex w-full cursor-pointer appearance-none items-center rounded-lg border border-slate-200 bg-white text-sm text-slate-950 shadow-sm shadow-black/5 transition-shadow focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 has-[option[disabled]:checked]:text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:focus-visible:border-slate-300 dark:focus-visible:ring-slate-300/20 dark:has-[option[disabled]:checked]:text-slate-400",
props.multiple
? "py-1 [&>*]:px-3 [&>*]:py-1 [&_option:checked]:bg-slate-100 dark:[&_option:checked]:bg-slate-800"
: "h-9 pe-8 ps-3",
className
)}
ref={ref}
{...props}
>
{children}
</select>
{!props.multiple && (
<span className="pointer-events-none absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center text-slate-500/80 peer-disabled:opacity-50 dark:text-slate-400/80">
<ChevronDown size={16} strokeWidth={2} aria-hidden="true" />
</span>
)}
</div>
);
}
);
SelectNative.displayName = "SelectNative";
export { SelectNative };

View file

@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-slate-950 shadow-sm shadow-black/5 transition-shadow placeholder:text-slate-500/70 focus-visible:border-indigo-500 focus-visible:ring focus-visible:ring-indigo-200 focus-visible:ring-opacity-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 dark:placeholder:text-slate-400/70 dark:focus-visible:border-slate-300 dark:focus-visible:ring-slate-300/20",
className
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };

View file

@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from "react";
export interface UseMediaQueryOptions {
getInitialValueInEffect: boolean;
}
type MediaQueryCallback = (event: { matches: boolean; media: string }) => void;
/**
* Older versions of Safari (shipped withCatalina and before) do not support addEventListener on matchMedia
* https://stackoverflow.com/questions/56466261/matchmedia-addlistener-marked-as-deprecated-addeventlistener-equivalent
* */
function attachMediaListener(
query: MediaQueryList,
callback: MediaQueryCallback
) {
try {
query.addEventListener("change", callback);
return () => query.removeEventListener("change", callback);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e: unknown) {
query.addListener(callback);
return () => query.removeListener(callback);
}
}
function getInitialValue(query: string, initialValue?: boolean) {
if (typeof initialValue === "boolean") {
return initialValue;
}
if (typeof window !== "undefined" && "matchMedia" in window) {
return window.matchMedia(query).matches;
}
return false;
}
export function useMediaQuery(
query: string,
initialValue?: boolean,
{ getInitialValueInEffect }: UseMediaQueryOptions = {
getInitialValueInEffect: true,
}
) {
const [matches, setMatches] = useState(
getInitialValueInEffect ? initialValue : getInitialValue(query)
);
const queryRef = useRef<MediaQueryList | null>(null);
useEffect(() => {
if ("matchMedia" in window) {
queryRef.current = window.matchMedia(query);
setMatches(queryRef.current.matches);
return attachMediaListener(queryRef.current, (event) =>
setMatches(event.matches)
);
}
return undefined;
}, [query]);
return matches;
}

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View file

@ -1,6 +1,7 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
@ -12,8 +13,13 @@ const config: Config = {
background: "var(--background)",
foreground: "var(--foreground)",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
plugins: [require("tailwindcss-animate")],
};
export default config;