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/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 ( +
+ + {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..716d36a2 --- /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: Partial, +) { + 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"); +} 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, };