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
1 change: 1 addition & 0 deletions src/app/api/settings/branding/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export async function PUT(request: NextRequest) {
logoUrl: parsed.data.logoUrl ?? null,
accentColor: parsed.data.accentColor ?? null,
tagline: parsed.data.tagline ?? null,
fontFamily: parsed.data.fontFamily ?? null,
});

return NextResponse.json(saved, { status: 200 });
Expand Down
28 changes: 26 additions & 2 deletions src/app/invoice/new/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useState, useEffect, useCallback, useMemo, Suspense } from "react";
import { useState, useEffect, useCallback, useMemo, useRef, Suspense } from "react";
import { useRegisterShortcuts } from "@/context/ShortcutRegistry";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import dynamic from "next/dynamic";
import Stepper, { type Step } from "@/components/ui/Stepper";
Expand Down Expand Up @@ -652,6 +653,29 @@ function NewInvoiceForm() {
};
};

const formRef = useRef<HTMLFormElement>(null);

// Cmd/Ctrl+Enter from anywhere in the wizard submits the form — on the
// review step this creates the invoice, on earlier steps it advances to
// the next step (handleSubmit already implements that distinction).
useRegisterShortcuts(
[
{
id: "invoice-new:submit",
keys: ["Cmd", "Enter"],
description: "Submit / go to next step",
group: "Invoices",
handler: (e) => {
if (e.key !== "Enter") return;
if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;
e.preventDefault();
formRef.current?.requestSubmit();
},
},
],
[],
);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Pressing Enter inside a field on an earlier step should advance the
Expand Down Expand Up @@ -1485,7 +1509,7 @@ function NewInvoiceForm() {
</p>
)}

<form onSubmit={handleSubmit} className="flex flex-col gap-6" aria-label="Create invoice form">
<form ref={formRef} onSubmit={handleSubmit} className="flex flex-col gap-6" aria-label="Create invoice form">
{step === 0 && !loading && renderBasicInfo()}
{step === 1 && !loading && renderRecipients()}
{step === 2 && !loading && renderOptions()}
Expand Down
13 changes: 10 additions & 3 deletions src/components/InvoiceExportButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
import { useCallback, useState } from 'react';
import type { Invoice } from '@stellar-split/sdk';
import { formatAmount } from '@stellar-split/sdk';
import { DEFAULT_ACCENT_COLOR, type BrandSettings } from '@/lib/brandSettings';
import { DEFAULT_ACCENT_COLOR, BRAND_FONT_OPTIONS, type BrandSettings } from '@/lib/brandSettings';

/** Resolves the react-pdf font name for a brand font (falls back to Helvetica). */
function resolvePdfFontFamily(font: BrandSettings['fontFamily'] | undefined): string {
return BRAND_FONT_OPTIONS.find((o) => o.value === font)?.value ?? 'Helvetica';
}

