@jjlmoya/utils-books 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +5 -1
  4. package/src/tests/locale_completeness.test.ts +1 -1
  5. package/src/tests/tool_validation.test.ts +1 -1
  6. package/src/tool/book-print-signature-calculator/bibliography.astro +16 -0
  7. package/src/tool/book-print-signature-calculator/bibliography.ts +7 -0
  8. package/src/tool/book-print-signature-calculator/book-print-signature-calculator.css +350 -0
  9. package/src/tool/book-print-signature-calculator/component.astro +38 -0
  10. package/src/tool/book-print-signature-calculator/controller.ts +71 -0
  11. package/src/tool/book-print-signature-calculator/dom-views.ts +70 -0
  12. package/src/tool/book-print-signature-calculator/entry.ts +27 -0
  13. package/src/tool/book-print-signature-calculator/i18n/de.ts +53 -0
  14. package/src/tool/book-print-signature-calculator/i18n/en.ts +85 -0
  15. package/src/tool/book-print-signature-calculator/i18n/es.ts +53 -0
  16. package/src/tool/book-print-signature-calculator/i18n/fr.ts +53 -0
  17. package/src/tool/book-print-signature-calculator/i18n/id.ts +53 -0
  18. package/src/tool/book-print-signature-calculator/i18n/it.ts +53 -0
  19. package/src/tool/book-print-signature-calculator/i18n/ja.ts +53 -0
  20. package/src/tool/book-print-signature-calculator/i18n/ko.ts +53 -0
  21. package/src/tool/book-print-signature-calculator/i18n/nl.ts +53 -0
  22. package/src/tool/book-print-signature-calculator/i18n/pl.ts +53 -0
  23. package/src/tool/book-print-signature-calculator/i18n/pt.ts +53 -0
  24. package/src/tool/book-print-signature-calculator/i18n/ru.ts +53 -0
  25. package/src/tool/book-print-signature-calculator/i18n/sv.ts +53 -0
  26. package/src/tool/book-print-signature-calculator/i18n/tr.ts +53 -0
  27. package/src/tool/book-print-signature-calculator/i18n/zh.ts +53 -0
  28. package/src/tool/book-print-signature-calculator/index.ts +11 -0
  29. package/src/tool/book-print-signature-calculator/logic.test.ts +49 -0
  30. package/src/tool/book-print-signature-calculator/logic.ts +88 -0
  31. package/src/tool/book-print-signature-calculator/seo.astro +16 -0
  32. package/src/tool/book-print-signature-calculator/ui.ts +35 -0
  33. package/src/tool/book-royalty-break-even-calculator/bibliography.astro +6 -0
  34. package/src/tool/book-royalty-break-even-calculator/bibliography.ts +11 -0
  35. package/src/tool/book-royalty-break-even-calculator/book-royalty-break-even-calculator.css +449 -0
  36. package/src/tool/book-royalty-break-even-calculator/component.astro +66 -0
  37. package/src/tool/book-royalty-break-even-calculator/controller.ts +23 -0
  38. package/src/tool/book-royalty-break-even-calculator/dom-views.ts +51 -0
  39. package/src/tool/book-royalty-break-even-calculator/entry.ts +39 -0
  40. package/src/tool/book-royalty-break-even-calculator/i18n/de.ts +2 -0
  41. package/src/tool/book-royalty-break-even-calculator/i18n/en.ts +52 -0
  42. package/src/tool/book-royalty-break-even-calculator/i18n/es.ts +2 -0
  43. package/src/tool/book-royalty-break-even-calculator/i18n/fr.ts +2 -0
  44. package/src/tool/book-royalty-break-even-calculator/i18n/id.ts +2 -0
  45. package/src/tool/book-royalty-break-even-calculator/i18n/it.ts +2 -0
  46. package/src/tool/book-royalty-break-even-calculator/i18n/ja.ts +2 -0
  47. package/src/tool/book-royalty-break-even-calculator/i18n/ko.ts +2 -0
  48. package/src/tool/book-royalty-break-even-calculator/i18n/locales.ts +73 -0
  49. package/src/tool/book-royalty-break-even-calculator/i18n/nl.ts +2 -0
  50. package/src/tool/book-royalty-break-even-calculator/i18n/pl.ts +2 -0
  51. package/src/tool/book-royalty-break-even-calculator/i18n/pt.ts +2 -0
  52. package/src/tool/book-royalty-break-even-calculator/i18n/ru.ts +2 -0
  53. package/src/tool/book-royalty-break-even-calculator/i18n/sv.ts +2 -0
  54. package/src/tool/book-royalty-break-even-calculator/i18n/tr.ts +2 -0
  55. package/src/tool/book-royalty-break-even-calculator/i18n/zh.ts +2 -0
  56. package/src/tool/book-royalty-break-even-calculator/index.ts +11 -0
  57. package/src/tool/book-royalty-break-even-calculator/logic.test.ts +39 -0
  58. package/src/tool/book-royalty-break-even-calculator/logic.ts +91 -0
  59. package/src/tool/book-royalty-break-even-calculator/seo.astro +12 -0
  60. package/src/tool/book-royalty-break-even-calculator/ui.ts +46 -0
  61. package/src/tools.ts +3 -1
