Skip to content

Commit d4c70a6

Browse files
authored
Merge pull request #24 from deco-sites/jonasjesus/deco-5289-address-book-crud
feat(account): address book CRUD (DECO-5289)
2 parents ab56415 + 65052b6 commit d4c70a6

9 files changed

Lines changed: 382 additions & 0 deletions

File tree

src/actions/address/submit.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { RequestContext } from "@decocms/start/sdk/requestContext";
2+
import { usePlatform } from "../../apps/site";
3+
import {
4+
type Address,
5+
type AddressBookState,
6+
EMPTY_ADDRESS_BOOK,
7+
} from "../../platform/address/address.types";
8+
import {
9+
readAddressCookie,
10+
serializeAddressCookie,
11+
} from "../../platform/address/cookie";
12+
13+
export type AddressInput = Omit<Address, "id"> & { id?: string };
14+
15+
export type AddressOp =
16+
| { op: "save"; address: AddressInput }
17+
| { op: "remove"; id: string }
18+
| { op: "setDefault"; id: string };
19+
20+
async function action(
21+
props: AddressOp,
22+
req?: Request,
23+
): Promise<AddressBookState> {
24+
const platform = usePlatform();
25+
if (platform === "vtex" || platform === "wake") {
26+
// TODO(consumer): wire the real platform address endpoints here.
27+
}
28+
29+
const request = req ?? RequestContext.current?.request;
30+
const current = request ? readAddressCookie(request) : EMPTY_ADDRESS_BOOK;
31+
let addresses = [...current.addresses];
32+
33+
if (props.op === "save") {
34+
const input = props.address;
35+
let savedId = input.id;
36+
if (input.id) {
37+
addresses = addresses.map((a) =>
38+
a.id === input.id ? { ...a, ...input, id: a.id } : a
39+
);
40+
} else {
41+
savedId = crypto.randomUUID();
42+
const isFirst = addresses.length === 0;
43+
addresses.push({
44+
...input,
45+
id: savedId,
46+
isDefault: input.isDefault ?? isFirst,
47+
});
48+
}
49+
// When the saved address is the default, make all others non-default.
50+
if (addresses.find((a) => a.id === savedId)?.isDefault) {
51+
addresses = addresses.map((a) => ({ ...a, isDefault: a.id === savedId }));
52+
}
53+
} else if (props.op === "remove") {
54+
addresses = addresses.filter((a) => a.id !== props.id);
55+
// If we removed the default, promote the first remaining address.
56+
if (addresses.length > 0 && !addresses.some((a) => a.isDefault)) {
57+
addresses[0] = { ...addresses[0], isDefault: true };
58+
}
59+
} else if (props.op === "setDefault") {
60+
addresses = addresses.map((a) => ({ ...a, isDefault: a.id === props.id }));
61+
}
62+
63+
const next: AddressBookState = { addresses };
64+
RequestContext.responseHeaders.append(
65+
"Set-Cookie",
66+
serializeAddressCookie(next),
67+
);
68+
return next;
69+
}
70+
71+
export default action;
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { useState } from "react";
2+
import {
3+
type Address,
4+
useAddresses,
5+
useRemoveAddress,
6+
useSaveAddress,
7+
useSetDefaultAddress,
8+
} from "../../platform/address";
9+
10+
function AddressForm(
11+
{ initial, onClose }: { initial?: Address; onClose: () => void },
12+
) {
13+
const save = useSaveAddress();
14+
return (
15+
<form
16+
className="grid gap-3 sm:grid-cols-2"
17+
onSubmit={(e) => {
18+
e.preventDefault();
19+
const d = new FormData(e.currentTarget);
20+
const get = (k: string) => `${d.get(k) ?? ""}`.trim();
21+
save.mutate(
22+
{
23+
id: initial?.id,
24+
label: get("label"),
25+
recipient: get("recipient"),
26+
streetAddress: get("streetAddress"),
27+
addressLocality: get("addressLocality"),
28+
addressRegion: get("addressRegion"),
29+
postalCode: get("postalCode"),
30+
addressCountry: get("addressCountry"),
31+
isDefault: d.get("isDefault") === "on",
32+
},
33+
{ onSuccess: onClose },
34+
);
35+
}}
36+
>
37+
<input name="label" defaultValue={initial?.label} placeholder="Label (Home, Work)" className="input input-bordered input-sm" />
38+
<input name="recipient" defaultValue={initial?.recipient} placeholder="Recipient" className="input input-bordered input-sm" />
39+
<input name="streetAddress" defaultValue={initial?.streetAddress} placeholder="Street address" required className="input input-bordered input-sm sm:col-span-2" />
40+
<input name="addressLocality" defaultValue={initial?.addressLocality} placeholder="City" className="input input-bordered input-sm" />
41+
<input name="addressRegion" defaultValue={initial?.addressRegion} placeholder="State/Region" className="input input-bordered input-sm" />
42+
<input name="postalCode" defaultValue={initial?.postalCode} placeholder="Postal code" required className="input input-bordered input-sm" />
43+
<input name="addressCountry" defaultValue={initial?.addressCountry} placeholder="Country" className="input input-bordered input-sm" />
44+
<label className="label cursor-pointer gap-2 sm:col-span-2 justify-start">
45+
<input type="checkbox" name="isDefault" defaultChecked={initial?.isDefault} className="checkbox checkbox-sm" />
46+
<span className="label-text">Set as default</span>
47+
</label>
48+
<div className="flex gap-2 sm:col-span-2">
49+
<button type="submit" className="btn btn-primary btn-sm no-animation" disabled={save.isPending}>
50+
{save.isPending ? <span className="loading loading-spinner loading-xs" /> : "Save"}
51+
</button>
52+
<button type="button" className="btn btn-ghost btn-sm no-animation" onClick={onClose}>
53+
Cancel
54+
</button>
55+
</div>
56+
{save.isError && (
57+
<span className="text-sm text-error sm:col-span-2">
58+
Couldn't save the address. Please try again.
59+
</span>
60+
)}
61+
</form>
62+
);
63+
}
64+
65+
export default function AddressBook() {
66+
const { addresses, isLoading } = useAddresses();
67+
const remove = useRemoveAddress();
68+
const setDefault = useSetDefaultAddress();
69+
const [editingId, setEditingId] = useState<string | null>(null);
70+
const [adding, setAdding] = useState(false);
71+
72+
return (
73+
<div className="card bg-base-100 shadow p-6 max-w-xl mt-6">
74+
<div className="flex items-center justify-between mb-4">
75+
<h2 className="text-lg font-medium">Addresses</h2>
76+
{!adding && (
77+
<button
78+
type="button"
79+
className="btn btn-sm btn-outline no-animation"
80+
onClick={() => {
81+
setAdding(true);
82+
setEditingId(null);
83+
}}
84+
>
85+
Add address
86+
</button>
87+
)}
88+
</div>
89+
90+
{adding && (
91+
<div className="border border-base-200 rounded p-3 mb-3">
92+
<AddressForm onClose={() => setAdding(false)} />
93+
</div>
94+
)}
95+
96+
{isLoading
97+
? <span className="loading loading-spinner" />
98+
: addresses.length === 0 && !adding
99+
? <p className="text-sm text-base-content/60">No addresses saved yet.</p>
100+
: (
101+
<ul className="flex flex-col gap-3">
102+
{addresses.map((a) => (
103+
<li key={a.id} className="border border-base-200 rounded p-3">
104+
{editingId === a.id
105+
? <AddressForm initial={a} onClose={() => setEditingId(null)} />
106+
: (
107+
<div className="flex justify-between gap-3">
108+
<div className="text-sm">
109+
<div className="font-medium">
110+
{a.label || a.recipient || "Address"}
111+
{a.isDefault && (
112+
<span className="badge badge-sm badge-primary ml-2">
113+
Default
114+
</span>
115+
)}
116+
</div>
117+
<div className="text-base-content/70">
118+
{[
119+
a.streetAddress,
120+
a.addressLocality,
121+
a.addressRegion,
122+
a.postalCode,
123+
a.addressCountry,
124+
].filter(Boolean).join(", ")}
125+
</div>
126+
</div>
127+
<div className="flex flex-col gap-1 shrink-0 items-end">
128+
<button
129+
type="button"
130+
className="btn btn-ghost btn-xs no-animation"
131+
onClick={() => {
132+
setEditingId(a.id);
133+
setAdding(false);
134+
}}
135+
>
136+
Edit
137+
</button>
138+
{!a.isDefault && (
139+
<button
140+
type="button"
141+
className="btn btn-ghost btn-xs no-animation"
142+
disabled={setDefault.isPending}
143+
onClick={() => setDefault.mutate(a.id)}
144+
>
145+
Set default
146+
</button>
147+
)}
148+
<button
149+
type="button"
150+
className="btn btn-ghost btn-xs no-animation text-error"
151+
disabled={remove.isPending}
152+
onClick={() => remove.mutate(a.id)}
153+
>
154+
Remove
155+
</button>
156+
</div>
157+
</div>
158+
)}
159+
</li>
160+
))}
161+
</ul>
162+
)}
163+
</div>
164+
);
165+
}