interface Props {
invoice: Invoice;
Expand Down Expand Up @@ -39,6 +44,7 @@ async function downloadReceiptPdf(invoice: Invoice, total: bigint, brand: BrandS
const { pdf, Document, Page, Text, View, StyleSheet, Image } = await import('@react-pdf/renderer');

const accent = brand?.accentColor ?? DEFAULT_ACCENT_COLOR;
const fontFamily = resolvePdfFontFamily(brand?.fontFamily);
const logoDataUrl = brand?.logoUrl ? await fetchLogoDataUrl(brand.logoUrl) : null;
const qrCodeDataUrl = await generateQrCodeDataUrl(invoice.id);
const exportedAt = new Date().toLocaleString();
Expand All @@ -48,7 +54,7 @@ async function downloadReceiptPdf(invoice: Invoice, total: bigint, brand: BrandS
: `https://stellarsplit-dapp.vercel.app/verify/${invoice.id}`;

const styles = StyleSheet.create({
page: { padding: 36, fontSize: 10, fontFamily: 'Helvetica', color: '#111', backgroundColor: '#fff' },
page: { padding: 36, fontSize: 10, fontFamily, color: '#111', backgroundColor: '#fff' },
banner: { backgroundColor: accent, padding: '10 16', marginBottom: 18, borderRadius: 4 },
bannerTitle: { fontSize: 16, fontWeight: 'bold', color: '#fff' },
bannerSubtitle: { fontSize: 9, color: 'rgba(255,255,255,0.8)', marginTop: 2 },
Expand Down Expand Up @@ -172,12 +178,13 @@ export default function InvoiceExportButton({ invoice, total, branding }: Props)

const brand = await resolveBranding();
const accent = brand?.accentColor ?? DEFAULT_ACCENT_COLOR;
const fontFamily = resolvePdfFontFamily(brand?.fontFamily);
const logoDataUrl = brand?.logoUrl ? await fetchLogoDataUrl(brand.logoUrl) : null;
const qrCodeDataUrl = await generateQrCodeDataUrl(invoice.id);
const exportedAt = new Date().toLocaleString();

const styles = StyleSheet.create({
page: { padding: 40, fontSize: 11, fontFamily: 'Helvetica', color: '#111' },
page: { padding: 40, fontSize: 11, fontFamily, color: '#111' },
header: { marginBottom: 24 },
logo: { height: 40, width: 'auto', marginBottom: 8, objectFit: 'contain' },
title: { fontSize: 22, fontWeight: 'bold', marginBottom: 4, color: accent },
Expand Down
39 changes: 37 additions & 2 deletions src/components/settings/BrandingForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { getFreighterPublicKey } from "@/lib/freighter";
import {
ALLOWED_LOGO_MIME_TYPES,
BRAND_FONT_OPTIONS,
brandSettingsSchema,
DEFAULT_ACCENT_COLOR,
getBrandFontCssFamily,
isAllowedLogoMimeType,
isLogoSizeOk,
LOGO_SIZE_ERROR,
Expand All @@ -25,7 +27,7 @@ import {
uploadBrandLogo,
} from "@/lib/branding";

const EMPTY_VALUES = { logoUrl: "", accentColor: "", tagline: "" } as const;
const EMPTY_VALUES = { logoUrl: "", accentColor: "", tagline: "", fontFamily: "" } as const;

const MAX_LOGO_MB = MAX_LOGO_BYTES / (1024 * 1024);

Expand Down Expand Up @@ -72,6 +74,7 @@ export default function BrandingForm() {
logoUrl: settings.logoUrl ?? "",
accentColor: settings.accentColor ?? "",
tagline: settings.tagline ?? "",
fontFamily: settings.fontFamily ?? "",
});
})
.catch(() => {
Expand All @@ -88,6 +91,8 @@ export default function BrandingForm() {
const logoUrl = watch("logoUrl") ?? "";
const accentColor = watch("accentColor") ?? "";
const tagline = watch("tagline") ?? "";
const fontFamily = watch("fontFamily") ?? "";
const previewFontFamily = getBrandFontCssFamily(fontFamily || null);

// Real-time contrast feedback: warn inline as soon as the typed/picked
// color fails WCAG AA against white — before the user even hits save.
Expand Down Expand Up @@ -140,11 +145,13 @@ export default function BrandingForm() {
logoUrl: data.logoUrl ?? null,
accentColor: data.accentColor ?? null,
tagline: data.tagline ?? null,
fontFamily: data.fontFamily ?? null,
});
reset({
logoUrl: saved.logoUrl ?? "",
accentColor: saved.accentColor ?? "",
tagline: saved.tagline ?? "",
fontFamily: saved.fontFamily ?? "",
});
setFeedback({ kind: "success", text: "Branding saved — it now applies to all of your invoices." });
} catch (err) {
Expand Down Expand Up @@ -341,12 +348,40 @@ export default function BrandingForm() {
</div>
</section>

{/* ── Font ─────────────────────────────────────────────────────────── */}
<section aria-labelledby="branding-font-heading">
<h2 id="branding-font-heading" className="text-lg font-semibold mb-1">
Invoice font
</h2>
<p className="text-sm text-gray-400 mb-4">
Applied to your invoice page preview and exported PDFs.
</p>
<select
aria-label="Invoice font"
value={fontFamily}
onChange={(e) => setValue("fontFamily", e.target.value, { shouldDirty: true, shouldValidate: true })}
className="w-full max-w-xs rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">Platform default</option>
{BRAND_FONT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{errors.fontFamily && (
<p role="alert" className="mt-3 text-sm text-red-400">
{errors.fontFamily.message}
</p>
)}
</section>

{/* ── Live preview ─────────────────────────────────────────────────── */}
<section aria-labelledby="branding-preview-heading" aria-live="polite">
<h2 id="branding-preview-heading" className="text-lg font-semibold mb-3">
Invoice preview
</h2>
<div className="rounded-xl bg-white p-5 text-gray-900 shadow-lg">
<div className="rounded-xl bg-white p-5 text-gray-900 shadow-lg" style={{ fontFamily: previewFontFamily }}>
<div className="flex items-center gap-3">
{logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- user-supplied CDN URL
Expand Down
32 changes: 32 additions & 0 deletions src/lib/brandSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,31 @@ export const LOGO_MIME_EXTENSION: Record<AllowedLogoMimeType, string> = {
/** Max characters for the invoice tagline. */
export const MAX_TAGLINE_LENGTH = 120;

/**
* Font choices for invoice PDFs/previews. Values are react-pdf's built-in
* standard fonts (no custom font embedding needed), with a matching CSS
* font-family for the on-screen preview.
*/
export const BRAND_FONT_OPTIONS = [
{ value: "Helvetica", label: "Helvetica (default)", cssFontFamily: "Helvetica, Arial, sans-serif" },
{ value: "Times-Roman", label: "Times Roman (serif)", cssFontFamily: "'Times New Roman', Times, serif" },
{ value: "Courier", label: "Courier (monospace)", cssFontFamily: "'Courier New', Courier, monospace" },
] as const;

export type BrandFont = (typeof BRAND_FONT_OPTIONS)[number]["value"];

const BRAND_FONT_VALUES = BRAND_FONT_OPTIONS.map((o) => o.value) as readonly string[];

/** True when the value is one of the supported invoice fonts. */
export function isBrandFont(value: string): value is BrandFont {
return BRAND_FONT_VALUES.includes(value);
}

/** Resolves the CSS font-family stack for a brand font (falls back to the default). */
export function getBrandFontCssFamily(font: string | null | undefined): string {
return BRAND_FONT_OPTIONS.find((o) => o.value === font)?.cssFontFamily ?? BRAND_FONT_OPTIONS[0].cssFontFamily;
}

export const LOGO_TYPE_ERROR =
"Unsupported file type. Upload a PNG, JPEG, or WebP image.";
export const LOGO_SIZE_ERROR = "Logo exceeds the 2 MB size limit.";
Expand All @@ -40,6 +65,7 @@ export interface BrandSettings {
logoUrl: string | null;
accentColor: string | null;
tagline: string | null;
fontFamily: BrandFont | null;
updatedAt: string;
}

Expand All @@ -48,6 +74,7 @@ export const DEFAULT_BRAND_SETTINGS: Omit<BrandSettings, "updatedAt"> = {
logoUrl: null,
accentColor: null,
tagline: null,
fontFamily: null,
};

/** Platform default accent used when no accent color is configured. */
Expand Down Expand Up @@ -108,6 +135,11 @@ export const brandSettingsSchema = z.object({
.transform((v) => (v.length === 0 ? null : v))
.nullable()
.optional(),
fontFamily: z
.union([z.literal(""), z.string().refine(isBrandFont, { message: "Unsupported font selection" })])
.nullable()
.transform((v) => (v === "" ? null : (v as BrandFont | null) ?? null))
.optional(),
});

export type BrandSettingsInput = z.input<typeof brandSettingsSchema>;
Expand Down
8 changes: 7 additions & 1 deletion src/lib/brandSettingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,19 @@ export function hasBrandSettings(address: string): boolean {
*/
export function saveBrandSettings(
address: string,
settings: { logoUrl: string | null; accentColor: string | null; tagline: string | null },
settings: {
logoUrl: string | null;
accentColor: string | null;
tagline: string | null;
fontFamily: BrandSettings["fontFamily"];
},
): BrandSettings {
const key = normalizeAddress(address);
const record: BrandSettings = {
logoUrl: settings.logoUrl,
accentColor: settings.accentColor,
tagline: settings.tagline,
fontFamily: settings.fontFamily,
updatedAt: new Date().toISOString(),
};
brandStore.set(key, record);
Expand Down
8 changes: 7 additions & 1 deletion src/lib/branding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function readCache(address: string): BrandSettings | null {
logoUrl: parsed.logoUrl ?? null,
accentColor: parsed.accentColor ?? null,
tagline: parsed.tagline ?? null,
fontFamily: parsed.fontFamily ?? null,
updatedAt: parsed.updatedAt ?? "",
};
}
Expand Down Expand Up @@ -68,7 +69,12 @@ export async function fetchBrandSettings(address: string): Promise<BrandSettings
/** Persists brand settings for an account via the settings API. */
export async function saveBrandSettingsRemote(
address: string,
settings: { logoUrl: string | null; accentColor: string | null; tagline: string | null },
settings: {
logoUrl: string | null;
accentColor: string | null;
tagline: string | null;
fontFamily: BrandSettings["fontFamily"];
},
): Promise<BrandSettings> {
const res = await fetch("/api/settings/branding", {
method: "PUT",
Expand Down
Loading