diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d801a92..4fe1fd7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -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. diff --git a/README.md b/README.md index 2df5a0e..337df01 100644 --- a/README.md +++ b/README.md @@ -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`: diff --git a/src/app/(app)/customers/[id]/page.tsx b/src/app/(app)/customers/[id]/page.tsx index 1ed4d15..e985d9a 100644 --- a/src/app/(app)/customers/[id]/page.tsx +++ b/src/app/(app)/customers/[id]/page.tsx @@ -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, @@ -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 = { @@ -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[]; } @@ -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 (
@@ -225,6 +238,9 @@ export default async function CustomerPage({ params }: CustomerPageProps) { ) : (

No billing address added.

)} + {mapLocation ? ( + + ) : null}
diff --git a/src/app/(app)/customers/page.tsx b/src/app/(app)/customers/page.tsx index 4ed81ca..22e4576 100644 --- a/src/app/(app)/customers/page.tsx +++ b/src/app/(app)/customers/page.tsx @@ -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, @@ -257,7 +258,7 @@ export default async function CustomersPage({ searchParams }: CustomersPageProps {customer.name}

- {customer.country_code ?? "No country added"} + {countryName(customer.country_code) ?? "No country added"}

diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index fad47d7..93419cb 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -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, }))} diff --git a/src/components/auth/business-setup/address-step.tsx b/src/components/auth/business-setup/address-step.tsx index a57d035..08d2788 100644 --- a/src/components/auth/business-setup/address-step.tsx +++ b/src/components/auth/business-setup/address-step.tsx @@ -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, })) diff --git a/src/components/folio/app-shell.tsx b/src/components/folio/app-shell.tsx index ef56b1e..654e066 100644 --- a/src/components/folio/app-shell.tsx +++ b/src/components/folio/app-shell.tsx @@ -624,7 +624,7 @@ function SidebarContent({ mobile?: boolean; }) { return ( -
+
{mobile ? ( diff --git a/src/features/customers/customer-address-map.tsx b/src/features/customers/customer-address-map.tsx new file mode 100644 index 0000000..c689526 --- /dev/null +++ b/src/features/customers/customer-address-map.tsx @@ -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(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 ( +
+ {allowed ? ( +