From b57ab2e0903eeaf1bfa8fde59dcd7650bbaa0459 Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Wed, 26 Aug 2026 15:52:49 +0200 Subject: [PATCH 1/3] Let buyers manage and buy for their company MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A company's members could already do all of this through the Store API — manage each other, keep an address book, read the organization's orders — but there was no way to reach any of it, and the invitation email linked to a page that did not exist. The account area gains a company section (members with add-by-email, pending invitations, the address book, the organization's orders) and the invite link now lands somewhere: a page that registers the invitee or binds the account they are already signed into, refusing plainly when that is not the invited address. Checkout learns who the purchase is for. The picker sits on the cart as well, because pricing reads the cart's company — chosen late, the buyer reviews a basket priced for the wrong audience. Once a company is named, the address step offers its sites instead of the buyer's own: a purchase for the organization ships to the organization. The address book itself is the shopper's component with its mutations injected — a company keeps addresses the same way a person does, and only where the rows live differs. --- messages/de.json | 34 ++- messages/en.json | 34 ++- messages/es.json | 34 ++- messages/fr.json | 34 ++- messages/pl.json | 34 ++- .../checkout/[id]/CheckoutPageContent.tsx | 7 + .../(checkout)/checkout/[id]/page.tsx | 11 +- .../companies/[id]/orders/page.tsx | 46 ++++ .../(authenticated)/companies/[id]/page.tsx | 80 ++++++ .../(authenticated)/companies/page.tsx | 74 ++++++ .../account/(authenticated)/layout.tsx | 10 +- .../account/company-invitation/page.tsx | 69 ++++++ .../[locale]/(storefront)/cart/page.tsx | 10 + src/components/account/AccountShell.tsx | 27 +- .../account/AuthenticatedAccountShell.tsx | 4 +- .../addresses/AddressManagement.tsx | 32 ++- .../companies/BuyingForSelector.tsx | 86 +++++++ .../companies/CompanyAddressBook.tsx | 54 ++++ .../companies/CompanyInvitationAcceptance.tsx | 153 ++++++++++++ src/components/companies/CompanyMembers.tsx | 131 ++++++++++ src/lib/data/cart.ts | 25 ++ src/lib/data/companies.ts | 232 ++++++++++++++++++ 22 files changed, 1207 insertions(+), 14 deletions(-) create mode 100644 src/app/[country]/[locale]/(storefront)/account/(authenticated)/companies/[id]/orders/page.tsx create mode 100644 src/app/[country]/[locale]/(storefront)/account/(authenticated)/companies/[id]/page.tsx create mode 100644 src/app/[country]/[locale]/(storefront)/account/(authenticated)/companies/page.tsx create mode 100644 src/app/[country]/[locale]/(storefront)/account/company-invitation/page.tsx create mode 100644 src/components/companies/BuyingForSelector.tsx create mode 100644 src/components/companies/CompanyAddressBook.tsx create mode 100644 src/components/companies/CompanyInvitationAcceptance.tsx create mode 100644 src/components/companies/CompanyMembers.tsx create mode 100644 src/lib/data/companies.ts diff --git a/messages/de.json b/messages/de.json index 6061798f..022af90b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -332,7 +332,8 @@ "showPassword": "Passwort anzeigen", "hidePassword": "Passwort verbergen", "forgotPassword": "Passwort vergessen?", - "policyConsentRequired": "Sie müssen den Geschäftsrichtlinien zustimmen, um ein Konto zu erstellen" + "policyConsentRequired": "Sie müssen den Geschäftsrichtlinien zustimmen, um ein Konto zu erstellen", + "companies": "Unternehmen" }, "register": { "createAccount": "Konto erstellen", @@ -628,5 +629,36 @@ "signInForPricing": "Zur Preisanzeige anmelden", "signInToOrder": "Zum Bestellen anmelden" } + }, + "companies": { + "title": "Unternehmen", + "subtitle": "Die Organisationen, für die Sie einkaufen können.", + "empty": "Sie sind noch in keinem Unternehmen Mitglied.", + "members": "Mitglieder", + "addMember": "Hinzufügen", + "addMemberPlaceholder": "kollege@beispiel.de", + "addMemberFailed": "Diese Person konnte nicht hinzugefügt werden.", + "removeMember": "Mitglied entfernen", + "invitationPending": "Einladung gesendet", + "revoke": "Zurückziehen", + "addresses": "Adressbuch", + "addressesHelp": "Wohin diese Organisation beliefert wird. Von einer übergeordneten Einheit geerbte Standorte werden ebenfalls angezeigt.", + "viewOrders": "Unternehmensbestellungen ansehen", + "ordersTitle": "Unternehmensbestellungen", + "invitationTitle": "{company} beitreten", + "invitationBody": "Sie wurden eingeladen, bei {store} als {email} einzukaufen.", + "invitationMissingToken": "Diesem Link fehlt der Einladungscode.", + "invitationInvalidTitle": "Diese Einladung ist nicht mehr gültig", + "invitationInvalidBody": "Sie wurde möglicherweise bereits angenommen, zurückgezogen oder ist abgelaufen. Bitten Sie um eine neue Einladung.", + "invitationWrongAccount": "Diese Einladung ging an {invited}, Sie sind aber als {current} angemeldet. Melden Sie sich mit der eingeladenen Adresse an.", + "invitationAcceptFailed": "Die Einladung konnte nicht angenommen werden.", + "acceptAsSignedIn": "Einladung annehmen", + "acceptAndRegister": "Konto erstellen und beitreten", + "accepting": "Wird angenommen…", + "firstName": "Vorname", + "lastName": "Nachname", + "password": "Passwort", + "buyingFor": "Einkauf für", + "buyPersonally": "Privat einkaufen" } } diff --git a/messages/en.json b/messages/en.json index 8c5a8807..3abba53a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -332,7 +332,8 @@ "showPassword": "Show password", "hidePassword": "Hide password", "forgotPassword": "Forgot password?", - "policyConsentRequired": "You must agree to the store policies to create an account" + "policyConsentRequired": "You must agree to the store policies to create an account", + "companies": "Companies" }, "register": { "createAccount": "Create Account", @@ -628,5 +629,36 @@ "signInForPricing": "Sign in for pricing", "signInToOrder": "Sign in to order" } + }, + "companies": { + "title": "Companies", + "subtitle": "The organizations you can buy for.", + "empty": "You are not a member of any company yet.", + "members": "Members", + "addMember": "Add", + "addMemberPlaceholder": "colleague@example.com", + "addMemberFailed": "Could not add that person.", + "removeMember": "Remove member", + "invitationPending": "Invitation sent", + "revoke": "Revoke", + "addresses": "Address book", + "addressesHelp": "Where this organization takes delivery. Sites inherited from a parent are shown too.", + "viewOrders": "View company orders", + "ordersTitle": "Company orders", + "invitationTitle": "Join {company}", + "invitationBody": "You have been invited to buy on {store} as {email}.", + "invitationMissingToken": "This link is missing its invitation code.", + "invitationInvalidTitle": "This invitation is no longer valid", + "invitationInvalidBody": "It may have been accepted already, revoked, or simply expired. Ask whoever invited you to send a new one.", + "invitationWrongAccount": "This invitation was sent to {invited}, but you are signed in as {current}. Sign in as the invited address to accept it.", + "invitationAcceptFailed": "Could not accept the invitation.", + "acceptAsSignedIn": "Accept invitation", + "acceptAndRegister": "Create account and join", + "accepting": "Accepting…", + "firstName": "First name", + "lastName": "Last name", + "password": "Password", + "buyingFor": "Buying for", + "buyPersonally": "Buy personally" } } diff --git a/messages/es.json b/messages/es.json index 9db1c1d4..65cfdee7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -332,7 +332,8 @@ "showPassword": "Mostrar contrasena", "hidePassword": "Ocultar contrasena", "forgotPassword": "¿Olvidaste tu contrasena?", - "policyConsentRequired": "Debes aceptar las politicas de la tienda para crear una cuenta" + "policyConsentRequired": "Debes aceptar las politicas de la tienda para crear una cuenta", + "companies": "Empresas" }, "register": { "createAccount": "Crear cuenta", @@ -628,5 +629,36 @@ "signInForPricing": "Inicia sesión para ver precios", "signInToOrder": "Inicia sesión para pedir" } + }, + "companies": { + "title": "Empresas", + "subtitle": "Las organizaciones para las que puede comprar.", + "empty": "Todavía no es miembro de ninguna empresa.", + "members": "Miembros", + "addMember": "Añadir", + "addMemberPlaceholder": "colega@ejemplo.es", + "addMemberFailed": "No se pudo añadir a esa persona.", + "removeMember": "Eliminar miembro", + "invitationPending": "Invitación enviada", + "revoke": "Revocar", + "addresses": "Libreta de direcciones", + "addressesHelp": "Dónde recibe esta organización sus entregas. También se muestran las sedes heredadas de una entidad superior.", + "viewOrders": "Ver pedidos de la empresa", + "ordersTitle": "Pedidos de la empresa", + "invitationTitle": "Unirse a {company}", + "invitationBody": "Le han invitado a comprar en {store} como {email}.", + "invitationMissingToken": "A este enlace le falta el código de invitación.", + "invitationInvalidTitle": "Esta invitación ya no es válida", + "invitationInvalidBody": "Puede que ya se haya aceptado, se haya revocado o haya caducado. Pida una nueva.", + "invitationWrongAccount": "Esta invitación se envió a {invited}, pero ha iniciado sesión como {current}. Inicie sesión con la dirección invitada.", + "invitationAcceptFailed": "No se pudo aceptar la invitación.", + "acceptAsSignedIn": "Aceptar invitación", + "acceptAndRegister": "Crear cuenta y unirse", + "accepting": "Aceptando…", + "firstName": "Nombre", + "lastName": "Apellidos", + "password": "Contraseña", + "buyingFor": "Comprando para", + "buyPersonally": "Comprar a título personal" } } diff --git a/messages/fr.json b/messages/fr.json index 81e2ecaf..97a98bb0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -332,7 +332,8 @@ "showPassword": "Afficher le mot de passe", "hidePassword": "Masquer le mot de passe", "forgotPassword": "Mot de passe oublie ?", - "policyConsentRequired": "Vous devez accepter les politiques du magasin pour creer un compte" + "policyConsentRequired": "Vous devez accepter les politiques du magasin pour creer un compte", + "companies": "Entreprises" }, "register": { "createAccount": "Creer un compte", @@ -628,5 +629,36 @@ "signInForPricing": "Connectez-vous pour voir les prix", "signInToOrder": "Connectez-vous pour commander" } + }, + "companies": { + "title": "Entreprises", + "subtitle": "Les organisations pour lesquelles vous pouvez acheter.", + "empty": "Vous n'êtes membre d'aucune entreprise pour le moment.", + "members": "Membres", + "addMember": "Ajouter", + "addMemberPlaceholder": "collegue@exemple.fr", + "addMemberFailed": "Impossible d'ajouter cette personne.", + "removeMember": "Retirer le membre", + "invitationPending": "Invitation envoyée", + "revoke": "Révoquer", + "addresses": "Carnet d'adresses", + "addressesHelp": "Où cette organisation est livrée. Les sites hérités d'une entité parente sont également affichés.", + "viewOrders": "Voir les commandes de l'entreprise", + "ordersTitle": "Commandes de l'entreprise", + "invitationTitle": "Rejoindre {company}", + "invitationBody": "Vous avez été invité à acheter sur {store} en tant que {email}.", + "invitationMissingToken": "Ce lien ne contient pas de code d'invitation.", + "invitationInvalidTitle": "Cette invitation n'est plus valide", + "invitationInvalidBody": "Elle a peut-être déjà été acceptée, révoquée, ou elle a expiré. Demandez-en une nouvelle.", + "invitationWrongAccount": "Cette invitation a été envoyée à {invited}, mais vous êtes connecté en tant que {current}. Connectez-vous avec l'adresse invitée.", + "invitationAcceptFailed": "Impossible d'accepter l'invitation.", + "acceptAsSignedIn": "Accepter l'invitation", + "acceptAndRegister": "Créer un compte et rejoindre", + "accepting": "Acceptation…", + "firstName": "Prénom", + "lastName": "Nom", + "password": "Mot de passe", + "buyingFor": "Achat pour", + "buyPersonally": "Acheter à titre personnel" } } diff --git a/messages/pl.json b/messages/pl.json index 22ac0a33..33a518d1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -332,7 +332,8 @@ "showPassword": "Pokaż hasło", "hidePassword": "Ukryj hasło", "forgotPassword": "Zapomniałeś hasła?", - "policyConsentRequired": "Musisz zaakceptować regulamin sklepu, aby utworzyć konto" + "policyConsentRequired": "Musisz zaakceptować regulamin sklepu, aby utworzyć konto", + "companies": "Firmy" }, "register": { "createAccount": "Utwórz konto", @@ -628,5 +629,36 @@ "signInForPricing": "Zaloguj się, aby zobaczyć ceny", "signInToOrder": "Zaloguj się, aby zamówić" } + }, + "companies": { + "title": "Firmy", + "subtitle": "Organizacje, dla których możesz kupować.", + "empty": "Nie należysz jeszcze do żadnej firmy.", + "members": "Członkowie", + "addMember": "Dodaj", + "addMemberPlaceholder": "wspolpracownik@przyklad.pl", + "addMemberFailed": "Nie udało się dodać tej osoby.", + "removeMember": "Usuń członka", + "invitationPending": "Zaproszenie wysłane", + "revoke": "Cofnij", + "addresses": "Książka adresowa", + "addressesHelp": "Dokąd dostarczane są zamówienia tej organizacji. Pokazujemy też lokalizacje odziedziczone po jednostce nadrzędnej.", + "viewOrders": "Zobacz zamówienia firmy", + "ordersTitle": "Zamówienia firmy", + "invitationTitle": "Dołącz do {company}", + "invitationBody": "Zaproszono Cię do zakupów w {store} jako {email}.", + "invitationMissingToken": "W tym linku brakuje kodu zaproszenia.", + "invitationInvalidTitle": "To zaproszenie jest już nieaktualne", + "invitationInvalidBody": "Mogło zostać przyjęte, cofnięte albo po prostu wygasło. Poproś o nowe.", + "invitationWrongAccount": "To zaproszenie wysłano na {invited}, a jesteś zalogowany jako {current}. Zaloguj się na zaproszony adres.", + "invitationAcceptFailed": "Nie udało się przyjąć zaproszenia.", + "acceptAsSignedIn": "Przyjmij zaproszenie", + "acceptAndRegister": "Załóż konto i dołącz", + "accepting": "Przyjmowanie…", + "firstName": "Imię", + "lastName": "Nazwisko", + "password": "Hasło", + "buyingFor": "Zakup dla", + "buyPersonally": "Kup prywatnie" } } diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx index 79622303..ae2c6cca 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx @@ -21,6 +21,7 @@ import { PaymentSection, type PaymentSectionHandle, } from "@/components/checkout/PaymentSection"; +import { BuyingForSelector } from "@/components/companies/BuyingForSelector"; import { PolicyConsent } from "@/components/policy/PolicyConsent"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { useAuth } from "@/contexts/AuthContext"; @@ -699,6 +700,12 @@ function CheckoutPageContentInner({ : "relative" } > + {/* Whose purchase this is, before the addresses it decides. */} + + {/* Contact + Delivery */}
null), authStatus ? getAddresses() : Promise.resolve({ data: [] as Address[] }), ]); + // Buying for a company ships to the company's sites, so its book replaces + // the buyer's own rather than joining it — offering someone's home address + // beside their employer's invites the mis-click. The set matches what the + // server will accept: the node's own sites and the ones it inherits. + const addressesData = cartData?.company_id + ? await getCompanyAddresses(cartData.company_id) + : personalAddresses; + // Redirect to order-placed if already complete if (cartData?.current_step === "complete") { const basePath = `/${urlCountry}/en`; diff --git a/src/app/[country]/[locale]/(storefront)/account/(authenticated)/companies/[id]/orders/page.tsx b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/companies/[id]/orders/page.tsx new file mode 100644 index 00000000..3bf6bef6 --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/companies/[id]/orders/page.tsx @@ -0,0 +1,46 @@ +import { notFound } from "next/navigation"; +import { connection } from "next/server"; +import { getTranslations } from "next-intl/server"; +import { OrderList } from "@/components/account/OrderList"; +import { getCompany, getCompanyOrders } from "@/lib/data/companies"; + +interface CompanyOrdersPageProps { + params: Promise<{ country: string; locale: string; id: string }>; +} + +/** + * What the organization has bought — every completed order across the node's + * subtree, not only the ones placed by the buyer looking at the page. That is + * the point of a company view: a purchasing team sees the department's spend, + * not just their own. + */ +export default async function CompanyOrdersPage({ + params, +}: CompanyOrdersPageProps) { + await connection(); + const { country, locale, id } = await params; + const t = await getTranslations({ + locale: locale as Locale, + namespace: "companies", + }); + + const [company, orders] = await Promise.all([ + getCompany(id), + getCompanyOrders(id), + ]); + + if (!company) notFound(); + + const basePath = `/${country}/${locale}`; + + return ( +
+
+

