Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions nextjs_space/app/api/store/[slug]/checkout/quote/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/api-auth";
import { prisma } from "@/lib/db";
import { getCurrentTenant } from "@/lib/tenant/tenant";
import { getTenantDrGreenConfig } from "@/lib/tenant/tenant-config";
import { fetchDeliveryQuote } from "@/lib/drgreen/delivery-quote";
import { getCurrencySymbol } from "@/lib/drgreen/doctor-green-api";
import { apiError } from "@/lib/api-error";
import { parseSlug } from "@/lib/validation/parse-uuid";
import { logger } from "@/lib/logger";

export const dynamic = "force-dynamic";

const ROUTE = "GET /api/store/[slug]/checkout/quote";
const NO_QUOTE = { deliveryCharge: null, currency: null, currencySymbol: null } as const;

/**
* GET /api/store/[slug]/checkout/quote — Dr Green's delivery charge for the
* signed-in customer (BS-F01), read off their Dr Green server cart.
*
* Best-effort by design: no quote is a normal answer (no server cart yet, no
* Dr Green client, Dr Green unreachable) and checkout then shows "calculated
* by Dr Green". It never invents a number — the charge is Dr Green's.
*/
export const GET = withAuth(async (_request, { user }, { slug }) => {
try {
parseSlug(slug);

const email = user.email;
if (!email) return NextResponse.json(NO_QUOTE);

// Resolve the local row by email, as the order-submit route does — the
// auth user's id is not guaranteed to be users.id.
const dbUser = await prisma.users.findFirst({
where: { email },
select: { drGreenClientId: true },
});
const clientId = dbUser?.drGreenClientId;
if (!clientId) return NextResponse.json(NO_QUOTE);

const tenant = await getCurrentTenant();
if (!tenant) {
return apiError(new Error("Store not found"), {
route: ROUTE,
status: 404,
safeMessage: "Store not found",
});
}

const config = await getTenantDrGreenConfig(tenant.id);
try {
const quote = await fetchDeliveryQuote({
clientId,
email,
apiKey: config.apiKey,
secretKey: config.secretKey,
apiUrl: config.apiUrl,
});
if (!quote) return NextResponse.json(NO_QUOTE);
// The symbol lets checkout refuse a charge quoted in a different
// currency from the basket rather than print it under the wrong one.
return NextResponse.json({
...quote,
currencySymbol: quote.currency ? getCurrencySymbol(quote.currency) : null,
});
} catch (quoteError) {
logger.warn("[checkout-quote] Dr Green cart read failed", {
tenantId: tenant.id,
error:
quoteError instanceof Error ? quoteError.message : String(quoteError),
});
return NextResponse.json(NO_QUOTE);
}
} catch (error) {
return apiError(error, {
route: ROUTE,
safeMessage: "Failed to get delivery quote",
});
}
});
10 changes: 10 additions & 0 deletions nextjs_space/app/api/store/[slug]/orders/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,18 @@ export const POST = withAuth(async (request, { user }, { slug }) => {
// 400 the WHOLE order. Validate against the live catalog and drop anything
// no longer available, so a stale item can never fail a customer's order.
let itemsToOrder = cartItems;
// BS-F02: the same live catalogue is the server-side price fallback for a
// line Dr Green's order response does not price. The browser's prices are
// never used to price the order.
let catalogueUnitPrices: Record<string, number> | undefined;
if (cartItems && cartItems.length > 0) {
const country = tenant.countryCode || "ZA";
const liveProducts = await fetchProducts(country, drGreenConfig);
catalogueUnitPrices = Object.fromEntries(
liveProducts
.map((p) => [p.id, p.price || p.retailPrice || 0] as const)
.filter(([, price]) => price > 0), // 0 = "price unavailable"
);
const availableIds = new Set(
liveProducts.filter((p) => p.isAvailable !== false).map((p) => p.id),
);
Expand Down Expand Up @@ -205,6 +214,7 @@ export const POST = withAuth(async (request, { user }, { slug }) => {
secretKey: drGreenConfig.secretKey,
apiUrl: drGreenConfig.apiUrl,
clientCartItems: itemsToOrder,
catalogueUnitPrices,
paymentFlow: directPayEnabled ? "DIRECT" : "LINK",
});

Expand Down
169 changes: 169 additions & 0 deletions nextjs_space/app/store/[slug]/checkout/checkout-order-summary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"use client";

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { AlertCircle } from "lucide-react";
import type { CartItem } from "@/lib/cart-store";
import type { PricingStatus } from "./use-checkout-pricing";

const TEXT = {
color: "hsl(var(--tenant-color-text))",
fontFamily: "var(--tenant-font-base, sans-serif)",
};
const HEADING = {
color: "hsl(var(--tenant-color-heading))",
fontFamily: "var(--tenant-font-base, sans-serif)",
};

export const DELIVERY_UNQUOTED_LABEL = "Calculated by Dr Green";

function money(currency: string, value: number): string {
return `${currency}${value.toFixed(2)}`;
}

function Row({
label,
value,
strong,
}: {
label: string;
value: string;
strong?: boolean;
}) {
return (
<div className="flex justify-between items-center text-sm">
<span style={TEXT}>{label}</span>
<span
className={strong ? "text-lg font-bold" : "font-medium"}
style={
strong
? {
color: "hsl(var(--tenant-color-primary))",
fontFamily: "var(--tenant-font-heading, sans-serif)",
}
: HEADING
}
>
{value}
</span>
</div>
);
}

/**
* Checkout order summary (BS-F01): each line at the live catalogue price,
* subtotal, Dr Green's delivery charge and the total. Prices are shown as they
* are — no was/now, badge or percentage (Dr Green Flex PRD non-goals).
*/
export function CheckoutOrderSummary({
items,
currency,
subtotal,
deliveryCharge,
pricingStatus,
updatedIds,
removedNames,
}: {
items: CartItem[];
currency: string;
subtotal: number;
deliveryCharge: number | null;
pricingStatus: PricingStatus;
updatedIds: ReadonlySet<string>;
removedNames: string[];
}) {
const total = subtotal + (deliveryCharge ?? 0);

return (
<Card
className="sticky top-24"
style={{
backgroundColor: "hsl(var(--tenant-color-background))",
borderColor: "hsl(var(--tenant-color-primary) / 0.12)",
}}
>
<CardHeader className="pb-3">
<CardTitle
className="text-base"
style={{
color: "hsl(var(--tenant-color-heading))",
fontFamily: "var(--tenant-font-heading, sans-serif)",
}}
>
Order Summary
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{removedNames.length > 0 && (
<div className="flex items-start gap-2 bg-amber-50 border border-amber-200 text-amber-800 px-3 py-2 rounded-lg text-xs">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>
{removedNames.join(", ")}{" "}
{removedNames.length === 1 ? "is" : "are"} no longer available
and {removedNames.length === 1 ? "was" : "were"} removed from
your basket.
</span>
</div>
)}

<div className="space-y-2">
{items.map((item) => (
<div key={item.productId} className="flex justify-between text-sm">
<span className="truncate pr-2" style={TEXT}>
{item.name} ({item.quantity}g)
{updatedIds.has(item.productId) && (
<span
className="block text-xs"
style={{ color: "hsl(var(--tenant-color-primary))" }}
>
Price updated
</span>
)}
</span>
<span className="font-medium whitespace-nowrap" style={HEADING}>
{money(currency, item.price * item.quantity)}
</span>
</div>
))}
</div>

<Separator />

<Row label="Subtotal" value={money(currency, subtotal)} />
<Row
label="Delivery"
value={
deliveryCharge === null
? DELIVERY_UNQUOTED_LABEL
: money(currency, deliveryCharge)
}
/>
<Row
label={deliveryCharge === null ? "Total (excl. delivery)" : "Total"}
value={money(currency, total)}
strong
/>

{pricingStatus === "unconfirmed" && (
<p className="text-xs" style={TEXT}>
We could not confirm today&apos;s prices. Your order will be
charged at the store&apos;s current price.
</p>
)}

<div
className="rounded-lg p-3"
style={{
backgroundColor: "hsl(var(--tenant-color-primary) / 0.06)",
border: "1px solid hsl(var(--tenant-color-primary) / 0.12)",
}}
>
<p className="text-xs leading-relaxed" style={TEXT}>
Payment instructions will be sent after your order is placed.
Crypto and card options available.
</p>
</div>
</CardContent>
</Card>
);
}
Loading
Loading