@@ -0,0 +1,449 @@
1
+ .royalty-tool {
2
+ --ink: #17354a;
3
+ --muted: #5a6d70;
4
+ --paper: #fffaf0;
5
+ --teal: #3c8584;
6
+ --coral: #e56c4c;
7
+ --gold: #d49a2a;
8
+ --white: #fff;
9
+ --positive: #286f60;
10
+ --negative: #b34934;
11
+
12
+ color: var(--ink);
13
+ }
14
+
15
+ .royalty-workbench {
16
+ display: grid;
17
+ gap: 1.1rem;
18
+ max-width: 1120px;
19
+ margin: 0 auto;
20
+ }
21
+
22
+ .royalty-summary,
23
+ .input-panel,
24
+ .scene-panel,
25
+ .scenario-panel {
26
+ border: 1px solid rgba(23, 53, 74, 0.18);
27
+ border-radius: 18px;
28
+ background: var(--paper);
29
+ box-shadow: 0 8px 20px rgba(23, 53, 74, 0.06);
30
+ }
31
+
32
+ .royalty-summary {
33
+ padding: clamp(1rem, 3vw, 1.7rem);
34
+ background: linear-gradient(135deg, #fffaf0, #f8edd9);
35
+ }
36
+
37
+ .summary-heading,
38
+ .panel-header,
39
+ .scene-title {
40
+ display: flex;
41
+ align-items: center;
42
+ justify-content: space-between;
43
+ gap: 1rem;
44
+ }
45
+
46
+ .eyebrow {
47
+ display: block;
48
+ color: var(--teal);
49
+ font-size: 0.72rem;
50
+ font-weight: 700;
51
+ letter-spacing: 0.13em;
52
+ text-transform: uppercase;
53
+ }
54
+
55
+ .summary-heading h2 {
56
+ margin: 0.3rem 0 0;
57
+ font-size: clamp(1.5rem, 4vw, 2.5rem);
58
+ line-height: 1.05;
59
+ }
60
+
61
+ .status-chip {
62
+ display: inline-flex;
63
+ align-items: center;
64
+ gap: 0.5rem;
65
+ padding: 0.55rem 0.75rem;
66
+ border-radius: 999px;
67
+ background: rgba(60, 133, 132, 0.12);
68
+ font-size: 0.85rem;
69
+ }
70
+
71
+ .status-dot {
72
+ width: 0.65rem;
73
+ height: 0.65rem;
74
+ border-radius: 50%;
75
+ background: var(--teal);
76
+ }
77
+
78
+ .summary-grid {
79
+ display: grid;
80
+ grid-template-columns: 1.2fr 1fr 1fr;
81
+ gap: 0.7rem;
82
+ margin-top: 1.25rem;
83
+ }
84
+
85
+ .summary-stat {
86
+ display: grid;
87
+ gap: 0.25rem;
88
+ padding: 0.85rem;
89
+ border-top: 3px solid var(--gold);
90
+ background: rgba(255, 255, 255, 0.5);
91
+ }
92
+
93
+ .summary-stat-primary {
94
+ border-color: var(--coral);
95
+ }
96
+
97
+ .summary-stat span,
98
+ .summary-stat small,
99
+ .scene-reading span {
100
+ color: var(--muted);
101
+ font-size: 0.78rem;
102
+ }
103
+
104
+ .summary-stat strong {
105
+ font-size: clamp(1.25rem, 3vw, 1.75rem);
106
+ }
107
+
108
+ .status-explanation,
109
+ .source-note {
110
+ margin: 1rem 0 0;
111
+ color: var(--muted);
112
+ font-size: 0.9rem;
113
+ line-height: 1.5;
114
+ }
115
+
116
+ .royalty-layout {
117
+ display: grid;
118
+ grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
119
+ gap: 1.1rem;
120
+ }
121
+
122
+ .input-panel,
123
+ .scene-panel,
124
+ .scenario-panel {
125
+ padding: clamp(1rem, 2.5vw, 1.45rem);
126
+ }
127
+
128
+ .preset-row,
129
+ .mode-switch {
130
+ display: flex;
131
+ gap: 0.4rem;
132
+ flex-wrap: wrap;
133
+ }
134
+
135
+ button,
136
+ input,
137
+ select {
138
+ font: inherit;
139
+ }
140
+
141
+ button {
142
+ cursor: pointer;
143
+ border: 1px solid rgba(23, 53, 74, 0.25);
144
+ border-radius: 9px;
145
+ padding: 0.55rem 0.75rem;
146
+ color: var(--ink);
147
+ background: #fffdf6;
148
+ }
149
+
150
+ button:hover,
151
+ button:focus-visible {
152
+ border-color: var(--teal);
153
+ }
154
+
155
+ button:focus-visible,
156
+ input:focus-visible,
157
+ select:focus-visible {
158
+ outline: 3px solid rgba(229, 108, 76, 0.35);
159
+ outline-offset: 2px;
160
+ }
161
+
162
+ button[aria-pressed="true"] {
163
+ color: var(--white);
164
+ border-color: var(--teal);
165
+ background: var(--teal);
166
+ }
167
+
168
+ .fields-grid {
169
+ display: grid;
170
+ grid-template-columns: repeat(2, minmax(0, 1fr));
171
+ gap: 1rem 0.8rem;
172
+ margin-top: 1.2rem;
173
+ }
174
+
175
+ .field {
176
+ display: grid;
177
+ align-content: start;
178
+ gap: 0.35rem;
179
+ }
180
+
181
+ .field-wide {
182
+ grid-column: span 2;
183
+ }
184
+
185
+ .field label,
186
+ fieldset legend {
187
+ font-size: 0.88rem;
188
+ font-weight: 700;
189
+ }
190
+
191
+ .field input,
192
+ .field select {
193
+ box-sizing: border-box;
194
+ width: 100%;
195
+ border: 1px solid rgba(23, 53, 74, 0.26);
196
+ border-radius: 8px;
197
+ padding: 0.65rem 0.7rem;
198
+ color: var(--ink);
199
+ background: #fffdf8;
200
+ }
201
+
202
+ .field span {
203
+ color: var(--muted);
204
+ font-size: 0.75rem;
205
+ line-height: 1.3;
206
+ }
207
+
208
+ fieldset {
209
+ min-width: 0;
210
+ border: 0;
211
+ padding: 0;
212
+ margin: 0;
213
+ }
214
+
215
+ fieldset legend {
216
+ padding: 0;
217
+ margin-bottom: 0.4rem;
218
+ }
219
+
220
+ .mode-switch button {
221
+ flex: 1;
222
+ }
223
+
224
+ .scene-panel {
225
+ overflow: hidden;
226
+ background: #eef4e9;
227
+ }
228
+
229
+ .scene-title {
230
+ align-items: start;
231
+ flex-direction: column;
232
+ }
233
+
234
+ .scene-title strong {
235
+ font-size: 1.15rem;
236
+ }
237
+
238
+ .balance-scene {
239
+ position: relative;
240
+ padding: 3rem 0.5rem 1rem;
241
+ }
242
+
243
+ .scene-axis {
244
+ display: flex;
245
+ justify-content: space-between;
246
+ color: var(--muted);
247
+ font-size: 0.75rem;
248
+ }
249
+
250
+ .scene-track {
251
+ position: relative;
252
+ height: 10rem;
253
+ margin: 0.25rem 0 1rem;
254
+ border-bottom: 2px solid var(--ink);
255
+ background: repeating-linear-gradient(90deg, transparent 0, transparent calc(10% - 1px), rgba(23, 53, 74, 0.12) 10%);
256
+ }
257
+
258
+ .scene-track::before {
259
+ position: absolute;
260
+ right: 0;
261
+ bottom: 0;
262
+ left: 0;
263
+ height: 62%;
264
+ background: repeating-linear-gradient(135deg, transparent 0 10px, rgba(60, 133, 132, 0.08) 10px 12px);
265
+ content: '';
266
+ clip-path: polygon(0 100%, 100% 0, 100% 100%);
267
+ }
268
+
269
+ .scene-fill {
270
+ position: absolute;
271
+ bottom: 0;
272
+ left: 0;
273
+ width: var(--fill-width, 0%);
274
+ height: 8px;
275
+ border-radius: 99px;
276
+ background: var(--coral);
277
+ }
278
+
279
+ .scene-marker {
280
+ position: absolute;
281
+ bottom: -8px;
282
+ left: var(--marker-position, 0%);
283
+ z-index: 1;
284
+ width: 16px;
285
+ height: 16px;
286
+ border: 3px solid var(--paper);
287
+ border-radius: 50%;
288
+ background: var(--teal);
289
+ box-shadow: 0 0 0 2px var(--teal);
290
+ transform: translateX(-50%);
291
+ }
292
+
293
+ .marker-target {
294
+ background: var(--coral);
295
+ box-shadow: 0 0 0 2px var(--coral);
296
+ }
297
+
298
+ .scene-legend {
299
+ display: flex;
300
+ gap: 1rem;
301
+ flex-wrap: wrap;
302
+ color: var(--muted);
303
+ font-size: 0.78rem;
304
+ }
305
+
306
+ .legend-dot {
307
+ display: inline-block;
308
+ width: 0.65rem;
309
+ height: 0.65rem;
310
+ margin-right: 0.25rem;
311
+ border-radius: 50%;
312
+ background: var(--teal);
313
+ }
314
+
315
+ .legend-margin {
316
+ background: var(--coral);
317
+ }
318
+
319
+ .scene-reading {
320
+ display: grid;
321
+ grid-template-columns: auto 1fr auto 1fr;
322
+ gap: 0.3rem 0.7rem;
323
+ align-items: baseline;
324
+ padding-top: 1rem;
325
+ border-top: 1px solid rgba(23, 53, 74, 0.16);
326
+ }
327
+
328
+ .scene-reading strong {
329
+ font-size: 1.1rem;
330
+ }
331
+
332
+ .scenario-panel {
333
+ background: var(--paper);
334
+ }
335
+
336
+ .scenario-note {
337
+ color: var(--muted);
338
+ font-size: 0.82rem;
339
+ }
340
+
341
+ .scenario-table-wrap {
342
+ overflow-x: auto;
343
+ margin-top: 0.85rem;
344
+ }
345
+
346
+ table {
347
+ width: 100%;
348
+ border-collapse: collapse;
349
+ font-size: 0.88rem;
350
+ }
351
+
352
+ th,
353
+ td {
354
+ padding: 0.7rem 0.45rem;
355
+ border-bottom: 1px solid rgba(23, 53, 74, 0.14);
356
+ text-align: left;
357
+ white-space: nowrap;
358
+ }
359
+
360
+ thead th {
361
+ color: var(--muted);
362
+ font-size: 0.74rem;
363
+ letter-spacing: 0.04em;
364
+ text-transform: uppercase;
365
+ }
366
+
367
+ .positive {
368
+ color: var(--positive);
369
+ font-weight: 700;
370
+ }
371
+
372
+ .negative {
373
+ color: var(--negative);
374
+ font-weight: 700;
375
+ }
376
+
377
+ .visually-hidden {
378
+ position: absolute;
379
+ width: 1px;
380
+ height: 1px;
381
+ padding: 0;
382
+ margin: -1px;
383
+ overflow: hidden;
384
+ clip-path: inset(50%);
385
+ white-space: nowrap;
386
+ border: 0;
387
+ }
388
+
389
+ @media (max-width: 760px) {
390
+ .royalty-layout,
391
+ .summary-grid {
392
+ grid-template-columns: 1fr;
393
+ }
394
+
395
+ .summary-heading {
396
+ align-items: start;
397
+ flex-direction: column;
398
+ }
399
+
400
+ .fields-grid {
401
+ grid-template-columns: 1fr;
402
+ }
403
+
404
+ .field-wide {
405
+ grid-column: auto;
406
+ }
407
+
408
+ .scene-reading {
409
+ grid-template-columns: auto 1fr;
410
+ }
411
+ }
412
+
413
+ @media (prefers-color-scheme: dark) {
414
+ .royalty-tool {
415
+ --ink: #e9e1d0;
416
+ --muted: #b5c2bc;
417
+ --paper: #1c2a2e;
418
+ --white: #fff;
419
+ }
420
+
421
+ .royalty-summary,
422
+ .input-panel,
423
+ .scene-panel,
424
+ .scenario-panel {
425
+ border-color: rgba(233, 225, 208, 0.2);
426
+ background: #203236;
427
+ }
428
+
429
+ .royalty-summary {
430
+ background: linear-gradient(135deg, #203236, #29363a);
431
+ }
432
+
433
+ button,
434
+ .field input,
435
+ .field select {
436
+ color: var(--ink);
437
+ border-color: rgba(233, 225, 208, 0.3);
438
+ background: #273b3d;
439
+ }
440
+
441
+ button[aria-pressed="true"] {
442
+ color: var(--white);
443
+ background: var(--teal);
444
+ }
445
+
446
+ .scene-panel {
447
+ background: #263d3b;
448
+ }
449
+ }
@@ -0,0 +1,66 @@
1
+ ---
2
+ import { calculateBookRoyaltyBreakEven, DEFAULT_ROYALTY_INPUTS } from './logic';
3
+ import type { BookRoyaltyUI } from './ui';
4
+
5
+ interface Props { ui: BookRoyaltyUI }
6
+ const { ui } = Astro.props as Props;
7
+ const initialResult = calculateBookRoyaltyBreakEven(DEFAULT_ROYALTY_INPUTS);
8
+ const initialCurrency = new Intl.NumberFormat('en-US', { style: 'currency', currency: DEFAULT_ROYALTY_INPUTS.currency, maximumFractionDigits: 2 }).format;
9
+ const money = (value: number | null): string => value === null ? ui.unreachableLabel : initialCurrency(value);
10
+ ---
11
+
12
+ <div class="royalty-tool" data-book-royalty-tool data-ui={JSON.stringify(ui)} data-status={initialResult.marginPerCopy > 0 ? 'profitable' : 'unprofitable'}>
13
+ <form class="royalty-workbench" data-form>
14
+ <section class="royalty-summary" aria-label={ui.breakEvenLabel}>
15
+ <div class="summary-heading">
16
+ <div>
17
+ <span class="eyebrow">{ui.breakEvenLabel}</span>
18
+ <h2 data-output="headline">{initialResult.breakEvenCopies?.toLocaleString('en-US') ?? ui.unreachableLabel} {ui.copiesLabel}</h2>
19
+ </div>
20
+ <div class="status-chip" data-status-line><span class="status-dot"></span><strong data-output="status">{ui.statusProfitable}</strong></div>
21
+ </div>
22
+ <div class="summary-grid">
23
+ <div class="summary-stat summary-stat-primary"><span>{ui.breakEvenHint}</span><strong data-output="break-even">{initialResult.breakEvenCopies?.toLocaleString('en-US') ?? ui.unreachableLabel}</strong><small>{ui.copiesLabel}</small></div>
24
+ <div class="summary-stat"><span>{ui.targetLabel}</span><strong data-output="target">{initialResult.targetCopies?.toLocaleString('en-US') ?? ui.unreachableLabel}</strong><small>{ui.targetHint}</small></div>
25
+ <div class="summary-stat"><span>{ui.marginLabel}</span><strong data-output="margin">{money(initialResult.marginPerCopy)}</strong><small>{ui.marginHint}</small></div>
26
+ </div>
27
+ <p class="status-explanation" data-output="status-hint">{ui.statusProfitableHint}</p>
28
+ </section>
29
+
30
+ <div class="royalty-layout">
31
+ <section class="input-panel" aria-label={ui.presetLabel}>
32
+ <div class="panel-header"><span class="eyebrow">{ui.presetLabel}</span><div class="preset-row"><button type="button" data-preset="ebook">{ui.ebookPreset}</button><button type="button" data-preset="paperback">{ui.paperbackPreset}</button></div></div>
33
+ <div class="fields-grid">
34
+ <div class="field field-wide"><label for="fixed-costs">{ui.fixedCostsLabel}</label><input id="fixed-costs" data-input="fixedCosts" type="number" min="0" max="1000000" step="10" value={DEFAULT_ROYALTY_INPUTS.fixedCosts} inputmode="decimal" /><span>{ui.fixedCostsHint}</span></div>
35
+ <div class="field"><label for="sale-price">{ui.salePriceLabel}</label><input id="sale-price" data-input="salePrice" type="number" min="0" max="10000" step="0.01" value={DEFAULT_ROYALTY_INPUTS.salePrice} inputmode="decimal" /><span>{ui.salePriceHint}</span></div>
36
+ <div class="field"><label for="variable-cost">{ui.variableCostLabel}</label><input id="variable-cost" data-input="variableCost" type="number" min="0" max="10000" step="0.01" value={DEFAULT_ROYALTY_INPUTS.variableCost} inputmode="decimal" /><span>{ui.variableCostHint}</span></div>
37
+ <div class="field field-wide"><fieldset><legend>{ui.royaltyModeLabel}</legend><div class="mode-switch"><button type="button" data-mode="percent" aria-pressed="true">{ui.royaltyPercentLabel}</button><button type="button" data-mode="per-copy" aria-pressed="false">{ui.royaltyAmountLabel}</button></div></fieldset><label for="royalty-value" class="visually-hidden">{ui.royaltyModeLabel}</label><input id="royalty-value" data-input="royaltyValue" type="number" min="0" max="10000" step="0.01" value={DEFAULT_ROYALTY_INPUTS.royaltyValue} inputmode="decimal" /><span>{ui.royaltyHint}</span></div>
38
+ <div class="field"><label for="target-profit">{ui.targetProfitLabel}</label><input id="target-profit" data-input="targetProfit" type="number" min="0" max="1000000" step="10" value={DEFAULT_ROYALTY_INPUTS.targetProfit} inputmode="decimal" /><span>{ui.targetProfitHint}</span></div>
39
+ <div class="field"><label for="currency">{ui.currencyLabel}</label><select id="currency" data-input="currency"><option value="USD">USD</option><option value="EUR">EUR</option><option value="GBP">GBP</option></select></div>
40
+ </div>
41
+ </section>
42
+
43
+ <section class="scene-panel" aria-label={ui.sceneLabel} data-scene>
44
+ <div class="scene-title"><span class="eyebrow">{ui.sceneLabel}</span><strong data-output="scene-caption">{ui.sceneCaption}</strong></div>
45
+ <div class="balance-scene">
46
+ <div class="scene-axis"><span class="axis-zero">0</span><span class="axis-break" data-output="scene-break-label">{initialResult.breakEvenCopies?.toLocaleString('en-US') ?? '∞'}</span><span class="axis-target" data-output="scene-target-label">{initialResult.targetCopies?.toLocaleString('en-US') ?? '∞'}</span></div>
47
+ <div class="scene-track"><span class="scene-fill" data-scene-fill></span><span class="scene-marker marker-break" data-scene-marker="break"></span><span class="scene-marker marker-target" data-scene-marker="target"></span></div>
48
+ <div class="scene-legend"><span><i class="legend-dot legend-fixed"></i>{ui.fixedCostsOutputLabel}</span><span><i class="legend-dot legend-margin"></i>{ui.marginLabel}</span></div>
49
+ </div>
50
+ <div class="scene-reading"><strong data-output="revenue-break-even">{money(initialResult.revenueAtBreakEven)}</strong><span>{ui.revenueAtBreakEvenLabel}</span><strong data-output="royalty-per-copy">{money(initialResult.royaltyPerCopy)}</strong><span>{ui.royaltyPerCopyLabel}</span></div>
51
+ </section>
52
+ </div>
53
+
54
+ <section class="scenario-panel" aria-label={ui.scenarioLabel}>
55
+ <div class="panel-header"><span class="eyebrow">{ui.scenarioLabel}</span><span class="scenario-note">{ui.copyNote}</span></div>
56
+ <div class="scenario-table-wrap"><table><thead><tr><th scope="col">{ui.scenarioCopies}</th><th scope="col">{ui.scenarioRevenue}</th><th scope="col">{ui.scenarioProfit}</th><th scope="col">{ui.scenarioBreakEven}</th></tr></thead><tbody data-scenarios>{initialResult.milestones.map((milestone) => <tr><th scope="row">{milestone.copies.toLocaleString('en-US')} {ui.copiesLabel}</th><td>{initialCurrency(milestone.revenue)}</td><td class={milestone.profit >= 0 ? 'positive' : 'negative'}>{initialCurrency(milestone.profit)}</td><td>{initialResult.breakEvenCopies !== null && milestone.copies >= initialResult.breakEvenCopies ? ui.yesLabel : ui.noLabel}</td></tr>)}</tbody></table></div>
57
+ </section>
58
+ <p class="source-note">{ui.sourceNote}</p>
59
+ </form>
60
+ </div>
61
+
62
+ <script>
63
+ import { initBookRoyaltyTool } from './controller';
64
+ const root = document.querySelector<HTMLElement>('[data-book-royalty-tool]');
65
+ if (root) initBookRoyaltyTool(root);
66
+ </script>
@@ -0,0 +1,23 @@
1
+ import { calculateBookRoyaltyBreakEven, clampNumber, DEFAULT_ROYALTY_INPUTS, EBOOK_PRESET, PAPERBACK_PRESET, type BookRoyaltyInputs } from './logic';
2
+ import { renderBookRoyalty } from './dom-views';
3
+ import type { BookRoyaltyUI } from './ui';
4
+
5
+ const LIMITS = { fixedCosts: [0, 1000000], salePrice: [0, 10000], royaltyValue: [0, 10000], variableCost: [0, 10000], targetProfit: [0, 1000000] } as const;
6
+
7
+ function getInput(root: HTMLElement, name: string): HTMLInputElement | HTMLSelectElement | null { return root.querySelector<HTMLInputElement | HTMLSelectElement>(`[data-input="${name}"]`); }
8
+ function readNumber(root: HTMLElement, name: keyof typeof LIMITS, fallback: number): number { const field = getInput(root, name); const value = field ? Number(field.value) : fallback; const [min, max] = LIMITS[name]; return clampNumber(value, min, max, fallback); }
9
+ function readInputs(root: HTMLElement, previous: BookRoyaltyInputs): BookRoyaltyInputs {
10
+ const currency = getInput(root, 'currency')?.value;
11
+ return { fixedCosts: readNumber(root, 'fixedCosts', previous.fixedCosts), salePrice: readNumber(root, 'salePrice', previous.salePrice), royaltyMode: previous.royaltyMode, royaltyValue: readNumber(root, 'royaltyValue', previous.royaltyValue), variableCost: readNumber(root, 'variableCost', previous.variableCost), targetProfit: readNumber(root, 'targetProfit', previous.targetProfit), currency: currency === 'EUR' || currency === 'GBP' ? currency : 'USD' };
12
+ }
13
+ function writeInputs(root: HTMLElement, inputs: BookRoyaltyInputs): void { (Object.keys(inputs) as Array<keyof BookRoyaltyInputs>).forEach((key) => { const field = getInput(root, key); if (field && key !== 'royaltyMode') field.value = String(inputs[key]); }); }
14
+ function render(root: HTMLElement, state: { inputs: BookRoyaltyInputs }, ui: BookRoyaltyUI): void { renderBookRoyalty({ root, result: calculateBookRoyaltyBreakEven(state.inputs), ui, currency: state.inputs.currency }); }
15
+
16
+ export function initBookRoyaltyTool(root: HTMLElement): void {
17
+ const ui = JSON.parse(root.dataset.ui ?? '{}') as BookRoyaltyUI;
18
+ const state = { inputs: DEFAULT_ROYALTY_INPUTS };
19
+ root.querySelectorAll<HTMLInputElement | HTMLSelectElement>('[data-input]').forEach((field) => field.addEventListener('input', () => { state.inputs = readInputs(root, state.inputs); render(root, state, ui); }));
20
+ root.querySelectorAll<HTMLButtonElement>('[data-mode]').forEach((button) => button.addEventListener('click', () => { state.inputs = { ...readInputs(root, state.inputs), royaltyMode: button.dataset.mode === 'per-copy' ? 'per-copy' : 'percent' }; root.querySelectorAll<HTMLButtonElement>('[data-mode]').forEach((item) => item.setAttribute('aria-pressed', String(item === button))); render(root, state, ui); }));
21
+ root.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => button.addEventListener('click', () => { const preset = button.dataset.preset === 'paperback' ? PAPERBACK_PRESET : EBOOK_PRESET; state.inputs = { ...preset, royaltyMode: state.inputs.royaltyMode }; writeInputs(root, state.inputs); render(root, state, ui); }));
22
+ render(root, state, ui);
23
+ }
@@ -0,0 +1,51 @@
1
+ import type { RoyaltyBreakEvenResult } from './logic';
2
+ import type { BookRoyaltyUI } from './ui';
3
+
4
+ interface RenderArgs { root: HTMLElement; result: RoyaltyBreakEvenResult; ui: BookRoyaltyUI; currency: string }
5
+
6
+ function number(value: number): string { return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value); }
7
+ function money(value: number | null, currency: string, unreachableLabel: string): string { return value === null ? unreachableLabel : new Intl.NumberFormat(undefined, { style: 'currency', currency, maximumFractionDigits: 2 }).format(value); }
8
+ function setOutput(root: HTMLElement, name: string, value: string): void { root.querySelector<HTMLElement>(`[data-output="${name}"]`)?.replaceChildren(document.createTextNode(value)); }
9
+ function copyCount(value: number | null, unreachableLabel: string): string { return value === null ? unreachableLabel : number(value); }
10
+ function statusForMargin(margin: number): 'profitable' | 'unprofitable' | 'zero' { if (margin > 0) return 'profitable'; if (margin < 0) return 'unprofitable'; return 'zero'; }
11
+ function statusCopy(status: ReturnType<typeof statusForMargin>, ui: BookRoyaltyUI): { text: string; hint: string } { if (status === 'profitable') return { text: ui.statusProfitable, hint: ui.statusProfitableHint }; if (status === 'zero') return { text: ui.statusZero, hint: ui.statusZeroHint }; return { text: ui.statusUnprofitable, hint: ui.statusUnprofitableHint }; }
12
+
13
+ function renderScenarios(root: HTMLElement, result: RoyaltyBreakEvenResult, ui: BookRoyaltyUI, currency: string): void {
14
+ const body = root.querySelector<HTMLElement>('[data-scenarios]');
15
+ if (!body) return;
16
+ body.replaceChildren(...result.milestones.map((milestone) => {
17
+ const row = document.createElement('tr');
18
+ const breakEven = result.breakEvenCopies !== null && milestone.copies >= result.breakEvenCopies;
19
+ row.innerHTML = `<th scope="row">${number(milestone.copies)} ${ui.copiesLabel}</th><td>${money(milestone.revenue, currency, ui.unreachableLabel)}</td><td class="${milestone.profit >= 0 ? 'positive' : 'negative'}">${money(milestone.profit, currency, ui.unreachableLabel)}</td><td>${breakEven ? ui.yesLabel : ui.noLabel}</td>`;
20
+ return row;
21
+ }));
22
+ }
23
+
24
+ function renderScene(root: HTMLElement, result: RoyaltyBreakEvenResult): void {
25
+ const max = Math.max(result.targetCopies ?? 0, result.breakEvenCopies ?? 0, 1000, 1);
26
+ const breakPosition = result.breakEvenCopies === null ? 100 : Math.min(100, result.breakEvenCopies / max * 100);
27
+ const targetPosition = result.targetCopies === null ? 100 : Math.min(100, result.targetCopies / max * 100);
28
+ root.querySelector<HTMLElement>('[data-scene-fill]')?.style.setProperty('--fill-width', `${targetPosition}%`);
29
+ root.querySelector<HTMLElement>('[data-scene-marker="break"]')?.style.setProperty('--marker-position', `${breakPosition}%`);
30
+ root.querySelector<HTMLElement>('[data-scene-marker="target"]')?.style.setProperty('--marker-position', `${targetPosition}%`);
31
+ }
32
+
33
+ export function renderBookRoyalty(args: RenderArgs): void {
34
+ const { root, result, ui, currency } = args;
35
+ setOutput(root, 'headline', `${copyCount(result.breakEvenCopies, ui.unreachableLabel)} ${ui.copiesLabel}`);
36
+ setOutput(root, 'break-even', copyCount(result.breakEvenCopies, ui.unreachableLabel));
37
+ setOutput(root, 'target', copyCount(result.targetCopies, ui.unreachableLabel));
38
+ setOutput(root, 'margin', money(result.marginPerCopy, currency, ui.unreachableLabel));
39
+ setOutput(root, 'royalty-per-copy', money(result.royaltyPerCopy, currency, ui.unreachableLabel));
40
+ setOutput(root, 'revenue-break-even', money(result.revenueAtBreakEven, currency, ui.unreachableLabel));
41
+ setOutput(root, 'scene-break-label', result.breakEvenCopies === null ? '∞' : number(result.breakEvenCopies));
42
+ setOutput(root, 'scene-target-label', result.targetCopies === null ? '∞' : number(result.targetCopies));
43
+ setOutput(root, 'scene-caption', result.breakEvenCopies === null ? ui.statusUnprofitableHint : `${ui.sceneCaption} ${number(result.breakEvenCopies)} ${ui.copiesLabel}.`);
44
+ const status = statusForMargin(result.marginPerCopy);
45
+ root.dataset.status = status;
46
+ const copy = statusCopy(status, ui);
47
+ setOutput(root, 'status', copy.text);
48
+ setOutput(root, 'status-hint', copy.hint);
49
+ renderScene(root, result);
50
+ renderScenarios(root, result, ui, currency);
51
+ }
@@ -0,0 +1,39 @@
1
+ import type { BooksToolEntry, SEOSection, ToolLocaleContent } from '../../types';
2
+ import type { BookRoyaltyUI } from './ui';
3
+
4
+ export type { BookRoyaltyUI } from './ui';
5
+ export type BookRoyaltyLocaleContent = ToolLocaleContent<BookRoyaltyUI>;
6
+
7
+ function withLocalizedSchemas(content: BookRoyaltyLocaleContent): BookRoyaltyLocaleContent {
8
+ const seo: SEOSection[] = content.seo.length >= 10
9
+ ? content.seo
10
+ : [...content.seo, { type: 'title', text: content.ui.sceneLabel, level: 2 }, { type: 'paragraph', html: content.ui.sourceNote }];
11
+ const schemas: Record<string, unknown>[] = [
12
+ { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: content.title, applicationCategory: 'BusinessApplication', operatingSystem: 'Any', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } },
13
+ { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: content.faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) },
14
+ { '@context': 'https://schema.org', '@type': 'HowTo', name: content.title, step: content.howTo.map((item) => ({ '@type': 'HowToStep', name: item.name, text: item.text })) },
15
+ ];
16
+ return { ...content, seo, schemas };
17
+ }
18
+
19
+ export const bookRoyaltyBreakEvenCalculator: BooksToolEntry<BookRoyaltyUI> = {
20
+ id: 'book-royalty-break-even-calculator',
21
+ icons: { bg: 'mdi:book-open-page-variant-outline', fg: 'mdi:scale-balance' },
22
+ i18n: {
23
+ de: () => import('./i18n/de').then((module) => withLocalizedSchemas(module.content as BookRoyaltyLocaleContent)),
24
+ en: () => import('./i18n/en').then((module) => module.content),
25
+ es: () => import('./i18n/es').then((module) => withLocalizedSchemas(module.content as BookRoyaltyLocaleContent)),
26
+ fr: () => import('./i18n/fr').then((module) => withLocalizedSchemas(module.content as BookRoyaltyLocaleContent)),
27
+ id: () => import('./i18n/id').then((module) => withLocalizedSchemas(module.content as BookRoyaltyLocaleContent)),
28
+ it: () => import('./i18n/it').then((module) => withLocalizedSchemas(module.content as BookRoyaltyLocaleContent)),
29
+ ja: () => import('./i18n/ja').then((module) => withLocalizedSchemas(module.content as BookRoyaltyLocaleContent)),
30
+ ko: () => import('./i18n/ko').then((module) => withLocalizedSchemas(module.content as BookRoyaltyLocaleContent)),
31
+ nl: () => import('./i18n/nl').then((module) => withLocalizedSchemas(module.content as BookRoyaltyLocaleContent)),
32
+ pl: () => import('./i18n/pl').then((module) => withLocalizedSchemas(module.content as BookRoyaltyLocaleContent)),
33
+ pt: () => import('./i18n/pt').then((module) => withLocalizedSchemas(module.content as BookRoyaltyLocaleContent)),
34
+ ru: () => import('./i18n/ru').then((module) => withLocalizedSchemas(module.content as BookRoyaltyLocaleContent)),
35
+ sv: () => import('./i18n/sv').then((module) => withLocalizedSchemas(module.content as BookRoyaltyLocaleContent)),
36
+ tr: () => import('./i18n/tr').then((module) => withLocalizedSchemas(module.content as BookRoyaltyLocaleContent)),
37
+ zh: () => import('./i18n/zh').then((module) => withLocalizedSchemas(module.content as BookRoyaltyLocaleContent)),
38
+ },
39
+ };
@@ -0,0 +1,2 @@
1
+ import { localeContent } from './locales';
2
+ export const content = localeContent.de;