{t("ordersTitle")}

+

{company.name}

+
+ + +
+ ); +} diff --git a/src/app/[country]/[locale]/(storefront)/account/(authenticated)/companies/[id]/page.tsx b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/companies/[id]/page.tsx new file mode 100644 index 00000000..fd7476bb --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/companies/[id]/page.tsx @@ -0,0 +1,80 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { connection } from "next/server"; +import { getTranslations } from "next-intl/server"; +import { CompanyAddressBook } from "@/components/companies/CompanyAddressBook"; +import { CompanyMembers } from "@/components/companies/CompanyMembers"; +import { + getCompany, + getCompanyAddresses, + getCompanyInvitations, + getCompanyMembers, +} from "@/lib/data/companies"; +import { getMarketCountries, resolveMarket } from "@/lib/data/markets"; + +interface CompanyPageProps { + params: Promise<{ country: string; locale: string; id: string }>; +} + +/** + * One company node: who may buy for it, and where it takes delivery. + * + * Everything here is reached through standing — the server refuses a node the + * signed-in buyer has no membership over, which is why this page carries no + * permission checks of its own. + */ +export default async function CompanyPage({ params }: CompanyPageProps) { + await connection(); + const { country, locale, id } = await params; + const t = await getTranslations({ + locale: locale as Locale, + namespace: "companies", + }); + + const [company, members, invitations, addresses, market] = await Promise.all([ + getCompany(id), + getCompanyMembers(id), + getCompanyInvitations(id), + getCompanyAddresses(id), + resolveMarket(country).catch(() => null), + ]); + + if (!company) notFound(); + + const countriesResponse = market + ? await getMarketCountries(market.id).catch(() => ({ data: [] })) + : { data: [] }; + + const basePath = `/${country}/${locale}`; + + return ( +
+
+ {company.ancestors.length > 0 && ( +

+ {company.ancestors.map((node) => node.name).join(" / ")} +

+ )} +

{company.name}

+ + {t("viewOrders")} + +
+ + + + +
+ ); +} diff --git a/src/app/[country]/[locale]/(storefront)/account/(authenticated)/companies/page.tsx b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/companies/page.tsx new file mode 100644 index 00000000..7b66dd73 --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/companies/page.tsx @@ -0,0 +1,74 @@ +import { Building2 } from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { connection } from "next/server"; +import { getTranslations } from "next-intl/server"; +import { getMyCompanies } from "@/lib/data/companies"; + +interface CompaniesPageProps { + params: Promise<{ country: string; locale: string }>; +} + +/** + * The buyer's standing — every company node they may act for. A buyer with one + * membership is sent straight to it: a list of one is a page they would only + * ever click through. + */ +export default async function CompaniesPage({ params }: CompaniesPageProps) { + await connection(); + const { country, locale } = await params; + const t = await getTranslations({ + locale: locale as Locale, + namespace: "companies", + }); + + const { data: memberships } = await getMyCompanies(); + const basePath = `/${country}/${locale}`; + + if (memberships.length === 1) { + redirect(`${basePath}/account/companies/${memberships[0].company.id}`); + } + + return ( +
+
+

{t("title")}

+

{t("subtitle")}

+
+ + {memberships.length === 0 ? ( +
+ +

{t("empty")}

+
+ ) : ( +
    + {memberships.map((membership) => ( +
  • + + + + + {membership.company.name} + + {/* The path to the root, so two nodes with the same name in + different parts of the tree are told apart. */} + {membership.company.ancestors.length > 0 && ( + + {membership.company.ancestors + .map((node) => node.name) + .join(" / ")} + + )} + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/[country]/[locale]/(storefront)/account/(authenticated)/layout.tsx b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/layout.tsx index 0168a8a7..f5a9d541 100644 --- a/src/app/[country]/[locale]/(storefront)/account/(authenticated)/layout.tsx +++ b/src/app/[country]/[locale]/(storefront)/account/(authenticated)/layout.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { Suspense } from "react"; import { AuthenticatedAccountShell } from "@/components/account/AuthenticatedAccountShell"; import { REQUEST_PATHNAME_HEADER, REQUEST_SEARCH_HEADER } from "@/i18n/routing"; +import { getMyCompanies } from "@/lib/data/companies"; import { getAccessToken, getRefreshToken } from "@/lib/spree"; import { buildAccountLoginHref, @@ -43,8 +44,15 @@ export async function AuthenticatedAccountLayoutContent({ // the customer is anonymous. if (!accessToken && !refreshToken) redirect(loginHref); + // Resolved once for the whole account area: the nav only offers companies to + // a buyer who has standing over one. + const { data: memberships } = await getMyCompanies(); + return ( - + 0} + > {children} ); diff --git a/src/app/[country]/[locale]/(storefront)/account/company-invitation/page.tsx b/src/app/[country]/[locale]/(storefront)/account/company-invitation/page.tsx new file mode 100644 index 00000000..a5c22033 --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/account/company-invitation/page.tsx @@ -0,0 +1,69 @@ +import { Building2 } from "lucide-react"; +import { connection } from "next/server"; +import { getTranslations } from "next-intl/server"; +import { CompanyInvitationAcceptance } from "@/components/companies/CompanyInvitationAcceptance"; +import { lookupCompanyInvitation } from "@/lib/data/companies"; +import { getCustomer } from "@/lib/data/customer"; + +interface InvitationPageProps { + params: Promise<{ country: string; locale: string }>; + searchParams: Promise<{ token?: string }>; +} + +/** + * Where the invite email lands. Deliberately outside the authenticated shell: + * the invitee usually has no account yet, and the token from the email is the + * only credential the flow needs. + */ +export default async function CompanyInvitationPage({ + params, + searchParams, +}: InvitationPageProps) { + await connection(); + const { country, locale } = await params; + const { token } = await searchParams; + const t = await getTranslations({ + locale: locale as Locale, + namespace: "companies", + }); + + const basePath = `/${country}/${locale}`; + + if (!token) { + return ( +
+

{t("invitationMissingToken")}

+
+ ); + } + + // A spent, revoked or expired token resolves to nothing — the server keeps + // those out of the pending scope, so there is no invitation to show. + const [invitation, customer] = await Promise.all([ + lookupCompanyInvitation(token), + getCustomer().catch(() => null), + ]); + + if (!invitation) { + return ( +
+ +

+ {t("invitationInvalidTitle")} +

+

+ {t("invitationInvalidBody")} +

+
+ ); + } + + return ( + + ); +} diff --git a/src/app/[country]/[locale]/(storefront)/cart/page.tsx b/src/app/[country]/[locale]/(storefront)/cart/page.tsx index a17154cb..a4a1031d 100644 --- a/src/app/[country]/[locale]/(storefront)/cart/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/cart/page.tsx @@ -8,6 +8,7 @@ import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"; import { QuantityPickerField } from "@/components/cart/QuantityPickerField"; +import { BuyingForSelector } from "@/components/companies/BuyingForSelector"; import { Button } from "@/components/ui/button"; import { ProductImage } from "@/components/ui/product-image"; import { useCart } from "@/contexts/CartContext"; @@ -96,6 +97,15 @@ export default function CartPage() { {t("shoppingCart")} + {/* Above the items: the prices below are the ones this audience gets, + so the choice belongs before the buyer reads them. */} +
+ +
+
{/* Cart Items */}
diff --git a/src/components/account/AccountShell.tsx b/src/components/account/AccountShell.tsx index f63f6168..e80bb0ff 100644 --- a/src/components/account/AccountShell.tsx +++ b/src/components/account/AccountShell.tsx @@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react"; import { + Building2, CreditCard, Gift, Home, @@ -17,7 +18,10 @@ import { Button } from "@/components/ui/button"; import { useAuth } from "@/contexts/AuthContext"; import { extractBasePath } from "@/lib/utils/path"; -function getNavItems(t: ReturnType>): { +function getNavItems( + t: ReturnType>, + hasCompanies: boolean, +): { href: string; label: string; icon: LucideIcon; @@ -25,6 +29,17 @@ function getNavItems(t: ReturnType>): { return [ { href: "/account", label: t("overview"), icon: Home }, { href: "/account/orders", label: t("orders"), icon: ShoppingBag }, + // Only for buyers who actually act for one — an ordinary shopper never + // sees that companies exist. + ...(hasCompanies + ? [ + { + href: "/account/companies", + label: t("companies"), + icon: Building2, + }, + ] + : []), { href: "/account/addresses", label: t("addresses"), icon: MapPin }, { href: "/account/credit-cards", @@ -36,13 +51,19 @@ function getNavItems(t: ReturnType>): { ]; } -export function AccountShell({ children }: { children: React.ReactNode }) { +export function AccountShell({ + children, + hasCompanies = false, +}: { + children: React.ReactNode; + hasCompanies?: boolean; +}) { const t = useTranslations("account"); const pathname = usePathname(); const router = useRouter(); const basePath = extractBasePath(pathname); const { user, logout } = useAuth(); - const navItems = getNavItems(t); + const navItems = getNavItems(t, hasCompanies); const handleLogout = async () => { await logout(); diff --git a/src/components/account/AuthenticatedAccountShell.tsx b/src/components/account/AuthenticatedAccountShell.tsx index 67b596ff..06568341 100644 --- a/src/components/account/AuthenticatedAccountShell.tsx +++ b/src/components/account/AuthenticatedAccountShell.tsx @@ -29,6 +29,7 @@ function SessionFallback() { interface AuthenticatedAccountShellProps { children: React.ReactNode; + hasCompanies?: boolean; loginHref: string; } @@ -39,6 +40,7 @@ interface AuthenticatedAccountShellProps { */ export function AuthenticatedAccountShell({ children, + hasCompanies, loginHref, }: AuthenticatedAccountShellProps) { const router = useRouter(); @@ -50,5 +52,5 @@ export function AuthenticatedAccountShell({ if (loading || !isAuthenticated) return ; - return {children}; + return {children}; } diff --git a/src/components/addresses/AddressManagement.tsx b/src/components/addresses/AddressManagement.tsx index 16b06b78..c2fedb2f 100644 --- a/src/components/addresses/AddressManagement.tsx +++ b/src/components/addresses/AddressManagement.tsx @@ -89,12 +89,32 @@ function AddressCard({ address, onEdit, onDelete }: AddressCardProps) { ); } +/** + * The mutations a book needs. Injected because the same book is kept by two + * owners — a shopper keeps their own, a company keeps its sites — and only + * where the rows live differs. + */ +export interface AddressBookActions { + create: (data: AddressParams) => Promise<{ + success: boolean; + error?: string; + address?: Address; + }>; + update: ( + id: string, + data: AddressParams, + ) => Promise<{ success: boolean; error?: string; address?: Address }>; + remove: (id: string) => Promise<{ success: boolean; error?: string }>; +} + interface AddressManagementProps { initialAddresses: Address[]; countries: Country[]; showAddButton: boolean; emptyState: boolean; user?: User | null; + /** Defaults to the signed-in shopper's own book. */ + actions?: AddressBookActions; } export function AddressManagement({ @@ -103,7 +123,13 @@ export function AddressManagement({ showAddButton, emptyState, user, + actions, }: AddressManagementProps) { + const book: AddressBookActions = actions ?? { + create: createAddress, + update: updateAddress, + remove: deleteAddress, + }; const t = useTranslations("address"); const [addresses, setAddresses] = useState(initialAddresses); const [modalOpen, setModalOpen] = useState(false); @@ -130,7 +156,7 @@ export function AddressManagement({ const handleSave = async (data: AddressParams, id?: string) => { if (id) { - const result = await updateAddress(id, data); + const result = await book.update(id, data); if (!result.success) { throw new Error(result.error); } @@ -141,7 +167,7 @@ export function AddressManagement({ ); } } else { - const result = await createAddress(data); + const result = await book.create(data); if (!result.success) { throw new Error(result.error); } @@ -153,7 +179,7 @@ export function AddressManagement({ }; const handleDelete = async (id: string) => { - const result = await deleteAddress(id); + const result = await book.remove(id); if (result.success) { setAddresses((prev) => prev.filter((addr) => addr.id !== id)); } else { diff --git a/src/components/companies/BuyingForSelector.tsx b/src/components/companies/BuyingForSelector.tsx new file mode 100644 index 00000000..fcd68f19 --- /dev/null +++ b/src/components/companies/BuyingForSelector.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Building2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useState, useTransition } from "react"; +import { setCartCompany } from "@/lib/data/cart"; +import { + type CompanyMembershipWithCompany, + getMyCompanies, +} from "@/lib/data/companies"; + +/** + * Who this basket is for. It sits on the cart as well as checkout because + * pricing reads the cart's company: chosen late, the buyer reviews a basket + * priced for the wrong audience and watches the totals move at the last step. + * + * A buyer with one standing sees a statement rather than a choice — the server + * resolves that case on its own, and a select with one option is a decision + * nobody has to make. Buying personally stays possible: clearing the company + * clears its catalog, prices and tax anchoring with it. + */ +export function BuyingForSelector({ + cartId, + selectedCompanyId, + memberships: providedMemberships, +}: { + cartId: string; + selectedCompanyId: string | null; + /** Pass them where the page already fetched them; otherwise loaded here. */ + memberships?: CompanyMembershipWithCompany[]; +}) { + const t = useTranslations("companies"); + const [pending, startTransition] = useTransition(); + const [loaded, setLoaded] = useState( + providedMemberships ?? null, + ); + + // Standing is a property of the signed-in buyer, not of the cart, so it is + // fetched rather than derived — and only when the caller had no reason to + // have it already. + useEffect(() => { + if (providedMemberships) return; + let active = true; + getMyCompanies().then((result) => { + if (active) setLoaded(result.data); + }); + return () => { + active = false; + }; + }, [providedMemberships]); + + const memberships = loaded ?? []; + if (memberships.length === 0) return null; + + const handleChange = (value: string) => { + startTransition(async () => { + await setCartCompany(cartId, value === "" ? null : value); + }); + }; + + return ( +
+ + {t("buyingFor")} + + {memberships.length === 1 && selectedCompanyId ? ( + {memberships[0].company.name} + ) : ( + + )} +
+ ); +} diff --git a/src/components/companies/CompanyAddressBook.tsx b/src/components/companies/CompanyAddressBook.tsx new file mode 100644 index 00000000..0edfa7d6 --- /dev/null +++ b/src/components/companies/CompanyAddressBook.tsx @@ -0,0 +1,54 @@ +"use client"; + +import type { Address, AddressParams, Country } from "@spree/sdk"; +import { useTranslations } from "next-intl"; +import { AddressManagement } from "@/components/addresses/AddressManagement"; +import { + createCompanyAddress, + deleteCompanyAddress, + updateCompanyAddress, +} from "@/lib/data/companies"; + +/** + * The node's sites — and the ones it inherits from the nodes above it, which + * is the same set checkout will let a buyer ship to. + * + * The book itself is the shopper's address book component: a company keeps + * addresses the same way a person does, and only where the rows live differs. + */ +export function CompanyAddressBook({ + companyId, + addresses, + countries, +}: { + companyId: string; + addresses: Address[]; + countries: Country[]; +}) { + const t = useTranslations("companies"); + + return ( +
+
+

{t("addresses")}

+

{t("addressesHelp")}

+
+ +
+ + createCompanyAddress(companyId, data), + update: (id: string, data: AddressParams) => + updateCompanyAddress(companyId, id, data), + remove: (id: string) => deleteCompanyAddress(companyId, id), + }} + /> +
+
+ ); +} diff --git a/src/components/companies/CompanyInvitationAcceptance.tsx b/src/components/companies/CompanyInvitationAcceptance.tsx new file mode 100644 index 00000000..8a0d07af --- /dev/null +++ b/src/components/companies/CompanyInvitationAcceptance.tsx @@ -0,0 +1,153 @@ +"use client"; + +import type { CompanyInvitation } from "@spree/sdk"; +import { Building2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { acceptCompanyInvitation } from "@/lib/data/companies"; + +/** + * Accepting an invitation, for the two people who can arrive here. + * + * Someone with no account registers: the account is created with the invited + * address, which is why the email field is fixed. Someone already signed in + * simply binds their account — but only if it is the invited address, since + * the token names one person and the server refuses anyone else. + */ +export function CompanyInvitationAcceptance({ + token, + invitation, + basePath, + signedInEmail, +}: { + token: string; + invitation: CompanyInvitation & { company_name: string; store_name: string }; + basePath: string; + signedInEmail: string | null; +}) { + const t = useTranslations("companies"); + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [password, setPassword] = useState(""); + + const wrongAccount = + signedInEmail !== null && + signedInEmail.toLowerCase() !== invitation.email.toLowerCase(); + + const accept = (registration?: { + first_name: string; + last_name: string; + password: string; + password_confirmation: string; + }) => { + startTransition(async () => { + const result = await acceptCompanyInvitation(token, registration); + if (result.success) { + router.push(`${basePath}/account/companies`); + } else { + setError(result.error ?? t("invitationAcceptFailed")); + } + }); + }; + + return ( +
+
+ +

+ {t("invitationTitle", { company: invitation.company_name })} +

+

+ {t("invitationBody", { + store: invitation.store_name, + email: invitation.email, + })} +

+
+ + {wrongAccount ? ( + // The token names one person. Saying so plainly beats letting them + // submit and reading a refusal from the server. +

+ {t("invitationWrongAccount", { + invited: invitation.email, + current: signedInEmail, + })} +

+ ) : signedInEmail ? ( + + ) : ( +
{ + event.preventDefault(); + accept({ + first_name: firstName, + last_name: lastName, + password, + password_confirmation: password, + }); + }} + > + + + {t("firstName")} + + setFirstName(event.target.value)} + required + /> + + + + {t("lastName")} + + setLastName(event.target.value)} + required + /> + + + + {t("password")} + + setPassword(event.target.value)} + required + /> + + +
+ )} + + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/src/components/companies/CompanyMembers.tsx b/src/components/companies/CompanyMembers.tsx new file mode 100644 index 00000000..c2a2d75c --- /dev/null +++ b/src/components/companies/CompanyMembers.tsx @@ -0,0 +1,131 @@ +"use client"; + +import type { CompanyInvitation, CompanyMembership } from "@spree/sdk"; +import { Mail, Trash2, UserPlus } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + addCompanyMember, + removeCompanyMember, + revokeCompanyInvitation, +} from "@/lib/data/companies"; + +/** + * The people who may act for this node, and the invitations still outstanding. + * + * Adding takes an email and nothing else: the server turns it into a + * membership when that person already has an account and an emailed invitation + * when they do not, so the buyer never has to know which case they are in. + * + * Every member may manage the directory — the open-source edition ships no + * company roles, and the server enforces standing rather than rank. + */ +export function CompanyMembers({ + companyId, + members, + invitations, +}: { + companyId: string; + members: CompanyMembership[]; + invitations: CompanyInvitation[]; +}) { + const t = useTranslations("companies"); + const [email, setEmail] = useState(""); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + const handleAdd = (event: React.FormEvent) => { + event.preventDefault(); + if (!email.trim()) return; + + startTransition(async () => { + const result = await addCompanyMember(companyId, email.trim()); + if (result.success) { + setEmail(""); + setError(null); + } else { + setError(result.error ?? t("addMemberFailed")); + } + }); + }; + + return ( +
+
+

{t("members")}

+
+ +
+ {members.map((member) => ( +
+ + {member.email} + + +
+ ))} + + {invitations.map((invitation) => ( +
+ + + {invitation.email} + + {t("invitationPending")} + + + +
+ ))} +
+ +
+ setEmail(event.target.value)} + placeholder={t("addMemberPlaceholder")} + aria-label={t("addMember")} + disabled={pending} + /> + +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/src/lib/data/cart.ts b/src/lib/data/cart.ts index 843406e9..2423713e 100644 --- a/src/lib/data/cart.ts +++ b/src/lib/data/cart.ts @@ -236,3 +236,28 @@ export async function associateCartWithUser( return {}; }, "Failed to associate cart"); } + +/** + * Says who this basket is for. Standing is enforced server-side — a cart + * cannot name a company its buyer may not act for — so a refused id comes back + * as a failed result rather than being silently ignored. + * + * Passing null returns the basket to a personal purchase, dropping the + * company's catalog, pricing and tax anchoring with it. + */ +export async function setCartCompany( + cartId: string, + companyId: string | null, + surface: Surface = DEFAULT_SURFACE, +) { + return actionResult(async () => { + const spreeToken = await getCartToken(surface); + const cart = await getClientForSurface(surface).carts.update( + cartId, + { company_id: companyId }, + { spreeToken }, + ); + updateTag(cartTag(surface)); + return { cart }; + }, "Failed to set the company for this cart"); +} diff --git a/src/lib/data/companies.ts b/src/lib/data/companies.ts new file mode 100644 index 00000000..099c9d55 --- /dev/null +++ b/src/lib/data/companies.ts @@ -0,0 +1,232 @@ +"use server"; + +import type { + Address, + Company, + CompanyAddressParams, + CompanyInvitation, + CompanyMembership, + Order, +} from "@spree/sdk"; +import { updateTag } from "next/cache"; +import { getClient, withAuthRefresh } from "@/lib/spree"; +import { actionResult, withFallback } from "./utils"; + +/** + * The buyer's own standing — the companies they may act for. Empty for an + * ordinary shopper, which is what hides the whole company section. + */ +/** The membership rows the account endpoint returns, each with its node. */ +export type CompanyMembershipWithCompany = CompanyMembership & { + company: Company; +}; + +export async function getMyCompanies() { + return withFallback( + async () => { + return withAuthRefresh(async (options) => { + return getClient().account.companies(options); + }); + }, + { data: [] } as { data: CompanyMembershipWithCompany[] }, + ); +} + +export async function getCompany(id: string) { + return withFallback(async () => { + return withAuthRefresh(async (options) => { + return getClient().companies.get(id, options); + }); + }, null); +} + +export async function updateCompany(id: string, params: { name?: string }) { + return actionResult(async () => { + const result = await withAuthRefresh(async (options) => { + return getClient().companies.update(id, params, options); + }); + updateTag("companies"); + return { company: result as Company }; + }, "Failed to update company"); +} + +export async function getCompanyMembers(companyId: string) { + return withFallback( + async () => { + return withAuthRefresh(async (options) => { + return getClient().companies.members.list( + companyId, + undefined, + options, + ); + }); + }, + { data: [] } as { data: CompanyMembership[] }, + ); +} + +/** + * Adds someone by email. The server decides what that means: an existing + * customer becomes a member at once, anyone else is emailed an invitation — + * so the response is one or the other, told apart by its id prefix. + */ +export async function addCompanyMember(companyId: string, email: string) { + return actionResult(async () => { + const result = await withAuthRefresh(async (options) => { + return getClient().companies.members.create( + companyId, + { customer_email: email }, + options, + ); + }); + updateTag("companies"); + return { member: result }; + }, "Failed to add member"); +} + +export async function removeCompanyMember(companyId: string, id: string) { + return actionResult(async () => { + await withAuthRefresh(async (options) => { + return getClient().companies.members.delete(companyId, id, options); + }); + updateTag("companies"); + return {}; + }, "Failed to remove member"); +} + +export async function getCompanyInvitations(companyId: string) { + return withFallback( + async () => { + return withAuthRefresh(async (options) => { + return getClient().companies.invitations.list( + companyId, + undefined, + options, + ); + }); + }, + { data: [] } as { data: CompanyInvitation[] }, + ); +} + +/** Revokes rather than erases: the emailed token then stops resolving. */ +export async function revokeCompanyInvitation(companyId: string, id: string) { + return actionResult(async () => { + await withAuthRefresh(async (options) => { + return getClient().companies.invitations.delete(companyId, id, options); + }); + updateTag("companies"); + return {}; + }, "Failed to revoke invitation"); +} + +/** + * The node's sites and the ones it inherits from its ancestors — the same set + * checkout will accept an address id from. + */ +export async function getCompanyAddresses(companyId: string) { + return withFallback( + async () => { + return withAuthRefresh(async (options) => { + return getClient().companies.addresses.list( + companyId, + undefined, + options, + ); + }); + }, + { data: [] } as { data: Address[] }, + ); +} + +export async function createCompanyAddress( + companyId: string, + address: CompanyAddressParams, +) { + return actionResult(async () => { + const result = await withAuthRefresh(async (options) => { + return getClient().companies.addresses.create( + companyId, + address, + options, + ); + }); + updateTag("companies"); + return { address: result }; + }, "Failed to create address"); +} + +export async function updateCompanyAddress( + companyId: string, + id: string, + address: CompanyAddressParams, +) { + return actionResult(async () => { + const result = await withAuthRefresh(async (options) => { + return getClient().companies.addresses.update( + companyId, + id, + address, + options, + ); + }); + updateTag("companies"); + return { address: result }; + }, "Failed to update address"); +} + +export async function deleteCompanyAddress(companyId: string, id: string) { + return actionResult(async () => { + await withAuthRefresh(async (options) => { + return getClient().companies.addresses.delete(companyId, id, options); + }); + updateTag("companies"); + return {}; + }, "Failed to delete address"); +} + +/** Completed purchases across the node's subtree, not just this node. */ +export async function getCompanyOrders(companyId: string, page = 1) { + return withFallback( + async () => { + return withAuthRefresh(async (options) => { + return getClient().companies.orders.list(companyId, { page }, options); + }); + }, + { data: [] } as { data: Order[] }, + ); +} + +/** + * The invitation behind a token from the invite email. Deliberately + * unauthenticated — the invitee has no account yet, which is the whole point + * of the flow. + */ +export async function lookupCompanyInvitation(token: string) { + return withFallback(async () => { + return getClient().companyInvitations.lookup(token); + }, null); +} + +/** + * Accepts by token. Pass registration details for someone with no account — + * it is created with the invited email — or call it authenticated to bind the + * invitation to the signed-in customer. + */ +export async function acceptCompanyInvitation( + token: string, + params?: { + first_name?: string; + last_name?: string; + password?: string; + password_confirmation?: string; + }, +) { + return actionResult(async () => { + const result = await withAuthRefresh(async (options) => { + return getClient().companyInvitations.accept(token, params, options); + }); + updateTag("companies"); + return { membership: result }; + }, "Failed to accept invitation"); +} From 2c0080908454d05a446c61c5859b94ebc76aea11 Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Wed, 26 Aug 2026 15:59:42 +0200 Subject: [PATCH 2/3] Catch the checkout address writes up with the ISO rename The build has been failing on 6-0-dev: the Store API renamed an address's country field to country_code, and the storefront still sent country_iso, which is now a read-only alias. Fixed at the boundary rather than through the flow. formDataToAddress is the one place form state becomes API params, so the form keeps the names the checkout components have always used and only the conversion knows about the rename. Express checkout builds its params directly, so those two call sites are renamed as well. --- src/app/[country]/[locale]/layout.test.tsx | 8 ++++---- src/components/checkout/ExpressCheckoutButton.tsx | 2 +- src/lib/data/express-checkout-flow.ts | 2 +- src/lib/utils/address.ts | 5 ++++- src/lib/utils/express-checkout.ts | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/app/[country]/[locale]/layout.test.tsx b/src/app/[country]/[locale]/layout.test.tsx index 615a1c56..c346dd13 100644 --- a/src/app/[country]/[locale]/layout.test.tsx +++ b/src/app/[country]/[locale]/layout.test.tsx @@ -63,7 +63,7 @@ function market(overrides: Partial = {}): Market { default_locale: "en", tax_inclusive: false, default: true, - country_isos: ["US"], + country_codes: ["US"], supported_locales: ["en"], countries: [country("US")], ...overrides, @@ -81,7 +81,7 @@ describe("CountryLocaleLayout Market fallback", () => { market({ default_locale: "es", supported_locales: ["es"], - country_isos: ["AR"], + country_codes: ["AR"], countries: [country("AR")], }), ], @@ -119,7 +119,7 @@ describe("CountryLocaleLayout Market fallback", () => { default: false, default_locale: "de", supported_locales: ["de", "es", "fr"], - country_isos: ["PL"], + country_codes: ["PL"], countries: [country("PL")], }), ], @@ -163,7 +163,7 @@ describe("CountryLocaleLayout Market fallback", () => { ["an empty Markets response", []], [ "a Market without countries", - [market({ countries: [], country_isos: [] })], + [market({ countries: [], country_codes: [] })], ], ])("does not redirect the default route to itself for %s", async (_, markets) => { mocks.getMarkets.mockResolvedValue({ data: markets }); diff --git a/src/components/checkout/ExpressCheckoutButton.tsx b/src/components/checkout/ExpressCheckoutButton.tsx index 58b07370..3d7def33 100644 --- a/src/components/checkout/ExpressCheckoutButton.tsx +++ b/src/components/checkout/ExpressCheckoutButton.tsx @@ -121,7 +121,7 @@ function ExpressCheckoutInner({ const result = await expressCheckoutResolveShipping(cart.id, { city: address.city, postal_code: address.postal_code, - country_iso: address.country, + country_code: address.country, state_name: address.state || undefined, }); diff --git a/src/lib/data/express-checkout-flow.ts b/src/lib/data/express-checkout-flow.ts index 612f37be..07257191 100644 --- a/src/lib/data/express-checkout-flow.ts +++ b/src/lib/data/express-checkout-flow.ts @@ -16,7 +16,7 @@ import { actionResult } from "@/lib/data/utils"; export interface ExpressCheckoutPartialAddress { city: string; postal_code: string; - country_iso: string; + country_code: string; state_name?: string; } diff --git a/src/lib/utils/address.ts b/src/lib/utils/address.ts index fc1a27c7..9a3a53bc 100644 --- a/src/lib/utils/address.ts +++ b/src/lib/utils/address.ts @@ -69,7 +69,10 @@ export function formDataToAddress(data: AddressFormData): AddressParams { postal_code: data.postal_code, phone: data.phone || undefined, company: data.company || undefined, - country_iso: data.country_iso, + // The form keeps the names the storefront has always used; the API took + // the ISO-code rename, so the conversion happens here at the boundary + // rather than through every field in the checkout flow. + country_code: data.country_iso, state_abbr: data.state_abbr || undefined, state_name: data.state_name || undefined, }; diff --git a/src/lib/utils/express-checkout.ts b/src/lib/utils/express-checkout.ts index bbe0b318..3771d2ed 100644 --- a/src/lib/utils/express-checkout.ts +++ b/src/lib/utils/express-checkout.ts @@ -128,7 +128,7 @@ export function buildSpreeAddress( address2: address.line2 || undefined, city: address.city, postal_code: address.postal_code, - country_iso: address.country, + country_code: address.country, state_name: address.state || undefined, phone: phone || undefined, }; From 58e413b59ec910c1a62c621bd7411ff14e6561e7 Mon Sep 17 00:00:00 2001 From: Damian Legawiec Date: Wed, 26 Aug 2026 16:34:26 +0200 Subject: [PATCH 3/3] Type the company address update as partial Matches the customer's own address book and what the endpoint accepts: a lone label or default flag, without resending the whole address. --- src/lib/data/companies.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/data/companies.ts b/src/lib/data/companies.ts index 099c9d55..716d36a2 100644 --- a/src/lib/data/companies.ts +++ b/src/lib/data/companies.ts @@ -159,7 +159,7 @@ export async function createCompanyAddress( export async function updateCompanyAddress( companyId: string, id: string, - address: CompanyAddressParams, + address: Partial, ) { return actionResult(async () => { const result = await withAuthRefresh(async (options) => {