HextorUI
block

Storefront — Account

Overview, address book and profile, with marketing consent presented as a real choice.

npx shadcn@latest add @hextor/store-account

Source

"use client";

import { useState } from "react";
import Link from "next/link";
import { MapPin, PackageSearch, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AddressBook, type AccountAddress } from "@/registry/hextor/components/store/storefront/address-book";
import { EmptyState } from "@/registry/hextor/components/store/storefront/empty-state";
import { OrderHistoryRow } from "@/registry/hextor/components/store/storefront/order-history-row";
import {
  StoreHeader,
  type StoreHeaderProps,
} from "@/registry/hextor/components/store/storefront/store-header";
import {
  StoreFooter,
  type StoreFooterProps,
} from "@/registry/hextor/components/store/storefront/store-footer";
import { cn } from "@/registry/hextor/lib/utils";
import type { Address, Customer, Order } from "@/registry/hextor/lib/store";
import {
  resolveLabels,
  type AccountLabelsOverrides,
} from "@/registry/hextor/components/store/storefront/account-labels";

export interface ProfileFormValues {
  name: string;
  email: string;
  acceptsMarketing: boolean;
}

export interface AccountPageProps {
  customer: Customer;
  addresses: AccountAddress[];
  /** A short preview for the overview tab — the caller decides how many (the route slices `orders` before passing them here). */
  recentOrders: Order[];
  ordersHref?: string;
  orderHref?: (order: Order) => string;
  onReorder?: (order: Order) => void;
  onSaveAddress: (address: Address, id?: string) => void;
  onDeleteAddress: (id: string) => void;
  onSetDefaultAddress: (id: string) => void;
  onSaveProfile: (profile: ProfileFormValues) => void;
  shopHref?: string;
  header: StoreHeaderProps;
  footer: StoreFooterProps;
  locale?: string;
  labels?: AccountLabelsOverrides;
  className?: string;
}

type AccountTab = "overview" | "addresses" | "profile";

/**
 * The customer-account shell: an overview (greeting, default address,
 * recent orders, quick links) plus addresses and profile as tabs on the
 * same page — Base UI `Tabs`, fully controlled, so a quick link on the
 * overview can jump straight to the addresses tab without a route change.
 * Order history gets its own page (`OrderHistoryView`) rather than a
 * fourth tab: a customer with two years of orders needs a real list, not a
 * panel competing for space with everything else here.
 */
export default function AccountView({
  customer,
  addresses,
  recentOrders,
  ordersHref = "/account/orders",
  orderHref,
  onReorder,
  onSaveAddress,
  onDeleteAddress,
  onSetDefaultAddress,
  onSaveProfile,
  shopHref = "/",
  header,
  footer,
  locale,
  labels,
  className,
}: AccountPageProps) {
  const copy = resolveLabels(labels);
  const [tab, setTab] = useState<AccountTab>("overview");

  const defaultAddress = addresses.find((a) => a.isDefault)?.address ?? customer.defaultAddress;

  const rowLabels = {
    payment: copy.statuses.payment,
    fulfillment: copy.statuses.fulfillment,
    itemsCount: copy.orders.itemsCount,
    reorder: copy.orders.reorder,
  };

  return (
    <div className={cn("flex min-h-screen flex-col", className)}>
      <StoreHeader {...header} />

      <main className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 px-4 py-8 sm:px-6">
        <div className="flex flex-col gap-2">
          <span aria-hidden="true" className="h-[3px] w-8 rounded-full bg-brand" />
          <h1 className="font-heading text-3xl font-semibold tracking-tight sm:text-4xl">
            {copy.overview.greeting(customer.name)}
          </h1>
        </div>

        <Tabs value={tab} onValueChange={(next) => setTab(next as AccountTab)}>
          <TabsList className="w-fit">
            <TabsTrigger value="overview">{copy.nav.overview}</TabsTrigger>
            <TabsTrigger value="addresses">{copy.nav.addresses}</TabsTrigger>
            <TabsTrigger value="profile">{copy.nav.profile}</TabsTrigger>
          </TabsList>

          <TabsContent value="overview" className="flex flex-col gap-8 pt-2">
            <section className="flex flex-col gap-3">
              <span className="text-sm font-medium">{copy.overview.defaultAddressHeading}</span>
              {defaultAddress ? (
                <div className="flex items-start gap-3 rounded-xl p-4 text-sm ring-1 ring-foreground/10">
                  <MapPin aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
                  <div>
                    <p className="font-medium">{defaultAddress.name}</p>
                    <p className="text-muted-foreground">{defaultAddress.line1}</p>
                    <p className="text-muted-foreground">
                      {defaultAddress.city}
                      {defaultAddress.province ? `, ${defaultAddress.province}` : ""} {defaultAddress.postalCode}
                    </p>
                  </div>
                </div>
              ) : (
                <p className="text-sm text-muted-foreground">{copy.overview.noDefaultAddress}</p>
              )}
            </section>

            <Separator />

            <section className="flex flex-col gap-3">
              <div className="flex items-center justify-between">
                <span className="text-sm font-medium">{copy.overview.recentOrdersHeading}</span>
                {recentOrders.length > 0 && (
                  <Link href={ordersHref} className="text-sm text-muted-foreground transition-colors hover:text-brand">
                    {copy.overview.viewAllOrders}
                  </Link>
                )}
              </div>
              {recentOrders.length === 0 ? (
                <EmptyState
                  icon={PackageSearch}
                  title={copy.overview.noRecentOrdersTitle}
                  description={copy.overview.noRecentOrdersDescription}
                  action={<Button size="sm" render={<Link href={shopHref}>{copy.overview.browseCta}</Link>} />}
                />
              ) : (
                <div className="divide-y divide-border rounded-2xl px-4 ring-1 ring-foreground/10 sm:px-6">
                  {recentOrders.map((order) => (
                    <OrderHistoryRow
                      key={order.id}
                      order={order}
                      orderHref={orderHref?.(order)}
                      onReorder={onReorder}
                      locale={locale}
                      labels={rowLabels}
                    />
                  ))}
                </div>
              )}
            </section>

            <Separator />

            <section className="grid grid-cols-1 gap-3 sm:grid-cols-3">
              <Button variant="outline" render={<Link href={ordersHref}>{copy.overview.quickLinks.orders}</Link>} />
              <Button variant="outline" onClick={() => setTab("addresses")}>
                {copy.overview.quickLinks.addresses}
              </Button>
              <Button variant="outline" onClick={() => setTab("profile")}>
                {copy.overview.quickLinks.profile}
              </Button>
            </section>
          </TabsContent>

          <TabsContent value="addresses" className="pt-2">
            <AddressBook
              addresses={addresses}
              onSave={onSaveAddress}
              onDelete={onDeleteAddress}
              onSetDefault={onSetDefaultAddress}
              labels={copy.addresses}
            />
          </TabsContent>

          <TabsContent value="profile" className="pt-2">
            <ProfileForm
              customer={customer}
              onSave={onSaveProfile}
              labels={copy.profile}
            />
          </TabsContent>
        </Tabs>
      </main>

      <StoreFooter {...footer} />
    </div>
  );
}