src/loaders/address.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { RequestContext } from "@decocms/start/sdk/requestContext";
2+
import { usePlatform } from "../apps/site";
3+
import {
4+
type AddressBookState,
5+
EMPTY_ADDRESS_BOOK,
6+
} from "../platform/address/address.types";
7+
import { readAddressCookie } from "../platform/address/cookie";
8+
9+
async function loader(): Promise<AddressBookState> {
10+
const platform = usePlatform();
11+
12+
if (platform === "vtex") {
13+
// TODO(consumer): call the real VTEX address-book loader.
14+
}
15+
if (platform === "wake") {
16+
// TODO(consumer): wire the wake address endpoint here.
17+
}
18+
19+
// Default: cookie-backed so the demo works without a backend.
20+
const req = RequestContext.current?.request;
21+
return req ? readAddressCookie(req) : EMPTY_ADDRESS_BOOK;
22+
}
23+
24+
export default loader;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2+
import { invoke } from "../../runtime";
3+
import type { AddressInput } from "../../actions/address/submit";
4+
import { type AddressBookState, EMPTY_ADDRESS_BOOK } from "./address.types";
5+
6+
export const ADDRESS_QUERY_KEY = ["addresses"] as const;
7+
8+
export function useAddresses() {
9+
const query = useQuery({
10+
queryKey: ADDRESS_QUERY_KEY,
11+
queryFn: (): Promise<AddressBookState> =>
12+
invoke.site.loaders.address() as Promise<AddressBookState>,
13+
staleTime: 60_000,
14+
placeholderData: EMPTY_ADDRESS_BOOK,
15+
});
16+
return {
17+
addresses: (query.data ?? EMPTY_ADDRESS_BOOK).addresses,
18+
isLoading: query.isLoading,
19+
isFetching: query.isFetching,
20+
error: query.error,
21+
};
22+
}
23+
24+
// Mutations reconcile via onSuccess (the server assigns ids and enforces the
25+
// single-default invariant, so an optimistic guess would be unreliable).
26+
function useAddressMutation<TInput>(
27+
toOp: (input: TInput) => unknown,
28+
) {
29+
const qc = useQueryClient();
30+
return useMutation({
31+
mutationFn: (input: TInput): Promise<AddressBookState> =>
32+
invoke.site.actions.address.submit(toOp(input)) as Promise<
33+
AddressBookState
34+
>,
35+
onSuccess: (state) => qc.setQueryData(ADDRESS_QUERY_KEY, state),
36+
});
37+
}
38+
39+
export function useSaveAddress() {
40+
return useAddressMutation<AddressInput>((address) => ({
41+
op: "save",
42+
address,
43+
}));
44+
}
45+
46+
export function useRemoveAddress() {
47+
return useAddressMutation<string>((id) => ({ op: "remove", id }));
48+
}
49+
50+
export function useSetDefaultAddress() {
51+
return useAddressMutation<string>((id) => ({ op: "setDefault", id }));
52+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/** A saved shipping/billing address. Field names follow schema.org PostalAddress. */
2+
export interface Address {
3+
id: string;
4+
/** Friendly label, e.g. "Home" or "Work". */
5+
label?: string;
6+
/** Recipient full name. */
7+
recipient?: string;
8+
streetAddress?: string;
9+
/** City. */
10+
addressLocality?: string;
11+
/** State/region. */
12+
addressRegion?: string;
13+
postalCode?: string;
14+
addressCountry?: string;
15+
isDefault?: boolean;
16+
}
17+
18+
export interface AddressBookState {
19+
addresses: Address[];
20+
}
21+
22+
export const EMPTY_ADDRESS_BOOK: AddressBookState = { addresses: [] };

src/platform/address/cookie.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {
2+
type Address,
3+
type AddressBookState,
4+
EMPTY_ADDRESS_BOOK,
5+
} from "./address.types";
6+
7+
const ADDRESS_COOKIE = "deco_addresses";
8+
const ADDRESS_COOKIE_TTL = 60 * 60 * 24 * 365;
9+
10+
export function readAddressCookie(req: Request): AddressBookState {
11+
const header = req.headers.get("cookie") ?? "";
12+
const match = header
13+
.split(/;\s*/)
14+
.find((c) => c.startsWith(`${ADDRESS_COOKIE}=`));
15+
if (!match) return EMPTY_ADDRESS_BOOK;
16+
try {
17+
const raw = decodeURIComponent(match.slice(ADDRESS_COOKIE.length + 1));
18+
const addresses = JSON.parse(raw);
19+
return Array.isArray(addresses)
20+
? { addresses: addresses as Address[] }
21+
: EMPTY_ADDRESS_BOOK;
22+
} catch {
23+
return EMPTY_ADDRESS_BOOK;
24+
}
25+
}
26+
27+
export function serializeAddressCookie(state: AddressBookState): string {
28+
const value = encodeURIComponent(JSON.stringify(state.addresses));
29+
return `${ADDRESS_COOKIE}=${value}; Path=/; Max-Age=${ADDRESS_COOKIE_TTL}; SameSite=Lax`;
30+
}

src/platform/address/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export type { Address, AddressBookState } from "./address.types";
2+
export { EMPTY_ADDRESS_BOOK } from "./address.types";
3+
export {
4+
ADDRESS_QUERY_KEY,
5+
useAddresses,
6+
useRemoveAddress,
7+
useSaveAddress,
8+
useSetDefaultAddress,
9+
} from "./address.hooks";

src/routes/account.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
22
import { useSignOut, useUser } from "../platform/user";
3+
import AddressBook from "../components/account/AddressBook";
34

45
export const Route = createFileRoute("/account")({
56
component: AccountPage,
@@ -69,6 +70,8 @@ function AccountPage() {
6970
</button>
7071
</div>
7172
</div>
73+
74+
<AddressBook />
7275
</div>
7376
);
7477
}

0 commit comments

Comments
 (0)