Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ Emails use the Cloudflare `SEND_EMAIL` Worker binding. `wrangler dev` simulates
5. run migrations
6. `pnpm dev`

## UI documentation

See [app screenshots](docs/screenshots/README.md) for examples of the current interface, starting with invoice and reminder emails. Run `pnpm docs:screenshots` to refresh these captures with sample data. When documenting another screen, add its screenshot and reproduction instructions there.

## Database

use turso cloud for database to develop locally. You can also use local sqlite database, but it's not recommended as some features are not supported for local database.
Expand Down
30 changes: 30 additions & 0 deletions docs/screenshots/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# App screenshots

These screenshots document the actual UI for contributors. They use synthetic fixture data, not customer invoices or a production account. The application interface is Czech; the email language can be changed independently.

## Invoice email composer

Open an invoice and choose **Odeslat fakturu**. Recipient and email language default to the contact. The subject and body are editable; changing the language affects only this email. A share link is appended when sending.

![Invoice email composer with a Czech template](invoice-email-cs.png)

## Payment reminder composer

Choose **Odeslat upomínku** on an unpaid invoice. This example uses an English-speaking contact, so the reminder is prefilled in English.

![Payment reminder composer with an English template](invoice-reminder-en.png)

## Refreshing the screenshots

From the repository root after installing dependencies:

```sh
pnpm exec playwright install chromium
pnpm docs:screenshots
```

The dedicated [Playwright configuration](../../playwright.docs.config.ts) starts the frontend and runs the [mocked email composer workflow](../../e2e/invoice-email.spec.ts). It needs no backend or database and sends no real email. Normal E2E runs do not overwrite these images. If a development frontend is already running on port 5173, it is reused.

Captures use Chromium, a 1440 × 1100 viewport, 1× scale, and light mode. For documentation only, the capture helper expands the message field when necessary to show the full draft; it does not alter the app's default layout. Images are cropped to the dialog to keep the documentation focused. Inspect the PNGs after regenerating them and commit them alongside the corresponding UI changes.

For future screens, use descriptive, stable filenames in this folder, add a short explanation here, and provide a reproducible capture using synthetic data. Never include personal data, credentials, real share links, or customer details.
Binary file added docs/screenshots/invoice-email-cs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/invoice-reminder-en.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
188 changes: 188 additions & 0 deletions e2e/invoice-email.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { expect, test } from './fixtures'
import { mkdir } from 'node:fs/promises'
import { resolve } from 'node:path'

