Skip to content

Commit d5b1b4d

Browse files
committed
fix(shop): collection icons + policy pages from Shopify API
Collections: map known handles to distinct lucide icons (Shirt for apparel, Tag for accessories, Code for library-merch). Unknown handles fall back to Sparkles. Policies: Shopify stores policies separately from generic pages (they live on shop.privacyPolicy, shop.refundPolicy, etc., not page(handle:)). The sidebar now fetches real policies via getShopPolicies() and only shows links for policies that actually exist in the Shopify admin. New /shop/policies/$handle route reads from the policies API instead of the pages API. The old /shop/pages/$handle route still works for generic CMS pages.
1 parent 06fe4f7 commit d5b1b4d

6 files changed

Lines changed: 224 additions & 33 deletions

File tree

src/components/shop/ShopLayout.tsx

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,48 @@ import { Link, useLocation } from '@tanstack/react-router'
33
import {
44
ChevronLeft,
55
ChevronRight,
6+
Code,
67
FileText,
78
Menu,
8-
Package,
99
Search,
10+
Shirt,
1011
ShoppingBag,
1112
ShoppingCart,
13+
Sparkles,
14+
Tag,
1215
X,
1316
} from 'lucide-react'
1417
import { twMerge } from 'tailwind-merge'
1518
import { useLocalStorage } from '~/utils/useLocalStorage'
16-
import type { CollectionListItem } from '~/utils/shopify-queries'
19+
import type { CollectionListItem, PolicySummary } from '~/utils/shopify-queries'
1720
import { CartDrawer } from './CartDrawer'
1821
import { useCartDrawerStore } from './cartDrawerStore'
1922

23+
type IconComponent = React.ComponentType<{ className?: string }>
24+
2025
type ShopLayoutProps = {
2126
collections: Array<CollectionListItem>
27+
policies: Array<PolicySummary>
2228
children: React.ReactNode
2329
}
2430

25-
const POLICY_PAGES = [
26-
{ handle: 'shipping-policy', label: 'Shipping' },
27-
{ handle: 'refund-policy', label: 'Returns' },
28-
{ handle: 'privacy-policy', label: 'Privacy' },
29-
{ handle: 'terms-of-service', label: 'Terms' },
30-
] as const
31+
/** Map known collection handles to meaningful icons. Defaults to Sparkles. */
32+
const COLLECTION_ICON_MAP: Record<string, IconComponent> = {
33+
apparel: Shirt,
34+
accessories: Tag,
35+
'library-merch': Code,
36+
}
3137

