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
8 changes: 1 addition & 7 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,4 @@ Describe the user-facing problem and the change that solves it.

## Verification

- [ ] `bun run typecheck`
- [ ] `bun run lint`
- [ ] `bun run test`
- [ ] `bun run build`
- [ ] Relevant light and dark UI states checked, if applicable
- [ ] Documentation and `.env.example` updated, if applicable
- [ ] No secrets, local databases or real customer data included
Summarise the relevant checks and their results. Include any failures or checks that could not be run.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@
- Creates drafts and searchable A4 PDF invoices in multiple currencies. Issued invoices retain the customer and seller details used at the time.
- Records full and partial payments, blocks overpayments, and tracks outstanding and overdue balances.
- Stores customers, billing addresses and reusable invoice lines.
- Shows customer address maps after consent, with options to allow once or remember the choice in this browser.
- Issues invoices from recurring schedules that can be paused and resumed.
- Records expenses and receipt images, then reports sales, receipts, expenses, cash net income and collections.
- Exports report data as CSV.

Customer address maps use a Google Maps iframe and need no API key. Folio makes no map requests until the viewer chooses **Allow once** or **Always allow**. Loading a map shares the billing address and the viewer's IP address with Google, which may also use cookies. **Allow once** lasts for the current page visit; **Always allow** is saved in this browser for all customer maps on this Folio installation. Customers without address details beyond a country do not show a map.

## Run with Docker Compose