function ProfileForm({
  customer,
  onSave,
  labels,
}: {
  customer: Customer;
  onSave: (profile: ProfileFormValues) => void;
  labels: ReturnType<typeof resolveLabels>["profile"];
}) {
  const [name, setName] = useState(customer.name);
  const [email, setEmail] = useState(customer.email);
  // Reflects the customer's actual, previously-recorded choice — never
  // defaulted to checked. Marketing consent is opt-in, and its absence is
  // not consent (see store.ts's own note on `acceptsMarketing`).
  const [acceptsMarketing, setAcceptsMarketing] = useState(customer.acceptsMarketing);
  const [justSaved, setJustSaved] = useState(false);

  return (
    <form
      className="flex max-w-md flex-col gap-4"
      onSubmit={(e) => {
        e.preventDefault();
        onSave({ name, email, acceptsMarketing });
        setJustSaved(true);
      }}
    >
      <div className="flex items-center gap-2">
        <User aria-hidden="true" className="size-4 text-muted-foreground" />
        <p className="text-sm text-muted-foreground">{labels.description}</p>
      </div>

      <div className="flex flex-col gap-1.5">
        <Label htmlFor="profile-name">{labels.nameLabel}</Label>
        <Input
          id="profile-name"
          value={name}
          onChange={(e) => {
            setName(e.target.value);
            setJustSaved(false);
          }}
        />
      </div>

      <div className="flex flex-col gap-1.5">
        <Label htmlFor="profile-email">{labels.emailLabel}</Label>
        <Input
          id="profile-email"
          type="email"
          value={email}
          onChange={(e) => {
            setEmail(e.target.value);
            setJustSaved(false);
          }}
        />
      </div>

      <div className="flex flex-col gap-1.5">
        <div className="flex items-center gap-2">
          <Checkbox
            id="profile-marketing"
            checked={acceptsMarketing}
            onCheckedChange={(next) => {
              setAcceptsMarketing(next === true);
              setJustSaved(false);
            }}
          />
          <Label htmlFor="profile-marketing" className="font-normal">
            {labels.marketingLabel}
          </Label>
        </div>
        <p className="pl-6 text-xs text-muted-foreground">{labels.marketingHint}</p>
      </div>

      <div className="flex items-center gap-3">
        <Button type="submit" className="bg-brand text-brand-foreground hover:bg-brand/90">
          {labels.save}
        </Button>
        {justSaved && (
          <span role="status" className="text-sm text-muted-foreground">
            {labels.saved}
          </span>
        )}
      </div>
    </form>
  );
}

Docs

Consent is opt-in and its absence is not consent, so the marketing checkbox is seeded from the customer record and never force-checked.

Order rows carry payment and fulfilment as two separate badges. They are independent axes in this model and merging them into one status is the modelling mistake that makes every later question unanswerable.

Ships with sample data from @hextor/store-fixtures so it renders a real shop the moment it installs — a template you have to wire up before you can look at it is a template nobody evaluates. The route file is the only place that import appears.

Dependencies

@hextor/store-ui@hextor/store-fixtures