Skip to content

Commit ab85807

Browse files
feat: rework to the examples/memberships plan and price offering selection workflow
A plan is selected first which navigates the user to a separate page to allow them access to selecting one of the available price offerings for the selected plan. Also removed logic to silo this project to just showcasing membership/subscription features.
1 parent 21cd20e commit ab85807

9 files changed

Lines changed: 261 additions & 297 deletions

File tree

examples/memberships/next-env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3+
/// <reference path="./.next/types/routes.d.ts" />
34

45
// NOTE: This file should not be edited
56
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

examples/memberships/src/app/(store)/StoreProvider.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,8 @@
22

33
import React, { createContext, ReactNode, useContext } from "react";
44
import { InitialState } from "../../lib/get-store-initial-state";
5-
import { NavigationNode } from "../../lib/build-site-navigation";
65

7-
interface StoreState {
8-
nav?: NavigationNode[];
9-
}
6+
interface StoreState {}
107

118
export const StoreProviderContext = createContext<StoreState | null>(null);
129

@@ -18,7 +15,7 @@ export const StoreProvider = ({
1815
initialState: InitialState;
1916
}) => {
2017
return (
21-
<StoreProviderContext.Provider value={{ nav: initialState?.nav }}>
18+
<StoreProviderContext.Provider value={{}}>
2219
{children}
2320
</StoreProviderContext.Provider>
2421
);
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"use client";
2+
3+
import React, { useState, useTransition } from "react";
4+
import { getACartQueryKey } from "@epcc-sdk/sdks-shopper/react-query";
5+
import { getCookie } from "cookies-next";
6+
import { useQueryClient } from "@tanstack/react-query";
7+
import { Button } from "../../../../components/button/Button";
8+
import { addToCart } from "../add-to-cart-action";
9+
import { useNotify } from "../../../../hooks/use-event";
10+
import { CART_COOKIE_NAME } from "../../../../lib/cookie-constants";
11+
import { client } from "../../../../lib/client";
12+
13+
interface PricingOption {
14+
id?: string;
15+
attributes?: {
16+
name?: string;
17+
billing_interval_type?: string;
18+
billing_frequency?: number;
19+
};
20+
meta?: {
21+
prices?: Record<
22+
string,
23+
{
24+
display_price?: {
25+
without_tax?: { formatted?: string };
26+
};
27+
}
28+
>;
29+
};
30+
}
31+
32+
interface Plan {
33+
id?: string;
34+
attributes?: {
35+
name?: string;
36+
description?: string;
37+
};
38+
}
39+
40+
interface Props {
41+
offeringId: string;
42+
plan: Plan;
43+
pricingOptions: PricingOption[];
44+
}
45+
46+
const cartErrorOptions = {
47+
scope: "cart",
48+
type: "error",
49+
action: "add-product",
50+
} as const;
51+
52+
export default function PricingOptions({
53+
offeringId,
54+
plan,
55+
pricingOptions,
56+
}: Props) {
57+
const [selectedPricingOption, setSelectedPricingOption] = useState<
58+
string | null
59+
>(null);
60+
const [isPending, startTransition] = useTransition();
61+
const queryClient = useQueryClient();
62+
const notify = useNotify();
63+
64+
const handleAddToCart = () => {
65+
if (!selectedPricingOption) return;
66+
67+
startTransition(async () => {
68+
try {
69+
const result = await addToCart({
70+
offeringId,
71+
planId: plan.id!,
72+
pricingOptionId: selectedPricingOption,
73+
});
74+
75+
if (result.error) {
76+
notify({
77+
...cartErrorOptions,
78+
message: (result.error as any).errors[0]?.detail,
79+
cause: {
80+
type: "cart-store-error",
81+
cause: new Error(JSON.stringify(result.error)),
82+
},
83+
});
84+
} else {
85+
notify({
86+
scope: "cart",
87+
type: "success",
88+
action: "add-product",
89+
message: "Successfully added to cart",
90+
});
91+
}
92+
} catch (err) {
93+
notify({
94+
...cartErrorOptions,
95+
message: "Failed to add to cart",
96+
cause: {
97+
type: "cart-store-error",
98+
cause: err as Error,
99+
},
100+
});
101+
} finally {
102+
const cartID = await getCookie(CART_COOKIE_NAME);
103+
const queryKey = getACartQueryKey({
104+
client,
105+
path: { cartID: cartID! },
106+
query: { include: ["items"] },
107+
});
108+
await queryClient.invalidateQueries({ queryKey });
109+
}
110+
});
111+
};
112+
113+
const getPriceForOption = (option: PricingOption) =>
114+
option.meta?.prices?.[plan.id!]?.display_price?.without_tax?.formatted;
115+
116+
const formatInterval = (option: PricingOption) => {
117+
const freq = option.attributes?.billing_frequency;
118+
const type = option.attributes?.billing_interval_type;
119+
if (!freq || !type) return "";
120+
return freq === 1 ? `per ${type}` : `every ${freq} ${type}s`;
121+
};
122+
123+
return (
124+
<div className="p-4">
125+
<div className="w-full mx-auto py-6 px-[6rem]">
126+
<h2 className="text-4xl font-semibold text-center mb-4">
127+
{plan.attributes?.name}
128+
</h2>
129+
{plan.attributes?.description && (
130+
<p className="text-xl text-center text-[#62687A] mb-4">
131+
{plan.attributes.description}
132+
</p>
133+
)}
134+
<p className="text-xl font-semibold text-center mb-6">
135+
Choose a pricing option
136+
</p>
137+
138+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-4xl mx-auto mb-8">
139+
{pricingOptions.map((option) => {
140+
const isSelected = selectedPricingOption === option.id;
141+
return (
142+
<button
143+
key={option.id}
144+
onClick={() => setSelectedPricingOption(option.id!)}
145+
className={`border rounded-lg p-6 text-left transition-colors ${
146+
isSelected
147+
? "border-black bg-black/5"
148+
: "border-[#DEE4F3] hover:border-black/40"
149+
}`}
150+
>
151+
<div className="text-lg font-semibold">
152+
{option.attributes?.name}
153+
</div>
154+
<div className="text-sm text-[#62687A] mt-1">
155+
{formatInterval(option)}
156+
</div>
157+
<div className="text-xl font-medium mt-2">
158+
{getPriceForOption(option) ?? "N/A"}
159+
</div>
160+
</button>
161+
);
162+
})}
163+
</div>
164+
165+
<div className="flex justify-center">
166+
<Button
167+
disabled={!selectedPricingOption || isPending}
168+
onClick={handleAddToCart}
169+
>
170+
{isPending ? "Adding..." : "Add to cart"}
171+
</Button>
172+
</div>
173+
</div>
174+
</div>
175+
);
176+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { getOffering } from "@epcc-sdk/sdks-shopper";
2+
import { notFound } from "next/navigation";
3+
import { createElasticPathClient } from "../create-elastic-path-client";
4+
import PricingOptions from "./PricingOptions";
5+
6+
interface Props {
7+
params: Promise<{ planId: string }>;
8+
}
9+
10+
export default async function PlanPage({ params }: Props) {
11+
const { planId } = await params;
12+
const client = createElasticPathClient();
13+
14+
const offeringResponse = await getOffering({
15+
client,
16+
path: {
17+
offering_uuid: process.env.NEXT_PUBLIC_SUBSCRIPTION_OFFERING_ID!,
18+
},
19+
query: {
20+
include: ["plans", "pricing_options", "features"],
21+
},
22+
});
23+
24+
if (!offeringResponse?.data?.data) {
25+
return notFound();
26+
}
27+
28+
const offering = offeringResponse.data;
29+
const plan = offering.included?.plans?.find((p) => p.id === planId);
30+
31+
if (!plan) {
32+
return notFound();
33+
}
34+
35+
const planPricingOptionIds: string[] =
36+
(plan.relationships?.pricing_options?.data as { id: string }[] | undefined)?.map(
37+
(ref) => ref.id,
38+
) ?? [];
39+
40+
const allPricingOptions = offering.included?.pricing_options ?? [];
41+
const planPricingOptions = allPricingOptions.filter((opt) =>
42+
planPricingOptionIds.includes(opt.id!),
43+
);
44+
45+
return (
46+
<PricingOptions
47+
offeringId={offering.data?.id!}
48+
plan={plan}
49+
pricingOptions={planPricingOptions}
50+
/>
51+
);
52+
}

examples/memberships/src/app/(store)/page.tsx

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,10 @@ export default async function Home() {
1515
promotion={promotion}
1616
linkProps={{
1717
link: "/membership",
18-
text: "View pricing",
18+
text: "Choose a membership plan",
1919
}}
2020
/>
21-
<div className="grid gap-12 p-[2rem] md:p-[4em]">
22-
<div className="gap-3 p-8 md:p-16">
23-
<div>
24-
<Suspense>
25-
<FeaturedProducts
26-
title="Trending Products"
27-
linkProps={{
28-
link: `/search`,
29-
text: "See all products",
30-
}}
31-
/>
32-
</Suspense>
33-
</div>
34-
</div>
35-
</div>
21+
<div className="grid gap-12 p-[2rem] md:p-[4em]"></div>
3622
</div>
3723
);
3824
}

examples/memberships/src/components/header/navigation/MobileNavBar.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import Link from "next/link";
33
import EpIcon from "../../icons/ep-icon";
44
import { MobileNavBarButton } from "./MobileNavBarButton";
5-
import { buildSiteNavigation } from "../../../lib/build-site-navigation";
65
import { CartSheet } from "../../cart/CartSheet";
76
import { createElasticPathClient } from "../../../lib/create-elastic-path-client";
87
import { cookies } from "next/headers";
@@ -21,7 +20,6 @@ import { TAGS } from "../../../lib/constants";
2120

2221
export default async function MobileNavBar() {
2322
const client = await createElasticPathClient();
24-
const nav = await buildSiteNavigation(client);
2523
const cartId = (await cookies()).get(CART_COOKIE_NAME)?.value;
2624

2725
if (!cartId) {
@@ -67,7 +65,7 @@ export default async function MobileNavBar() {
6765
<div className="grid w-full grid-cols-[1fr_auto_1fr]">
6866
<div className="flex items-center">
6967
<MobileNavBarButton
70-
nav={nav}
68+
nav={[]}
7169
account={accountMember?.data?.data}
7270
accountMemberTokens={accountMemberCookie}
7371
/>
Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,3 @@
1-
"use server";
2-
import { NavBarPopover } from "./NavBarPopover";
3-
import { buildSiteNavigation } from "../../../lib/build-site-navigation";
4-
import { createElasticPathClient } from "../../../lib/create-elastic-path-client";
5-
61
export default async function NavBar() {
7-
const client = await createElasticPathClient();
8-
const nav = await buildSiteNavigation(client);
9-
10-
return (
11-
<div>
12-
<div className="flex w-full">
13-
<NavBarPopover nav={nav} />
14-
</div>
15-
</div>
16-
);
2+
return <div />;
173
}

0 commit comments

Comments
 (0)