test('invoice email composer uses the contact language, preserves edits and sends reminders', async ({
page
}, testInfo) => {
const captureComposer = async (filename: string) => {
if (!testInfo.project.metadata.documentationScreenshots) return
const directory = resolve('docs/screenshots')
await mkdir(directory, { recursive: true })
await page.evaluate(() => document.fonts.ready)
// Expand only for documentation so the complete draft is visible.
const textarea = page
.getByRole('dialog')
.getByLabel('Zpráva', { exact: true })
await textarea.evaluate((element) => {
if (element.scrollHeight > element.clientHeight) {
element.style.height = `${element.scrollHeight + 24}px`
}
})
await page.getByRole('dialog').getByRole('heading').click()
await page.getByRole('dialog').screenshot({
path: resolve(directory, filename),
animations: 'disabled',
caret: 'hide'
})
}
await page.addInitScript(() => {
localStorage.setItem('auth_token', 'email-composer-test-token')
localStorage.setItem(
'auth_user',
JSON.stringify({
id: 'email-test-user',
name: 'Seller',
email: 'seller@example.com'
})
)
})
const invoice = {
id: 'email-test-invoice',
number: '2026-123',
language: 'cs',
your_name: 'Seller',
your_street: 'Street 1',
your_city: 'Prague',
your_zip: '11000',
your_country: 'CZ',
your_registration_no: '12345678',
your_vat_no: '',
client_name: 'Client',
client_street: 'Street 2',
client_city: 'London',
client_zip: '12345',
client_country: 'GB',
issued_on: '2026-09-01',
taxable_fulfillment_due: '2026-09-01',
due_on: '2026-09-15',
due_in_days: 14,
bank_account: '123456789/0100',
currency: 'CZK',
total: 100,
paid_on: null,
status: null,
cancelled_at: null,
sent_at: null,
reminder_sent_at: null,
items: [
{
id: 1,
description: 'Work',
quantity: 1,
unit_price: 100,
vat_rate: 0,
unit: 'ks'
}
]
}
const sends: Record<string, unknown>[] = []
let failSend = false
await page.route('**/trpc/**', async (route) => {
const url = new URL(route.request().url())
const procedures = url.pathname.split('/trpc/')[1].split(',')
const inputs = JSON.parse(
route.request().postData() || url.searchParams.get('input') || '{}'
)
const response = procedures.map((procedure, index) => {
let json: unknown = null
if (procedure === 'invoices.getById') json = invoice
if (procedure === 'invoicingDetails')
json = { name: 'Seller', vat_payer: false, bankAccounts: [] }
if (procedure === 'invoices.listShares') json = []
if (procedure === 'invoices.getEmailDraft')
json = {
to: 'client@example.com',
language: 'en',
number: invoice.number,
dueOn: invoice.due_on,
senderName: 'Seller'
}
if (procedure === 'invoices.sendEmail') {
if (failSend)
return {
error: {
json: {
message: 'E-mail se nepodařilo odeslat.',
code: -32603,
data: {
code: 'INTERNAL_SERVER_ERROR',
httpStatus: 500,
path: procedure
}
}
}
}
sends.push(inputs[index].json)
json = { sentAt: '2026-09-21' }
}
return { result: { data: { json } } }
})
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify(response)
})
})
await page.goto('http://localhost:5173/invoices/email-test-invoice')
await page
.getByRole('button', { name: 'Odeslat fakturu', exact: true })
.click()
const dialog = page.getByRole('dialog')
const language = dialog.getByLabel('Jazyk e-mailu', { exact: true })
const subject = dialog.getByLabel('Předmět', { exact: true })
const body = dialog.getByLabel('Zpráva', { exact: true })
await expect(language).toHaveValue('en')
await expect(subject).toHaveValue('Invoice 2026-123')
await expect(dialog.getByLabel('Příjemce')).toHaveValue('client@example.com')
await language.selectOption('cs')
await expect(subject).toHaveValue('Faktura 2026-123')
await captureComposer('invoice-email-cs.png')
await body.fill('Vlastní zpráva')
page.once('dialog', (confirmation) => confirmation.dismiss())
await language.selectOption('en')
await expect(language).toHaveValue('cs')
await expect(body).toHaveValue('Vlastní zpráva')
page.once('dialog', (confirmation) => confirmation.accept())
await language.selectOption('en')
await expect(body).toHaveValue(/Hello,/)
await subject.fill('Custom subject')
await body.fill('Please review my invoice.')
await dialog.getByLabel('Příjemce').fill('other@example.com')
// A background query refetch must not reset the draft.
await page.evaluate(() => window.dispatchEvent(new Event('focus')))
await expect(body).toHaveValue('Please review my invoice.')
failSend = true
await dialog
.getByRole('button', { name: 'Odeslat e-mail', exact: true })
.click()
await expect(
page.getByText('E-mail se nepodařilo odeslat.').first()
).toBeVisible()
await expect(body).toHaveValue('Please review my invoice.')
failSend = false
await dialog
.getByRole('button', { name: 'Odeslat e-mail', exact: true })
.click()
await expect(dialog).not.toBeVisible()
expect(sends).toEqual([
{
invoiceId: invoice.id,
kind: 'invoice',
language: 'en',
to: 'other@example.com',
subject: 'Custom subject',
body: 'Please review my invoice.'
}
])
await page
.getByRole('button', { name: 'Odeslat upomínku', exact: true })
.click()
await expect(subject).toHaveValue('Payment reminder – invoice 2026-123')
await expect(body).toHaveValue(/remains unpaid/)
await captureComposer('invoice-reminder-en.png')
await dialog
.getByRole('button', { name: 'Odeslat e-mail', exact: true })
.click()
await expect(dialog).not.toBeVisible()
expect(sends[1]).toMatchObject({ kind: 'reminder', language: 'en' })
})
1 change: 1 addition & 0 deletions faktorio-api/.dev.vars.e2e
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ TURSO_DATABASE_URL=http://127.0.0.1:8080
TURSO_AUTH_TOKEN=local
JWT_SECRET=e2e-jwt-secret-not-used-anywhere-real-32-chars
OPENROUTER_API_KEY=e2e-test-openrouter-key
PUBLIC_APP_URL=http://localhost:5173
VAPID_PUBLIC_KEY=BGs37P8xLBBboVi_dD2JWT6y8Kauh1iiXtxrs6tpy2edZCzYixA8BA6iQhm2rFBH7SSgR5xKHF23BkXkRWMS-fQ
VAPID_PRIVATE_KEY=e2e-test-vapid-private-key
VAPID_SUBJECT=mailto:e2e@test.local
1 change: 1 addition & 0 deletions faktorio-api/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ TURSO_DATABASE_URL=
TURSO_AUTH_TOKEN=
JWT_SECRET=
OPENROUTER_API_KEY=
PUBLIC_APP_URL=http://localhost:5173
INVOICE_LOGO_PUBLIC_BASE_URL=https://uploads.faktorio.cz
1 change: 1 addition & 0 deletions faktorio-api/src/envSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const envSchema = z.object({
OPENROUTER_API_KEY: z.string().min(1),
// Supplied by Wrangler, not process.env; absent in browser-only local mode.
SEND_EMAIL: z.custom<SendEmail>().optional(),
PUBLIC_APP_URL: z.url().optional(),
VAPID_PUBLIC_KEY: z.string().min(1),
VAPID_PRIVATE_KEY: z.string().min(1),
VAPID_SUBJECT: z.string().min(1),
Expand Down
Loading
Loading