3238
/**
3339
* /shop layout: persistent left sidebar on md+, slide-in drawer on mobile.
3440
* Collapse state persists to localStorage. When collapsed, hovering the
3541
* rail expands it as an overlay without shifting the main content.
3642
*/
37-
export function ShopLayout({ collections, children }: ShopLayoutProps) {
43+
export function ShopLayout({
44+
collections,
45+
policies,
46+
children,
47+
}: ShopLayoutProps) {
3848
const [isCollapsed, setIsCollapsed] = useLocalStorage(
3949
'shopSidebarCollapsed',
4050
false,
@@ -105,6 +115,7 @@ export function ShopLayout({ collections, children }: ShopLayoutProps) {
105115

106116
<ShopSidebarNav
107117
collections={collections}
118+
policies={policies}
108119
showLabels={showExpanded}
109120
onNavigate={() => setIsMobileOpen(false)}
110121
/>
@@ -164,10 +175,12 @@ function ShopCartDrawer() {
164175

165176
function ShopSidebarNav({
166177
collections,
178+
policies,
167179
showLabels,
168180
onNavigate,
169181
}: {
170182
collections: Array<CollectionListItem>
183+
policies: Array<PolicySummary>
171184
showLabels: boolean
172185
onNavigate: () => void
173186
}) {
@@ -206,27 +219,29 @@ function ShopSidebarNav({
206219
to="/shop/collections/$handle"
207220
params={{ handle: c.handle }}
208221
label={c.title}
209-
icon={Package}
222+
icon={COLLECTION_ICON_MAP[c.handle] ?? Sparkles}
210223
showLabels={showLabels}
211224
onNavigate={onNavigate}
212225
/>
213226
))}
214227
</SidebarSection>
215228
) : null}
216229

217-
<SidebarSection label="Info" showLabels={showLabels}>
218-
{POLICY_PAGES.map((page) => (
219-
<SidebarLink
220-
key={page.handle}
221-
to="/shop/pages/$handle"
222-
params={{ handle: page.handle }}
223-
label={page.label}
224-
icon={FileText}
225-
showLabels={showLabels}
226-
onNavigate={onNavigate}
227-
/>
228-
))}
229-
</SidebarSection>
230+
{policies.length > 0 ? (
231+
<SidebarSection label="Info" showLabels={showLabels}>
232+
{policies.map((policy) => (
233+
<SidebarLink
234+
key={policy.handle}
235+
to="/shop/policies/$handle"
236+
params={{ handle: policy.handle }}
237+
label={policy.title}
238+
icon={FileText}
239+
showLabels={showLabels}
240+
onNavigate={onNavigate}
241+
/>
242+
))}
243+
</SidebarSection>
244+
) : null}
230245
</nav>
231246
)
232247
}
@@ -255,8 +270,6 @@ function SidebarSection({
255270
)
256271
}
257272

258-
type IconComponent = React.ComponentType<{ className?: string }>
259-
260273
function SidebarLink({
261274
to,
262275
params,

src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import { Route as LibraryIdVersionIndexRouteImport } from './routes/$libraryId/$
103103
import { Route as StatsNpmPackagesRouteImport } from './routes/stats/npm/$packages'
104104
import { Route as ShowcaseEditIdRouteImport } from './routes/showcase/edit.$id'
105105
import { Route as ShopProductsHandleRouteImport } from './routes/shop.products.$handle'
106+
import { Route as ShopPoliciesHandleRouteImport } from './routes/shop.policies.$handle'
106107
import { Route as ShopPagesHandleRouteImport } from './routes/shop.pages.$handle'
107108
import { Route as ShopCollectionsHandleRouteImport } from './routes/shop.collections.$handle'
108109
import { Route as IntentRegistryPackageNameRouteImport } from './routes/intent/registry/$packageName'
@@ -620,6 +621,11 @@ const ShopProductsHandleRoute = ShopProductsHandleRouteImport.update({
620621
path: '/products/$handle',
621622
getParentRoute: () => ShopRoute,
622623
} as any)
624+
const ShopPoliciesHandleRoute = ShopPoliciesHandleRouteImport.update({
625+
id: '/policies/$handle',
626+
path: '/policies/$handle',
627+
getParentRoute: () => ShopRoute,
628+
} as any)
623629
const ShopPagesHandleRoute = ShopPagesHandleRouteImport.update({
624630
id: '/pages/$handle',
625631
path: '/pages/$handle',
@@ -962,6 +968,7 @@ export interface FileRoutesByFullPath {
962968
'/intent/registry/$packageName': typeof IntentRegistryPackageNameRouteWithChildren
963969
'/shop/collections/$handle': typeof ShopCollectionsHandleRoute
964970
'/shop/pages/$handle': typeof ShopPagesHandleRoute
971+
'/shop/policies/$handle': typeof ShopPoliciesHandleRoute
965972
'/shop/products/$handle': typeof ShopProductsHandleRoute
966973
'/showcase/edit/$id': typeof ShowcaseEditIdRoute
967974
'/stats/npm/$packages': typeof StatsNpmPackagesRoute
@@ -1093,6 +1100,7 @@ export interface FileRoutesByTo {
10931100
'/auth/$provider/start': typeof AuthProviderStartRoute
10941101
'/shop/collections/$handle': typeof ShopCollectionsHandleRoute
10951102
'/shop/pages/$handle': typeof ShopPagesHandleRoute
1103+
'/shop/policies/$handle': typeof ShopPoliciesHandleRoute
10961104
'/shop/products/$handle': typeof ShopProductsHandleRoute
10971105
'/showcase/edit/$id': typeof ShowcaseEditIdRoute
10981106
'/stats/npm/$packages': typeof StatsNpmPackagesRoute
@@ -1235,6 +1243,7 @@ export interface FileRoutesById {
12351243
'/intent/registry/$packageName': typeof IntentRegistryPackageNameRouteWithChildren
12361244
'/shop/collections/$handle': typeof ShopCollectionsHandleRoute
12371245
'/shop/pages/$handle': typeof ShopPagesHandleRoute
1246+
'/shop/policies/$handle': typeof ShopPoliciesHandleRoute
12381247
'/shop/products/$handle': typeof ShopProductsHandleRoute
12391248
'/showcase/edit/$id': typeof ShowcaseEditIdRoute
12401249
'/stats/npm/$packages': typeof StatsNpmPackagesRoute
@@ -1378,6 +1387,7 @@ export interface FileRouteTypes {
13781387
| '/intent/registry/$packageName'
13791388
| '/shop/collections/$handle'
13801389
| '/shop/pages/$handle'
1390+
| '/shop/policies/$handle'
13811391
| '/shop/products/$handle'
13821392
| '/showcase/edit/$id'
13831393
| '/stats/npm/$packages'
@@ -1509,6 +1519,7 @@ export interface FileRouteTypes {
15091519
| '/auth/$provider/start'
15101520
| '/shop/collections/$handle'
15111521
| '/shop/pages/$handle'
1522+
| '/shop/policies/$handle'
15121523
| '/shop/products/$handle'
15131524
| '/showcase/edit/$id'
15141525
| '/stats/npm/$packages'
@@ -1650,6 +1661,7 @@ export interface FileRouteTypes {
16501661
| '/intent/registry/$packageName'
16511662
| '/shop/collections/$handle'
16521663
| '/shop/pages/$handle'
1664+
| '/shop/policies/$handle'
16531665
| '/shop/products/$handle'
16541666
| '/showcase/edit/$id'
16551667
| '/stats/npm/$packages'
@@ -2449,6 +2461,13 @@ declare module '@tanstack/react-router' {
24492461
preLoaderRoute: typeof ShopProductsHandleRouteImport
24502462
parentRoute: typeof ShopRoute
24512463
}
2464+
'/shop/policies/$handle': {
2465+
id: '/shop/policies/$handle'
2466+
path: '/policies/$handle'
2467+
fullPath: '/shop/policies/$handle'
2468+
preLoaderRoute: typeof ShopPoliciesHandleRouteImport
2469+
parentRoute: typeof ShopRoute
2470+
}
24522471
'/shop/pages/$handle': {
24532472
id: '/shop/pages/$handle'
24542473
path: '/pages/$handle'
@@ -2948,6 +2967,7 @@ interface ShopRouteChildren {
29482967
ShopIndexRoute: typeof ShopIndexRoute
29492968
ShopCollectionsHandleRoute: typeof ShopCollectionsHandleRoute
29502969
ShopPagesHandleRoute: typeof ShopPagesHandleRoute
2970+
ShopPoliciesHandleRoute: typeof ShopPoliciesHandleRoute
29512971
ShopProductsHandleRoute: typeof ShopProductsHandleRoute
29522972
}
29532973

@@ -2957,6 +2977,7 @@ const ShopRouteChildren: ShopRouteChildren = {
29572977
ShopIndexRoute: ShopIndexRoute,
29582978
ShopCollectionsHandleRoute: ShopCollectionsHandleRoute,
29592979
ShopPagesHandleRoute: ShopPagesHandleRoute,
2980+
ShopPoliciesHandleRoute: ShopPoliciesHandleRoute,
29602981
ShopProductsHandleRoute: ShopProductsHandleRoute,
29612982
}
29622983

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { createFileRoute, notFound } from '@tanstack/react-router'
2+
import { Breadcrumbs } from '~/components/shop/Breadcrumbs'
3+
import { getShopPolicy } from '~/utils/shop.functions'
4+
import { seo } from '~/utils/seo'
5+
6+
export const Route = createFileRoute('/shop/policies/$handle')({
7+
loader: async ({ params }) => {
8+
const policy = await getShopPolicy({ data: { handle: params.handle } })
9+
if (!policy) throw notFound()
10+
return { policy }
11+
},
12+
head: ({ loaderData }) => {
13+
const p = loaderData?.policy
14+
if (!p) return { meta: [] }
15+
return {
16+
meta: seo({
17+
title: `${p.title} | TanStack Shop`,
18+
}),
19+
}
20+
},
21+
component: PolicyPage,
22+
})
23+
24+
function PolicyPage() {
25+
const { policy } = Route.useLoaderData()
26+
return (
27+
<article className="flex flex-col max-w-3xl mx-auto gap-8 p-4 md:p-8">
28+
<Breadcrumbs
29+
crumbs={[{ label: 'Shop', href: '/shop' }, { label: policy.title }]}
30+
/>
31+
<header>
32+
<h1 className="text-3xl font-black">{policy.title}</h1>
33+
</header>
34+
{policy.body ? (
35+
<div
36+
className="prose dark:prose-invert max-w-none"
37+
// eslint-disable-next-line react/no-danger
38+
dangerouslySetInnerHTML={{ __html: policy.body }}
39+
/>
40+
) : (
41+
<p className="text-gray-600 dark:text-gray-400">
42+
This policy has no content yet.
43+
</p>
44+
)}
45+
</article>
46+
)
47+
}

src/routes/shop.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
import { Link, Outlet, createFileRoute } from '@tanstack/react-router'
22
import { ShopLayout } from '~/components/shop/ShopLayout'
33
import { CART_QUERY_KEY } from '~/hooks/useCart'
4-
import { getCart, getCollections } from '~/utils/shop.functions'
4+
import {
5+
getCart,
6+
getCollections,
7+
getShopPolicies,
8+
} from '~/utils/shop.functions'
59
import { seo } from '~/utils/seo'
610

711
export const Route = createFileRoute('/shop')({
812
loader: async ({ context }) => {
9-
// Fetch collections for the sidebar, and pre-seed the cart into the
10-
// React Query cache so any /shop child page (including the cart page)
11-
// renders with real data on the very first frame — no hydration gap.
12-
const [collections] = await Promise.all([
13+
// Fetch collections + policies for the sidebar, and pre-seed the cart
14+
// into the React Query cache so any /shop child page (including the
15+
// cart page) renders with real data on the very first frame.
16+
const [collections, policies] = await Promise.all([
1317
getCollections(),
18+
getShopPolicies(),
1419
context.queryClient.prefetchQuery({
1520
queryKey: CART_QUERY_KEY,
1621
queryFn: () => getCart(),
1722
}),
1823
])
19-
return { collections }
24+
return { collections, policies }
2025
},
2126
head: () => ({
2227
meta: seo({
@@ -40,9 +45,9 @@ export const Route = createFileRoute('/shop')({
4045
})
4146

4247
function ShopRoute() {
43-
const { collections } = Route.useLoaderData()
48+
const { collections, policies } = Route.useLoaderData()
4449
return (
45-
<ShopLayout collections={collections}>
50+
<ShopLayout collections={collections} policies={policies}>
4651
<Outlet />
4752
</ShopLayout>
4853
)

src/utils/shop.functions.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ import {
4242
type ProductsQueryVariables,
4343
type SearchQueryResult,
4444
type ShopQueryResult,
45+
SHOP_POLICIES_QUERY,
46+
type ShopPoliciesQueryResult,
47+
type ShopPolicy,
48+
flattenPolicies,
49+
type PolicySummary,
4550
} from '~/utils/shopify-queries'
4651

4752
const CART_COOKIE_NAME = 'tanstack_cart_id'
@@ -213,6 +218,36 @@ export const getPage = createServerFn({ method: 'POST' })
213218
return result.page
214219
})
215220

221+
export const getShopPolicies = createServerFn({ method: 'GET' }).handler(
222+
async (): Promise<Array<PolicySummary>> => {
223+
setBrowseCacheHeaders()
224+
const result = await shopifyServerFetch<ShopPoliciesQueryResult>({
225+
query: SHOP_POLICIES_QUERY,
226+
})
227+
return flattenPolicies(result.shop)
228+
},
229+
)
230+
231+
export const getShopPolicy = createServerFn({ method: 'POST' })
232+
.inputValidator(v.object({ handle: v.string() }))
233+
.handler(
234+
async ({
235+
data,
236+
}): Promise<{ title: string; body: string; handle: string } | null> => {
237+
setBrowseCacheHeaders()
238+
const result = await shopifyServerFetch<ShopPoliciesQueryResult>({
239+
query: SHOP_POLICIES_QUERY,
240+
})
241+
const all = [
242+
result.shop.privacyPolicy,
243+
result.shop.refundPolicy,
244+
result.shop.termsOfService,
245+
result.shop.shippingPolicy,
246+
].filter((p): p is NonNullable<ShopPolicy> => p !== null)
247+
return all.find((p) => p.handle === data.handle) ?? null
248+
},
249+
)
250+
216251
export const searchProducts = createServerFn({ method: 'POST' })
217252
.inputValidator(
218253
v.object({

0 commit comments

Comments
 (0)