Skip to content
Merged
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ A modern, headless e-commerce storefront built with Next.js 16, React 19, and [S
- **Multi-Region Support** - Country, currency, and language switching via URL segments, powered by [Spree Markets](https://spreecommerce.org/docs/developer/core-concepts/markets)
- **Responsive Design** - Mobile-first Tailwind CSS styling
- **Google Tag Mananager** and **Google Analytics 4 Ecommerce events** tracking supported natively
- **Store Policies** - Policy pages fetched from Spree API, with consent checkboxes on registration and guest checkout
- **SEO-ready** - meta tags, JSON-LD, OpenGraph - all built in!
- **Error Tracking** - Sentry integration for both server-side and client-side error monitoring with source maps

Expand Down Expand Up @@ -122,6 +123,7 @@ src/
│ │ ├── register/ # Registration
│ │ └── profile/ # Profile settings
│ ├── cart/ # Shopping cart
│ ├── policies/ # Store policy pages
│ ├── products/ # Product listing
│ │ └── [slug]/ # Product details
│ ├── t/[...permalink]/ # Taxon/category pages
Expand All @@ -143,6 +145,7 @@ src/
├── credit-cards.ts # Payment methods
├── customer.ts # Auth & profile
├── orders.ts # Order history
├── policies.ts # Store policies
├── products.ts # Product queries
├── store.ts # Store configuration
└── taxonomies.ts # Categories/taxons
Expand Down
118 changes: 50 additions & 68 deletions package-lock.json

Large diffs are not rendered by default.

40 changes: 34 additions & 6 deletions src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
type PaymentSectionHandle,
} from "@/components/checkout/PaymentSection";
import { Summary } from "@/components/checkout/Summary";
import { PolicyConsent } from "@/components/policy/PolicyConsent";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useAuth } from "@/contexts/AuthContext";
import { useCheckout } from "@/contexts/CheckoutContext";
import {
Expand Down Expand Up @@ -100,6 +102,8 @@ export default function CheckoutPage({ params }: CheckoutPageProps) {
const [sectionErrors, setSectionErrors] = useState<Record<string, string[]>>(
{},
);
const [policyConsent, setPolicyConsent] = useState(false);
const [policyError, setPolicyError] = useState(false);

const fulfillments = cart?.fulfillments ?? [];

Expand Down Expand Up @@ -460,6 +464,18 @@ export default function CheckoutPage({ params }: CheckoutPageProps) {
setSectionErrors({});
setError(null);

if (!isAuthenticated && !policyConsent) {
setPolicyError(true);
setError(
"You must agree to the store policies before placing your order",
);
document
.getElementById("policy-consent")
?.scrollIntoView({ behavior: "smooth", block: "center" });
document.getElementById("policy-consent")?.focus();
return;
}

// Refresh cart to get latest requirements
const freshOrder = await getCheckoutOrder(cart.id);
if (!freshOrder) {
Expand Down Expand Up @@ -573,12 +589,10 @@ export default function CheckoutPage({ params }: CheckoutPageProps) {
<div>
{/* Error banner */}
{error && (
<div className="rounded-sm border border-red-300 bg-red-50 px-4 py-3 mb-6">
<p className="text-sm text-red-700 flex items-center gap-2">
<CircleAlert className="h-4 w-4 flex-shrink-0" />
{error}
</p>
</div>
<Alert variant="destructive" className="mb-6">
<CircleAlert />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}

{/* Contact + Delivery */}
Expand Down Expand Up @@ -628,6 +642,20 @@ export default function CheckoutPage({ params }: CheckoutPageProps) {
/>
</div>

{/* Policy consent — guests only, authenticated users accepted at registration */}
{!isAuthenticated && (
<div className="mt-6">
<PolicyConsent
checked={policyConsent}
onCheckedChange={(checked) => {
setPolicyConsent(checked);
if (checked) setPolicyError(false);
}}
error={policyError}
/>
</div>
)}

{/* Pay now button — Shopify: black, tall, minimal radius, bold */}
<button
type="button"
Expand Down
15 changes: 14 additions & 1 deletion src/app/[country]/[locale]/(checkout)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { CheckoutProvider, CheckoutSummary } from "@/contexts/CheckoutContext";
import { POLICY_LINKS } from "@/lib/constants/policies";
import { extractBasePath } from "@/lib/utils/path";

const storeName = process.env.NEXT_PUBLIC_STORE_NAME || "Spree Store";
Expand Down Expand Up @@ -38,13 +39,25 @@ function CheckoutHeader() {
}

function CheckoutFooter() {
const pathname = usePathname();
const basePath = extractBasePath(pathname);
const currentYear = new Date().getFullYear();

return (
<footer className="py-4 text-xs text-gray-500 border-t border-gray-200 mt-auto">
<footer className="py-4 text-xs text-gray-500 border-t border-gray-200 mt-auto flex flex-wrap items-center gap-x-3 gap-y-1">
<p>
&copy; {currentYear} {storeName}. All rights reserved.
</p>
{POLICY_LINKS.map((policy) => (
<Link
key={policy.slug}
href={`${basePath}/policies/${policy.slug}`}
target="_blank"
className="text-gray-500 underline hover:text-gray-700"
Comment thread
Cichorek marked this conversation as resolved.
>
{policy.name}
</Link>
))}
</footer>
);
}
Expand Down
22 changes: 22 additions & 0 deletions src/app/[country]/[locale]/(storefront)/account/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CircleAlert, Eye, EyeOff } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { PolicyConsent } from "@/components/policy/PolicyConsent";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Expand Down Expand Up @@ -35,6 +36,8 @@ export default function RegisterPage() {
useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [policyConsent, setPolicyConsent] = useState(false);
const [policyError, setPolicyError] = useState(false);

// Redirect if already authenticated
// useEffect is needed here to prevent rendering issues.
Expand All @@ -61,6 +64,16 @@ export default function RegisterPage() {
return;
}

if (!policyConsent) {
setPolicyError(true);
setError("You must agree to the store policies to create an account");
document
.getElementById("policy-consent")
?.scrollIntoView({ behavior: "smooth", block: "center" });
document.getElementById("policy-consent")?.focus();
return;
}

setSubmitting(true);

try {
Expand Down Expand Up @@ -210,6 +223,15 @@ export default function RegisterPage() {
</div>
</Field>

<PolicyConsent
checked={policyConsent}
onCheckedChange={(checked) => {
setPolicyConsent(checked);
if (checked) setPolicyError(false);
}}
error={policyError}
/>

<div className="w-full">
<Button
type="submit"
Expand Down
2 changes: 1 addition & 1 deletion src/app/[country]/[locale]/(storefront)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default async function StorefrontLayout({
expand: ["children.children"],
})
.then((res) => res.data)
.catch(() => []);
.catch(() => [] as Category[]);

return (
<>
Expand Down
56 changes: 56 additions & 0 deletions src/app/[country]/[locale]/(storefront)/policies/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPolicy } from "@/lib/data/policies";
import { getStoreName } from "@/lib/seo";

interface PolicyPageProps {
params: Promise<{
country: string;
locale: string;
slug: string;
}>;
}

export async function generateMetadata({
params,
}: PolicyPageProps): Promise<Metadata> {
const { slug } = await params;
const policy = await getPolicy(slug);

if (!policy) {
return { title: "Policy Not Found" };
}

const storeName = getStoreName();

return {
title: storeName ? `${policy.name} | ${storeName}` : policy.name,
};
}

export default async function PolicyPage({ params }: PolicyPageProps) {
const { slug } = await params;
const policy = await getPolicy(slug);

if (!policy) {
notFound();
}

return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1 className="text-3xl font-bold text-gray-900 mb-8">{policy.name}</h1>
{policy.body_html ? (
<div
className="prose prose-gray"
dangerouslySetInnerHTML={{ __html: policy.body_html }}
/>
) : policy.body ? (
<div className="prose prose-gray whitespace-pre-wrap">
{policy.body}
</div>
) : (
<p className="text-gray-500">No content available for this policy.</p>
)}
</div>
);
}
34 changes: 28 additions & 6 deletions src/components/layout/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { useStore } from "@/contexts/StoreContext";
import { POLICY_LINKS } from "@/lib/constants/policies";
import { extractBasePath } from "@/lib/utils/path";

export function Footer() {
const { storeName, storeDescription } = useStore();
const pathname = usePathname();
const basePath = extractBasePath(pathname);
return (
<footer className="bg-primary text-gray-300">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="grid grid-cols-1 gap-8 md:grid-cols-5">
{/* Brand */}
<div className="col-span-1 md:col-span-2">
<span className="text-xl font-bold text-white">{storeName}</span>
Expand All @@ -21,15 +26,15 @@ export function Footer() {
<ul className="mt-4 space-y-3">
<li>
<Link
href="/products"
href={`${basePath}/products`}
className="text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
>
All Products
</Link>
</li>
<li>
<Link
href="/products"
href={`${basePath}/products`}
className="text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
>
Categories
Expand All @@ -44,30 +49,47 @@ export function Footer() {
<ul className="mt-4 space-y-3">
<li>
<Link
href="/account"
href={`${basePath}/account`}
className="text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
>
My Account
</Link>
</li>
<li>
<Link
href="/account/orders"
href={`${basePath}/account/orders`}
className="text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
>
Order History
</Link>
</li>
<li>
<Link
href="/cart"
href={`${basePath}/cart`}
className="text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
>
Cart
</Link>
</li>
</ul>
</div>

{/* Policies */}
<div>
<h3 className="text-sm font-medium text-neutral-300">Policies</h3>
<ul className="mt-4 space-y-3">
{POLICY_LINKS.map((policy) => (
<li key={policy.slug}>
<Link
href={`${basePath}/policies/${policy.slug}`}
className="text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
>
{policy.name}
</Link>
</li>
))}
</ul>
</div>
</div>

<div className="mt-8 pt-8 border-t border-neutral-800 text-xs text-neutral-500 text-center">
Expand Down
57 changes: 57 additions & 0 deletions src/components/policy/PolicyConsent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { Checkbox } from "@/components/ui/checkbox";
import { CONSENT_POLICIES } from "@/lib/constants/policies";
import { cn } from "@/lib/utils";
import { extractBasePath } from "@/lib/utils/path";

interface PolicyConsentProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
error?: boolean;
}

export function PolicyConsent({
checked,
onCheckedChange,
error,
}: PolicyConsentProps) {
const pathname = usePathname();
const basePath = extractBasePath(pathname);
return (
<div className="flex items-start gap-2.5">
<Checkbox
id="policy-consent"
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
className={cn("mt-0.5", error && "border-red-500")}
/>
<label
htmlFor="policy-consent"
className={cn("text-sm", error ? "text-red-500" : "text-gray-900")}
>
I agree to the{" "}
{CONSENT_POLICIES.map((policy, index) => (
<span key={policy.slug}>
{index > 0 && index < CONSENT_POLICIES.length - 1 && ", "}
{index > 0 && index === CONSENT_POLICIES.length - 1 && " and "}
<Link
href={`${basePath}/policies/${policy.slug}`}
target="_blank"
className={cn(
"underline",
error
? "text-red-500 hover:text-red-700"
: "text-primary hover:text-primary/70",
)}
>
{policy.name}
</Link>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</span>
))}
</label>
</div>
);
}
Loading
Loading