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 (
+
+ );
+}
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/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/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/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/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 (
+
+ );
+}
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 (
+
+
+
+ );
+}
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 (
+
+
+