From bb979f2023aa12d3844fc90d80be3f2dcd71fb78 Mon Sep 17 00:00:00 2001 From: Ravi Teja KNTS Date: Tue, 13 Oct 2026 14:45:19 +0530 Subject: [PATCH] Add ISBN barcode generator and KDP series read-through calculator Two new KDP Tools pages: - ISBN barcode generator: checks an ISBN-10 or ISBN-13 and builds a print-ready EAN-13 barcode with the EAN-5 price add-on, as SVG, PDF (100% black only) and 1-bit 600 ppi PNG. Own encoder with OCR-B outlines from CTAN; zxing and bwip-js are test-only dev dependencies. - KDP series read-through calculator: read-through at each step, what one new book 1 reader earns across the series, and the break-even ad cost, with Kindle Unlimited, Countdown Deals and free book 1. Shared: UiDownloadAction gains isDisabled and lazy contents; UiRepeatableList gains minItems (defaults to 0). Card art, catalog rows and page registry entries for both tools. Co-Authored-By: Claude Opus 5.5 --- .../[slug]/content/kdp-tools/article.mdx | 2 + .../examples/actions-and-forms.tsx | 6 +- .../content/article.mdx | 106 +++ .../isbn-barcode-generator/content/intro.mdx | 1 + .../isbn-barcode-generator/content/usage.mdx | 23 + .../isbn-barcode-generator-from-url.tsx | 12 + .../isbn-barcode-generator.module.css | 174 +++++ .../isbn-barcode-generator.tsx | 411 +++++++++++ .../(tools)/isbn-barcode-generator/layout.tsx | 6 + .../(tools)/isbn-barcode-generator/page.tsx | 25 + .../content/article.mdx | 140 ++++ .../content/intro.mdx | 3 + .../content/usage.mdx | 39 ++ .../series-read-through-calculator/layout.tsx | 6 + .../series-read-through-calculator/page.tsx | 25 + .../series-ladder.tsx | 99 +++ ...eries-read-through-calculator-from-url.tsx | 12 + .../series-read-through-calculator.module.css | 349 ++++++++++ .../series-read-through-calculator.tsx | 648 ++++++++++++++++++ app/ui/art/isbn-barcode-generator.spec.json | 11 + app/ui/art/isbn-barcode-generator.svg | 37 + app/ui/art/isbn-barcode-generator.ts | 39 ++ .../series-read-through-calculator.spec.json | 11 + app/ui/art/series-read-through-calculator.svg | 19 + app/ui/art/series-read-through-calculator.ts | 21 + app/ui/controls.tsx | 15 +- .../isbn-barcode-generator/research.md | 278 ++++++++ .../isbn-barcode-generator/tool-spec.md | 455 ++++++++++++ .../research.md | 397 +++++++++++ .../tool-spec.md | 471 +++++++++++++ lib/card-art.ts | 4 + lib/card-icons.ts | 3 +- lib/isbn-barcode-generator/encoding.ts | 61 ++ lib/isbn-barcode-generator/engine.ts | 271 ++++++++ lib/isbn-barcode-generator/files.ts | 78 +++ lib/isbn-barcode-generator/ocr-b-glyphs.ts | 32 + lib/isbn-barcode-generator/png.ts | 291 ++++++++ lib/isbn-barcode-generator/symbol.ts | 196 ++++++ lib/kdp/series-read-through.ts | 530 ++++++++++++++ lib/registered-site-pages.json | 2 + package-lock.json | 46 ++ package.json | 2 + scripts/stack127/marker-art/figures.mjs | 40 ++ .../isbn-barcode-generator.test.mjs | 269 ++++++++ .../series-read-through.test.mjs | 211 ++++++ 45 files changed, 5871 insertions(+), 6 deletions(-) create mode 100644 app/(site)/(tools)/isbn-barcode-generator/content/article.mdx create mode 100644 app/(site)/(tools)/isbn-barcode-generator/content/intro.mdx create mode 100644 app/(site)/(tools)/isbn-barcode-generator/content/usage.mdx create mode 100644 app/(site)/(tools)/isbn-barcode-generator/isbn-barcode-generator-from-url.tsx create mode 100644 app/(site)/(tools)/isbn-barcode-generator/isbn-barcode-generator.module.css create mode 100644 app/(site)/(tools)/isbn-barcode-generator/isbn-barcode-generator.tsx create mode 100644 app/(site)/(tools)/isbn-barcode-generator/layout.tsx create mode 100644 app/(site)/(tools)/isbn-barcode-generator/page.tsx create mode 100644 app/(site)/(tools)/series-read-through-calculator/content/article.mdx create mode 100644 app/(site)/(tools)/series-read-through-calculator/content/intro.mdx create mode 100644 app/(site)/(tools)/series-read-through-calculator/content/usage.mdx create mode 100644 app/(site)/(tools)/series-read-through-calculator/layout.tsx create mode 100644 app/(site)/(tools)/series-read-through-calculator/page.tsx create mode 100644 app/(site)/(tools)/series-read-through-calculator/series-ladder.tsx create mode 100644 app/(site)/(tools)/series-read-through-calculator/series-read-through-calculator-from-url.tsx create mode 100644 app/(site)/(tools)/series-read-through-calculator/series-read-through-calculator.module.css create mode 100644 app/(site)/(tools)/series-read-through-calculator/series-read-through-calculator.tsx create mode 100644 app/ui/art/isbn-barcode-generator.spec.json create mode 100644 app/ui/art/isbn-barcode-generator.svg create mode 100644 app/ui/art/isbn-barcode-generator.ts create mode 100644 app/ui/art/series-read-through-calculator.spec.json create mode 100644 app/ui/art/series-read-through-calculator.svg create mode 100644 app/ui/art/series-read-through-calculator.ts create mode 100644 content_docs/isbn-barcode-generator/research.md create mode 100644 content_docs/isbn-barcode-generator/tool-spec.md create mode 100644 content_docs/series-read-through-calculator/research.md create mode 100644 content_docs/series-read-through-calculator/tool-spec.md create mode 100644 lib/isbn-barcode-generator/encoding.ts create mode 100644 lib/isbn-barcode-generator/engine.ts create mode 100644 lib/isbn-barcode-generator/files.ts create mode 100644 lib/isbn-barcode-generator/ocr-b-glyphs.ts create mode 100644 lib/isbn-barcode-generator/png.ts create mode 100644 lib/isbn-barcode-generator/symbol.ts create mode 100644 lib/kdp/series-read-through.ts create mode 100644 tests/pages/isbn-barcode-generator/isbn-barcode-generator.test.mjs create mode 100644 tests/pages/series-read-through-calculator/series-read-through.test.mjs diff --git a/app/(site)/(catalogs)/[slug]/content/kdp-tools/article.mdx b/app/(site)/(catalogs)/[slug]/content/kdp-tools/article.mdx index 7a557747..13fedf86 100644 --- a/app/(site)/(catalogs)/[slug]/content/kdp-tools/article.mdx +++ b/app/(site)/(catalogs)/[slug]/content/kdp-tools/article.mdx @@ -6,6 +6,7 @@ Find the step you're on. | --- | --- | | Estimating how many pages your print book will have | [KDP page count estimator](/kdp-page-count-estimator) | | Sizing the cover, spine, page, and margins | [KDP book setup calculator](/kdp-book-setup-calculator) | +| Checking your ISBN and making the cover barcode | [ISBN barcode generator](/isbn-barcode-generator) | | Checking that an image is sharp enough to print | [KDP image DPI checker](/kdp-image-dpi-checker), [pixels to print size](/pixels-to-print-size-calculator) | | Checking an interior or cover PDF before upload | [KDP PDF preflight checker](/kdp-pdf-preflight-checker) | | Writing the title, description, and keywords | [KDP book title analyzer](/kdp-book-title-analyzer), [KDP description formatter](/kdp-book-description-formatter), [KDP keywords builder](/kdp-backend-keywords-builder) | @@ -13,5 +14,6 @@ Find the step you're on. | Choosing a list price | [KDP minimum list price](/kdp-minimum-list-price-calculator), [KDP print price and profit](/kdp-price-profit-calculator) | | Working out what you earn per sale | [KDP royalty calculator](/kdp-royalty-calculator) | | Setting a bid for Amazon ads | [KDP book ads bid check](/kdp-break-even-acos-calculator) | +| Working out what one new reader is worth across a series | [KDP series read-through calculator](/series-read-through-calculator) | | Estimating Kindle Unlimited earnings | [Kindle Unlimited KENP earnings](/kindle-unlimited-kenp-earnings-calculator) | | Finding out when KDP will pay you | [KDP payment date calculator](/kdp-payment-date-calculator) | diff --git a/app/(site)/(components-ui)/components-ui/examples/actions-and-forms.tsx b/app/(site)/(components-ui)/components-ui/examples/actions-and-forms.tsx index 7fc3ec19..a6619d97 100644 --- a/app/(site)/(components-ui)/components-ui/examples/actions-and-forms.tsx +++ b/app/(site)/(components-ui)/components-ui/examples/actions-and-forms.tsx @@ -86,18 +86,20 @@ export function ActionsAndFormsExamples() { - + + - + setDrives((current) => [...current, { id: `drive-${Date.now()}`, capacity: 8, unit: "TB" }])} onRemove={(id) => setDrives((current) => current.filter((drive) => drive.id !== id))} removeLabel={(_, index) => `Remove drive ${index + 1}`} diff --git a/app/(site)/(tools)/isbn-barcode-generator/content/article.mdx b/app/(site)/(tools)/isbn-barcode-generator/content/article.mdx new file mode 100644 index 00000000..3356c432 --- /dev/null +++ b/app/(site)/(tools)/isbn-barcode-generator/content/article.mdx @@ -0,0 +1,106 @@ +## Do I need my own barcode? + +Not always. If your KDP cover has no barcode, KDP adds one for you in a 2 × 1.2 in box on the back cover. You just have to leave that box empty. KDP rejects covers with text or images where the barcode goes ([KDP Barcodes](https://kdp.amazon.com/en_US/help/topic/G5HDYGP4BXLX4RUW)). IngramSpark's cover template comes with a barcode for your ISBN already on it. You can move that barcode, but you can't resize it ([IngramSpark File Creation Guide](https://www.ingramspark.com/hubfs/downloads/file-creation-guide.pdf)). + +Your own barcode helps when: + +- You or your designer build the whole cover and want to place the barcode yourself. +- You want a price printed in the barcode. +- You print with someone who doesn't add a barcode for you. + +KDP takes the cover as one PDF with the back, spine, and front together. A barcode you add has to be inside that PDF, because there's no separate barcode upload ([KDP Create a Paperback Cover](https://kdp.amazon.com/help?topicId=G201953020)). + +## What does the five-digit price code mean? + +The small barcode to the right is the price add-on. In the US, its first digit is 5, which means US dollars. The other four digits are the price in cents. So 51499 means $14.99 ([BISG barcoding guidelines](https://www.bisg.org/barcoding-guidelines-for-the-us-book-industry)). + +| List price | Price code | +| --- | --- | +| No price | 90000 | +| $0.01 | 50001 | +| $14.99 | 51499 | +| $24.95 | 52495 | +| $99.98 | 59998 | +| $99.99 or more | Can't be printed, so the tool uses 90000 | + +90000 means "no price here." It tells stores to ignore the barcode for pricing. IngramSpark uses it when you don't set a price ([IngramSpark User Guide](https://www.ingramspark.com/hubfs/downloads/user-guide.pdf)). + +If you do print a price, KDP asks that it match your list price ([KDP Barcodes](https://kdp.amazon.com/en_US/help/topic/G5HDYGP4BXLX4RUW)). So whenever you change the price, you'd need a new cover. Printing 90000 avoids that. If you're still settling on a price, the [KDP royalty calculator](/kdp-royalty-calculator) shows what each one pays. + +## ISBN-10 or ISBN-13: which goes on the barcode? + +Always the ISBN-13. Every ISBN issued since January 1, 2007 has 13 digits ([ISBN International](https://www.isbn-international.org/content/what-isbn/10)). An older ISBN-10 still works, because it turns into an ISBN-13: put 978 in front, keep the first nine digits, and work out a new last digit. The tool does this for you, so 0-306-40615-2 becomes 978-0-306-40615-7. + +It only goes one way for numbers that start with 979. A 979 ISBN has no ISBN-10 form, and converting one anyway creates duplicate numbers and order mix-ups ([ISBN International, US prefixes](https://www.isbn-international.org/content/changes-united-states-isbn-prefixes)). Numbers that start with 979-0 aren't ISBNs at all. They're ISMNs, the numbers for printed music. + +Each format needs its own ISBN, so your paperback and hardcover each get their own barcode. An eBook on KDP doesn't need an ISBN ([KDP Get an ISBN](https://kdp.amazon.com/en_US/help/topic/GTJ8LBXL6Z4WV5QX)). A free KDP ISBN covers your paperback and hardcover, but only on KDP, and you can't use it with IngramSpark. + +## Why does the tool say my ISBN isn't valid? + +The last digit of every ISBN is a check digit. It's worked out from the other digits, so one typo or two swapped digits almost always make it wrong. When the check fails, the tool doesn't guess a fix, because the mistake could be in any digit. Copy the ISBN again from your ISBN record. + + + +Multiply the first 12 digits by 1, 3, 1, 3, and so on, and add them up. The check digit is whatever brings the total up to the next multiple of 10, or 0 if it's already one. + +For 978-0-11-000222-?, the total is 56, so the check digit is 4 ([ISBN Users' Manual, appendix 1](https://www.isbn-international.org/sites/default/files/ISBN%20Manual%202012%20-corr.pdf)). + + + +The tool also catches a few other mix-ups: the wrong number of digits, a price code pasted onto the end, a store product barcode that doesn't start with 978 or 979, and music ISMNs. + +## How big should a book barcode be? + +The standard book barcode, called 100%, is 1 in high and 2-3/16 in wide, price add-on included. It can be scaled from 80% to 200% ([BISG](https://www.bisg.org/barcoding-guidelines-for-the-us-book-industry)). Each printer also asks for a clear area on the back cover: + +| Area | Size | +| --- | --- | +| KDP's suggested barcode box | 2 × 1.2 in | +| IngramSpark's clear area | 1.75 × 1 in | + +KDP also asks you to keep the barcode at least 0.25 in from the spine and the trim edges. On a hardcover, keep it at least 0.76 in from the bottom edge ([KDP Create a Hardcover Cover](https://kdp.amazon.com/en_US/help/topic/GDTKFJPNQCBTMRV6)). The [KDP book setup calculator](/kdp-book-setup-calculator) gives your full cover size. + +This tool's printed size is the whole white box: the bars, the clear space scanners need on both sides, and the "ISBN" line on top. With the price add-on, that's 2.20 × 1.21 in at 100% and 1.99 × 1.10 in at 90%. That's why the tool picks 90% when you leave Size empty: it's the largest size that fits KDP's box. + +## Why won't my barcode fit IngramSpark's area? + +IngramSpark's area is 1.75 in wide. Two parts of a barcode set its width, and each one alone is too wide: + +- The bars with their clear space. With the price add-on, they need 1.78 in even at 80%, the smallest size the standard allows. +- The "ISBN" line on top. BISG asks for text of at least 9 point ([BISG](https://www.bisg.org/barcoding-guidelines-for-the-us-book-industry)), so the line doesn't shrink with the bars. With dashes, the line and its margins need about 1.93 in. Without dashes, about 1.61 in. + +So a barcode fits IngramSpark's area only when you leave off the price add-on, type the ISBN without dashes, and set the size to 81% or smaller. The tool shows this as "Fits at 81% or smaller." + +For most IngramSpark books, the simpler path is to keep the template barcode IngramSpark gives you. IngramSpark may also replace a barcode it can't scan, without asking ([IngramSpark User Guide](https://www.ingramspark.com/hubfs/downloads/user-guide.pdf)). + +## How do I put the barcode on my cover? + +Pick the file for your design app, then place it at the printed size the tool shows. + +| App | File | How | +| --- | --- | --- | +| InDesign | PDF | File > Place. A PDF keeps its printed size, so it lands at the right size. | +| Affinity | PDF | File > Place. Check the width against Printed size. | +| Canva | SVG | Upload it, add it to your back cover, then set the width to Printed size. Canva doesn't always keep a file's printed size. | +| Anything else | PNG | Use it when your app won't take PDF or SVG. It's 600 ppi, and it records that, so apps that read it place it at the right size. | + +Whichever app you use: + +- Don't resize it in your app. To get a different size, change Size in the tool and download again. +- Keep it 100% black on its white box. KDP and IngramSpark both ask for this ([KDP Barcodes](https://kdp.amazon.com/en_US/help/topic/G5HDYGP4BXLX4RUW), [IngramSpark File Creation Guide](https://www.ingramspark.com/hubfs/downloads/file-creation-guide.pdf)). The PDF uses black ink only. Don't convert it to rich black, which mixes all four inks. Some apps make that change when they export a print PDF in CMYK, so check the barcode in your exported cover before you upload it. +- Don't add a border, and don't let anything overlap the white box. + +Before you upload your cover, the [KDP PDF preflight checker](/kdp-pdf-preflight-checker) can check its size and bleed. + + + +The bars follow EAN-13, the barcode standard book scanners read, and the tool's tests read its barcodes back with a separate barcode reader. Still, no tool can promise how your printer prints it. Order a proof and scan the barcode with a phone app before you order more copies. + + + +## What doesn't this tool do? + +- It doesn't sell, register, or look up ISBNs, so it can't tell you if a number is assigned to your book. Get ISBNs from your country's ISBN agency, such as Bowker in the US, or a free one from KDP. +- It doesn't place dashes for you. It keeps the dashes you type, without checking where they go. +- It only prints prices in US dollars. Other currencies and special codes, such as used-book codes, aren't supported. +- It makes ISBN barcodes only, not UPC or QR codes. diff --git a/app/(site)/(tools)/isbn-barcode-generator/content/intro.mdx b/app/(site)/(tools)/isbn-barcode-generator/content/intro.mdx new file mode 100644 index 00000000..3953b268 --- /dev/null +++ b/app/(site)/(tools)/isbn-barcode-generator/content/intro.mdx @@ -0,0 +1 @@ +Get the barcode for the back cover of your paperback or hardcover, ready to drop into Canva, Affinity, or InDesign. Type your ISBN and list price. The tool checks the ISBN first, and if a digit is off, it tells you what's wrong. Everything runs in your browser. diff --git a/app/(site)/(tools)/isbn-barcode-generator/content/usage.mdx b/app/(site)/(tools)/isbn-barcode-generator/content/usage.mdx new file mode 100644 index 00000000..798edffd --- /dev/null +++ b/app/(site)/(tools)/isbn-barcode-generator/content/usage.mdx @@ -0,0 +1,23 @@ +## What to enter + +- ISBN: copy it from your ISBN agency's record or from KDP. Ten or thirteen digits both work, with or without dashes. If you type the dashes, the "ISBN" line above the bars keeps them. Without dashes, that line prints the 13 digits in a row. +- List price: the US price you set for this edition. It becomes the five-digit price code to the right of the bars. Leave it empty if you'd rather not print a price. +- Add-on and size: you can usually skip these. Leave off the price add-on if you sell outside the US and Canada, where book barcodes usually don't carry one. Set a size only if your designer or printer asked for one. Empty picks the largest size, up to 100%, that fits KDP's barcode box. + +## How to read the result + +| Result | What it tells you | +| --- | --- | +| Barcode | What will print on your cover. Check that the ISBN and price digits are right. | +| Valid ISBN | Your ISBN passed every check. It shows the ISBN-13 that's in the barcode, even if you typed an ISBN-10. | +| Price code | The five digits in the small barcode, and the price they stand for. | +| Size and Printed size | How large the barcode is compared with the standard, and how much room it takes on the cover, clear space included. | +| KDP box and IngramSpark area | Whether the barcode fits each printer's barcode area. If it doesn't, you'll see the largest size that does. | + +Then download the file you need. If you change anything, press Make barcode again. The downloads stay off until you do, so you can't save an old barcode by mistake. + + + +Type 978-0-306-40615-7 and 14.99. The price becomes the code 51499: a 5 for US dollars, then 1499 cents. The barcode comes out at 90%, or 1.99 × 1.10 in, which is the largest size that fits KDP's 2 × 1.2 in box. It doesn't fit IngramSpark's area at any size. The article below explains why. + + diff --git a/app/(site)/(tools)/isbn-barcode-generator/isbn-barcode-generator-from-url.tsx b/app/(site)/(tools)/isbn-barcode-generator/isbn-barcode-generator-from-url.tsx new file mode 100644 index 00000000..8db97b3d --- /dev/null +++ b/app/(site)/(tools)/isbn-barcode-generator/isbn-barcode-generator-from-url.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { useInitialQuery } from "@/app/ui/tools/use-initial-query"; +import { IsbnBarcodeGenerator, inputFromQuery, isSharedLink } from "./isbn-barcode-generator"; + +// Reads a shared link in the browser so the page stays static. A clean URL, or one with only +// other parameters such as tracking tags, opens on the example and leaves the address alone. +export function IsbnBarcodeGeneratorFromUrl() { + const query = useInitialQuery(); + const shared = isSharedLink(query.get); + return ; +} diff --git a/app/(site)/(tools)/isbn-barcode-generator/isbn-barcode-generator.module.css b/app/(site)/(tools)/isbn-barcode-generator/isbn-barcode-generator.module.css new file mode 100644 index 00000000..e737ca61 --- /dev/null +++ b/app/(site)/(tools)/isbn-barcode-generator/isbn-barcode-generator.module.css @@ -0,0 +1,174 @@ +.form { + display: grid; + gap: var(--space-6); + min-width: 0; +} + +/* The ISBN gets the wider share; the price is a short field. Nested under .form so it wins over the shared row's auto-fit columns. */ +.form .basicRow { + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); +} + +.form .pairRow { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.advanced { + display: grid; + gap: var(--space-6); + min-width: 0; + padding-top: var(--space-5); +} + +/* The barcode on the left, where the eye lands, and what it encodes beside it. */ +.top { + align-items: start; + display: grid; + gap: var(--space-8); + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + min-width: 0; +} + +/* A barcode always prints black on white, so the tile stays white in both themes. */ +.tile { + align-items: center; + background: #fff; + border: 1px solid var(--color-border); + border-radius: var(--radius); + display: flex; + justify-content: center; + min-width: 0; + padding: var(--space-6); +} + +.barcode { + display: block; + height: auto; + max-width: 100%; +} + +.facts { + display: grid; + gap: var(--space-2); + min-width: 0; +} + +/* An ISBN never breaks across lines, so it reads as one number. */ +.isbn { + font-family: var(--font-mono); + font-size: var(--text-h2); + letter-spacing: 0; + white-space: nowrap; +} + +.detail { + display: flex; + flex-wrap: wrap; + gap: var(--space-2) var(--space-4); +} + +.status { + align-items: center; + display: inline-flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} + +.fit { + align-items: center; + display: grid; + gap: var(--space-8); + grid-template-columns: minmax(0, 18rem) minmax(0, 1fr); + min-width: 0; +} + +.drawing { + display: grid; + gap: var(--space-3); + margin: 0; + max-width: 20rem; + min-width: 0; +} + +.drawing svg { + display: block; + height: auto; + overflow: visible; + width: 100%; +} + +.drawingBarcode, +.drawingKdp, +.drawingIngram { + stroke-width: 1.5px; + vector-effect: non-scaling-stroke; +} + +.drawingBarcode { + fill: color-mix(in srgb, var(--color-data-chartreuse) 30%, transparent); + /* Accent text is dark green on the light theme, so the outline stays visible on cream. */ + stroke: var(--color-accent-text); +} + +.drawingKdp { + fill: none; + stroke: var(--color-text); +} + +.drawingIngram { + fill: none; + stroke: var(--color-text-muted); + stroke-dasharray: 4 3; +} + +.legend { + color: var(--color-text-muted); + display: flex; + flex-wrap: wrap; + font-size: var(--text-small); + gap: var(--space-2) var(--space-3); +} + +.legend span { + align-items: center; + display: inline-flex; + gap: var(--space-2); +} + +.legend i { + display: inline-block; + height: 0.75rem; + width: 1rem; +} + +.keyBarcode { + background: color-mix(in srgb, var(--color-data-chartreuse) 30%, transparent); + border: 1.5px solid var(--color-accent-text); +} + +.keyKdp { + border: 1.5px solid var(--color-text); +} + +.keyIngram { + border: 1.5px dashed var(--color-text-muted); +} + +@media (max-width: 720px) { + .form .basicRow, + .form .pairRow, + .top, + .fit { + grid-template-columns: minmax(0, 1fr); + } + + .tile { + padding: var(--space-4); + } +} diff --git a/app/(site)/(tools)/isbn-barcode-generator/isbn-barcode-generator.tsx b/app/(site)/(tools)/isbn-barcode-generator/isbn-barcode-generator.tsx new file mode 100644 index 00000000..787b3172 --- /dev/null +++ b/app/(site)/(tools)/isbn-barcode-generator/isbn-barcode-generator.tsx @@ -0,0 +1,411 @@ +"use client"; + +import { RotateCcw } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode, type RefObject } from "react"; +import { + UiButton, + UiChoiceGroup, + UiDisclosure, + UiDownloadAction, + UiShareAction, + UiTextField, + uiOption, + uiSetSummary, + type UiShareParams, +} from "@/app/ui/controls"; +import { UiActionRow, UiMetricGroup, UiPrimaryResult, UiStatus, UiStatusWord, type UiMetric } from "@/app/ui/display"; +import { ToolFieldRow, ToolResult, ToolWorkbench } from "@/app/ui/tools/tool-workbench"; +import { + ISBN_BARCODE_EXAMPLE_INPUT, + makeIsbnBarcode, + renderIsbnBarcodePdf, + renderIsbnBarcodePng, + type IsbnBarcode, + type IsbnBarcodeError, + type IsbnBarcodeField, + type IsbnBarcodeInput, +} from "@/lib/isbn-barcode-generator/engine.ts"; +import styles from "./isbn-barcode-generator.module.css"; + +type Input = IsbnBarcodeInput; +type Errors = Partial>; +type Shown = { input: Input; barcode: IsbnBarcode }; + +const EXAMPLE: Input = ISBN_BARCODE_EXAMPLE_INPUT; +const focusOrder: IsbnBarcodeField[] = ["isbn", "price", "size"]; +const addOnOptions = [uiOption("include", "Include"), uiOption("none", "Leave off")]; + +/* ---------------- words ---------------- */ + +function errorText(error: IsbnBarcodeError): string { + if (error.field === "isbn") { + switch (error.code) { + case "empty": return "Enter your ISBN."; + case "characters": return "Use digits only. An ISBN-10 can end in X."; + case "x-position": return "X can only be the last character of an ISBN-10."; + case "length": return `An ISBN has 10 or 13 digits. This has ${error.digitCount ?? "a different number"}.`; + case "includes-add-on": return "This ends with a price code. Put the price in List price."; + case "prefix": return "An ISBN-13 starts with 978 or 979. This looks like a product barcode."; + case "ismn": return "Numbers starting 979-0 are for printed music, not books."; + case "check-digit": return error.form === "isbn10" + ? "The last character doesn't match the first 9 digits. Look for a typo or two swapped digits." + : "The last digit doesn't match the first 12. Look for a typo or two swapped digits."; + } + } + if (error.field === "price") { + switch (error.code) { + case "format": return "Enter a price like 14.99, or leave it empty."; + case "zero": return "$0 can't be printed. Leave it empty for no price."; + case "code": return "Price codes run from 50001 to 59998, or 90000 for no price."; + case "without-add-on": return "Clear the price, or include the add-on."; + } + } + return "Enter a whole number from 80 to 200."; +} + +const usd = new Intl.NumberFormat("en-US", { currency: "USD", style: "currency" }); +const MM_PER_IN = 25.4; + +function inches(mm: number) { + return (mm / MM_PER_IN).toFixed(2); +} + +function areaName(area: "kdp" | "ingramspark") { + return area === "kdp" ? "KDP" : "IngramSpark"; +} + +/* ---------------- inputs ---------------- */ + +/** The parameters a shared link carries. Anything else in the address, such as tracking tags, is ignored. */ +const shareParamNames = ["isbn", "price", "addon", "size"] as const; + +export function isSharedLink(get: (name: string) => string): boolean { + return shareParamNames.some((name) => get(name) !== ""); +} + +export function inputFromQuery(get: (name: string) => string): Input { + return { + isbnText: get("isbn"), + priceText: get("price"), + addOn: get("addon") === "none" ? "none" : "", + sizeText: get("size"), + }; +} + +function shareParams(input: Input): UiShareParams { + return { + isbn: input.isbnText.trim(), + price: input.priceText.trim(), + addon: input.addOn === "none" ? "none" : null, + size: input.sizeText.trim(), + }; +} + +function advancedCount(input: Input) { + return [input.addOn === "none", input.sizeText.trim() !== ""].filter(Boolean).length; +} + +function errorsFrom(list: readonly IsbnBarcodeError[]): Errors { + const errors: Errors = {}; + for (const error of list) errors[error.field] ??= errorText(error); + return errors; +} + +/** Advanced opens for a size error, and for a price the Leave off choice rejects, so the cause is visible. */ +function needsAdvanced(list: readonly IsbnBarcodeError[]) { + return list.some((error) => error.field === "size" || (error.field === "price" && error.code === "without-add-on")); +} + +function firstError(errors: Errors): IsbnBarcodeField | null { + return focusOrder.find((field) => field in errors) ?? null; +} + +/* ---------------- the tool ---------------- */ + +export function IsbnBarcodeGenerator({ initialInput }: { initialInput?: Input }) { + const shared = initialInput !== undefined; + const [first] = useState(() => { + const example = makeIsbnBarcode(EXAMPLE); + if (example.status !== "ready") throw new Error("The ISBN barcode example must make a barcode."); + const exampleShown: Shown = { input: EXAMPLE, barcode: example.value }; + if (!initialInput) return { input: EXAMPLE, shown: exampleShown, errors: [] as readonly IsbnBarcodeError[], stale: false }; + const result = makeIsbnBarcode(initialInput); + return result.status === "ready" + ? { input: initialInput, shown: { input: initialInput, barcode: result.value }, errors: [] as readonly IsbnBarcodeError[], stale: false } + : { input: initialInput, shown: exampleShown, errors: result.errors, stale: true }; + }); + const [input, setInput] = useState(first.input); + const [shown, setShown] = useState(first.shown); + const [errors, setErrors] = useState(() => errorsFrom(first.errors)); + const [isExample, setIsExample] = useState(!shared || first.stale); + const [isStale, setIsStale] = useState(first.stale); + const [advancedOpen, setAdvancedOpen] = useState(needsAdvanced(first.errors)); + const [focusTarget, setFocusTarget] = useState(shared ? firstError(errorsFrom(first.errors)) : null); + const formRef = useRef(null); + const answerRef = useRef(null); + + // Focus waits for a render, so a field inside Advanced exists before it gets focus. + useEffect(() => { + if (!focusTarget) return; + if (focusTarget === "answer") answerRef.current?.focus(); + else formRef.current?.querySelector(`[data-focus-key="${focusTarget}"]`)?.focus(); + setFocusTarget(null); + }, [focusTarget]); + + function edit(next: Input, fields: IsbnBarcodeField[]) { + setInput(next); + setIsStale(true); + if (fields.some((field) => field in errors)) { + setErrors((current) => { + const rest = { ...current }; + for (const field of fields) delete rest[field]; + return rest; + }); + } + } + + function submit(event?: FormEvent) { + event?.preventDefault(); + const result = makeIsbnBarcode(input); + const list = result.status === "invalid" ? result.errors : []; + const nextErrors = errorsFrom(list); + setErrors(nextErrors); + if (result.status === "ready") { + setShown({ input, barcode: result.value }); + setIsExample(false); + setIsStale(false); + } + if (needsAdvanced(list)) setAdvancedOpen(true); + setFocusTarget(firstError(nextErrors) ?? "answer"); + } + + function reset() { + const example = makeIsbnBarcode(EXAMPLE); + setInput(EXAMPLE); + if (example.status === "ready") setShown({ input: EXAMPLE, barcode: example.value }); + setErrors({}); + setIsExample(true); + setIsStale(false); + setAdvancedOpen(false); + } + + return ( + +
+ + edit({ ...input, isbnText: value }, ["isbn"])} + required + spellCheck={false} + value={input.isbnText} + /> + edit({ ...input, priceText: value }, ["price"])} + suffix="USD" + value={input.priceText} + /> + + + +
+ + edit({ ...input, addOn: value === "none" ? "none" : "" }, ["price"])} + options={addOnOptions} + value={input.addOn === "none" ? "none" : "include"} + /> + edit({ ...input, sizeText: value }, ["size"])} + suffix="%" + value={input.sizeText} + /> + +
+
+ + + {isStale ? Inputs changed : null} + Make barcode + + + +
+ + + + +
+ ); +} + +/* ---------------- the result ---------------- */ + +function statusWords(barcode: IsbnBarcode, isExample: boolean): ReactNode[] { + const words: string[] = []; + if (isExample) words.push("Example"); + if (barcode.addOn?.limit === "above-max") words.push("No price printed"); + if (barcode.size.limit === "below-min") words.push("Smallest size BISG allows"); + if (barcode.size.limit === "above-max") words.push("Largest size BISG allows"); + return words.map((word) => {word}); +} + +function detailsFor(barcode: IsbnBarcode): UiMetric[] { + const { isbn, addOn, size, layout } = barcode; + const items: UiMetric[] = []; + if (isbn.entered === "isbn13") { + items.push(isbn.printedIsbn10 + ? { id: "isbn10", label: "ISBN-10", value: isbn.printedIsbn10 } + : { id: "isbn10", label: "ISBN-10", hint: "ISBNs starting with 979 have no ISBN-10 form.", value: "None" }); + } + if (addOn) { + const unit = addOn.priceCents !== null + ? usd.format(addOn.priceCents / 100) + : addOn.limit === "above-max" ? "No price, over $99.98" : "No price"; + items.push({ id: "price", label: "Price code", hint: "The five digits above the small barcode on the right.", unit, value: addOn.code }); + } + items.push({ + id: "size", + label: "Size", + hint: size.mode === "fit-kdp" ? "The largest size, up to 100%, that fits KDP's box." : "100% is the standard book barcode size.", + value: `${size.percent}%`, + }); + items.push({ + id: "printed", + label: "Printed size", + hint: "The whole white box, including the clear space scanners need.", + value: `${inches(layout.widthMm)} × ${inches(layout.heightMm)} in`, + }); + return items; +} + +function fitValue(fit: IsbnBarcode["fit"][number]) { + if (fit.fits) return "Fits"; + if (fit.largestFitPercent !== null) return `Fits at ${fit.largestFitPercent}% or smaller`; + return "No size fits"; +} + +function fitItems(barcode: IsbnBarcode): UiMetric[] { + return barcode.fit.map((fit) => ({ + id: fit.area, + label: `${areaName(fit.area)} ${fit.area === "kdp" ? "box" : "area"}, ${inches(fit.widthMm).replace(/\.?0+$/, "")} × ${inches(fit.heightMm).replace(/\.?0+$/, "")} in`, + hint: fit.area === "kdp" + ? "The barcode area KDP suggests on the back cover." + : "The clear area IngramSpark asks for on the back cover.", + value: fitValue(fit), + })); +} + +function ResultView({ + answerRef, + isExample, + isStale, + shown, +}: { + answerRef: RefObject; + isExample: boolean; + isStale: boolean; + shown: Shown; +}) { + const { input, barcode } = shown; + const { isbn } = barcode; + const words = statusWords(barcode, isExample); + const imageSrc = useMemo(() => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(barcode.svg)}`, [barcode.svg]); + const alt = `Barcode for ISBN ${isbn.printedIsbn13}${barcode.addOn ? ` with price code ${barcode.addOn.code}` : ""}`; + const name = barcode.fileBaseName; + + return ( + <> +
+
+ {/* Shown in proportion to its printed width, so a smaller or larger size looks it. */} + {/* eslint-disable-next-line @next/next/no-img-element -- the engine's own SVG, shown as a data URI */} + {alt} +
+
+ + {isbn.entered === "isbn10" ? From ISBN-10 {isbn.printedIsbn10} : null} + {words.length ? {words} : null} + + ) : undefined} + inputRef={answerRef} + label="Valid ISBN" + value={{isbn.printedIsbn13}} + /> + +
+
+ +
+ + renderIsbnBarcodePdf(barcode) as BlobPart} filename={`${name}.pdf`} isDisabled={isStale} label="Download PDF" mimeType="application/pdf" /> + renderIsbnBarcodePng(barcode) as BlobPart} filename={`${name}.png`} isDisabled={isStale} label="Download PNG" mimeType="image/png" /> + +
+ +
+ + +
+ + ); +} + +/* ---------------- cover fit drawing ---------------- */ + +/** KDP's box, IngramSpark's area, and the barcode's white box, to one scale and sharing a bottom-left corner. */ +function CoverFitDrawing({ barcode }: { barcode: IsbnBarcode }) { + const [kdp, ingram] = barcode.fit; + const { widthMm, heightMm } = barcode.layout; + const pad = 1; + const width = Math.max(kdp.widthMm, ingram.widthMm, widthMm) + pad * 2; + const height = Math.max(kdp.heightMm, ingram.heightMm, heightMm) + pad * 2; + const bottom = height - pad; + const box = (w: number, h: number) => ({ x: pad, y: bottom - h, width: w, height: h }); + + return ( +
+ + + + + +
+