Create `compose.yaml`:
Expand Down
18 changes: 17 additions & 1 deletion src/app/(app)/customers/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { EntityHeader } from "@/components/folio/entity-header";
import { StatusBadge } from "@/components/folio/status-badge";
import { Button } from "@/components/ui/button";
import { CustomerAvatar } from "@/features/customers/customer-avatar";
import { CustomerAddressMap } from "@/features/customers/customer-address-map";
import {
archiveCustomer,
restoreCustomer,
Expand All @@ -17,6 +18,7 @@ import {
} from "@/features/invoices/calculations";
import { invoiceStatusTone } from "@/features/invoices/presentation";
import type { Customer } from "@/lib/db/types";
import { countryName } from "@/lib/countries";
import { formatDate, formatMoney } from "@/lib/format";

type CustomerPageProps = {
Expand All @@ -30,7 +32,7 @@ function addressLines(customer: Customer) {
customer.address_line_1,
customer.address_line_2,
[customer.city, customer.region, customer.postal_code].filter(Boolean).join(", "),
customer.country_code,
countryName(customer.country_code),
].filter(Boolean) as string[];
}

Expand All @@ -42,6 +44,17 @@ export default async function CustomerPage({ params }: CustomerPageProps) {

const { customer, invoices, summary } = workspace;
const address = addressLines(customer);
const mapLocation = [
customer.address_line_1,
customer.address_line_2,
customer.city,
customer.region,
customer.postal_code,
].map((part) => part?.trim()).filter(Boolean).join(", ");
const mapAddress = [
mapLocation,
countryName(customer.country_code),
].filter(Boolean).join(", ");

return (
<div className="w-full space-y-6">
Expand Down Expand Up @@ -225,6 +238,9 @@ export default async function CustomerPage({ params }: CustomerPageProps) {
) : (
<p className="mt-3 text-[13px] text-subtle-foreground">No billing address added.</p>
)}
{mapLocation ? (
<CustomerAddressMap address={mapAddress} key={`${customer.id}:${mapAddress}`} />
) : null}
</section>

<section className="border-t p-4">
Expand Down
3 changes: 2 additions & 1 deletion src/app/(app)/customers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { HorizontalSlidingTabBar } from "@/components/folio/sliding-tab-bar";
import { StatusBadge } from "@/components/folio/status-badge";
import { Input } from "@/components/ui/input";
import { formatDate, formatMoney } from "@/lib/format";
import { countryName } from "@/lib/countries";
import {
buildListHref,
DEFAULT_LIST_PAGE_LIMIT,
Expand Down Expand Up @@ -257,7 +258,7 @@ export default async function CustomersPage({ searchParams }: CustomersPageProps
{customer.name}
</Link>
<p className="mt-0.5 text-[12px] text-subtle-foreground">
{customer.country_code ?? "No country added"}
{countryName(customer.country_code) ?? "No country added"}
</p>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/app/(app)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export default async function SettingsPage() {
id="countryCode"
name="countryCode"
options={COUNTRIES.map((country) => ({
description: country.code,
keywords: country.code,
label: country.name,
value: country.code,
}))}
Expand Down
2 changes: 1 addition & 1 deletion src/components/auth/business-setup/address-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { COUNTRIES } from "@/lib/countries"
import { cn } from "@/lib/utils"

const countryOptions = COUNTRIES.map((country) => ({
description: country.code,
keywords: country.code,
label: country.name,
value: country.code,
}))
Expand Down
2 changes: 1 addition & 1 deletion src/components/folio/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ function SidebarContent({
mobile?: boolean;
}) {
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{mobile ? (
<SheetClose asChild>
<Brand business={business} className="pr-12" />
Expand Down
100 changes: 100 additions & 0 deletions src/features/customers/customer-address-map.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"use client";

import { useState, useSyncExternalStore } from "react";

import { Button } from "@/components/ui/button";
import { MapPin } from "@/components/ui/icons";

const CONSENT_KEY = "folio:google-maps-consent";
const CONSENT_EVENT = "folio:google-maps-consent-change";

function hasSavedConsent() {
try {
return window.localStorage.getItem(CONSENT_KEY) === "allowed";
} catch {
return false;
}
}

function subscribeToConsent(onChange: () => void) {
function onStorage(event: StorageEvent) {
if (event.key === CONSENT_KEY || event.key === null) onChange();
}

window.addEventListener("storage", onStorage);
window.addEventListener(CONSENT_EVENT, onChange);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(CONSENT_EVENT, onChange);
};
}

export function CustomerAddressMap({ address }: { address: string }) {
const alwaysAllow = useSyncExternalStore(
subscribeToConsent,
hasSavedConsent,
() => false,
);
const [allowedOnce, setAllowedOnce] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const allowed = allowedOnce || alwaysAllow;

function allow(always: boolean) {
setMessage(null);
if (!always) {
setAllowedOnce(true);
return;
}

try {
window.localStorage.setItem(CONSENT_KEY, "allowed");
window.dispatchEvent(new Event(CONSENT_EVENT));
setAllowedOnce(false);
} catch {
setAllowedOnce(true);
setMessage("Your browser could not save this preference. The map is allowed for this visit only.");
}
}

return (
<div className="mt-4">
{allowed ? (
<iframe
className="block h-64 w-full rounded-xl border border-border bg-surface-subtle"
loading="lazy"
referrerPolicy="no-referrer"
src={`https://www.google.com/maps?${new URLSearchParams({ q: address, output: "embed" })}`}
title="Customer billing address on Google Maps"
/>
) : (
<div className="rounded-xl border border-border bg-surface-subtle p-3.5">
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground">
<MapPin aria-hidden="true" className="size-4" />
View address on a map
</div>
<p className="mt-2 text-[12px] leading-5 text-muted-foreground">
Loading this map shares the customer&apos;s billing address and your IP
address with Google. Google may also use cookies. Nothing is sent to
Google Maps until you allow it.
</p>
<p className="mt-2 text-[12px] leading-5 text-muted-foreground">
“Always allow” applies to all customer maps in this browser.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button className="text-[12px]" onClick={() => allow(false)} type="button" variant="outline">
Allow once
</Button>
<Button className="text-[12px]" onClick={() => allow(true)} type="button" variant="outline">
Always allow
</Button>
</div>
</div>
)}
{message ? (
<p className="px-3 py-2 text-[12px] leading-5 text-muted-foreground" role="status">
{message}
</p>
) : null}
</div>
);
}
2 changes: 1 addition & 1 deletion src/features/customers/customer-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ export function CustomerForm({ action, cancelHref, customer }: CustomerFormProps
options={[
{ label: "No country selected", value: "" },
...COUNTRIES.map((country) => ({
description: country.code,
keywords: country.code,
label: country.name,
value: country.code,
})),
Expand Down
4 changes: 3 additions & 1 deletion src/features/invoices/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ export function prepareInvoiceAggregate(
input.customer.city,
input.customer.region,
input.customer.postal_code,
input.customer.country_code,
]),
customer_country_code: input.customer.country_code,
notes: input.notes,
payment_instructions: input.paymentInstructions,
subtotal_cents: totals.subtotalCents,
Expand Down Expand Up @@ -165,12 +165,14 @@ export async function replaceDraftInvoiceAggregate(
seller_phone: invoice.seller_phone,
seller_tax_id: invoice.seller_tax_id,
seller_address: invoice.seller_address,
seller_country_code: invoice.seller_country_code,
customer_name: invoice.customer_name,
customer_billing_name: invoice.customer_billing_name,
customer_email: invoice.customer_email,
customer_phone: invoice.customer_phone,
customer_tax_id: invoice.customer_tax_id,
customer_address: invoice.customer_address,
customer_country_code: invoice.customer_country_code,
notes: invoice.notes,
payment_instructions: invoice.payment_instructions,
invoice_footer: invoice.invoice_footer,
Expand Down
Loading