feat: implement Cash on Delivery (COD) payment method

This commit adds comprehensive COD support using Saleor's native Transaction system:

**Architecture:**
- Uses Saleor's native Transaction objects (not metadata)
- Modular payment method configuration
- Extensible design for future payment types (cards, bank transfer, etc.)

**New Components:**
- PaymentMethodSelector: Reusable payment method selection UI
- PaymentMethodCard: Individual payment method card
- CODInstructions: COD-specific instructions and guidance
- PaymentSection: Checkout integration wrapper

**Core Features:**
- COD selected by default for Serbia (default-channel)
- Transaction created automatically on order completion
- Transaction visible in Saleor Dashboard
- Multi-language support (EN, SR, DE, FR)
- No additional fees
- Instructions shown to customer (prepare cash, inspect order, no fee)

**Files Added:**
- docs/COD-IMPLEMENTATION-PLAN.md
- src/lib/config/paymentMethods.ts
- src/lib/saleor/payments/types.ts
- src/lib/saleor/payments/cod.ts
- src/components/payment/PaymentMethodSelector.tsx
- src/components/payment/PaymentMethodCard.tsx
- src/components/payment/CODInstructions.tsx
- src/components/payment/index.ts
- src/app/[locale]/checkout/components/PaymentSection.tsx

**Files Modified:**
- src/app/[locale]/checkout/page.tsx (added payment section, transaction creation)
- src/i18n/messages/{en,sr,de,fr}.json (payment translations)

**Technical Details:**
- Transaction status: NOT_CHARGED
- Available actions: [CHARGE]
- PSP Reference format: COD-{orderNumber}-{timestamp}
- Staff collects cash and fulfills order via Dashboard

Closes: Cash on Delivery payment implementation
This commit is contained in:
Unchained
2026-03-29 06:02:51 +02:00
parent 6f9081cb52
commit ff481f18c3
14 changed files with 1067 additions and 5 deletions

View File

@@ -0,0 +1,55 @@
"use client";
import { useTranslations } from "next-intl";
import { Banknote, Package, CheckCircle } from "lucide-react";
interface CODInstructionsProps {
locale: string;
}
export function CODInstructions({ locale }: CODInstructionsProps) {
const t = useTranslations("Payment.COD");
const instructions = [
{
icon: Banknote,
title: t("instructions.prepareCash"),
description: t("instructions.prepareCashDesc"),
},
{
icon: Package,
title: t("instructions.inspectOrder"),
description: t("instructions.inspectOrderDesc"),
},
{
icon: CheckCircle,
title: t("instructions.noFee"),
description: t("instructions.noFeeDesc"),
},
];
return (
<div className="mt-4 rounded-lg bg-blue-50 p-4">
<h4 className="mb-3 font-medium text-blue-900">
{t("instructions.title")}
</h4>
<div className="space-y-3">
{instructions.map((instruction, index) => {
const Icon = instruction.icon;
return (
<div key={index} className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100">
<Icon className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="font-medium text-blue-900">{instruction.title}</p>
<p className="text-sm text-blue-700">{instruction.description}</p>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { cn } from "@/lib/utils";
import type { PaymentMethod } from "@/lib/saleor/payments/types";
import { Banknote, CreditCard, Building2, LucideIcon } from "lucide-react";
// Icon mapping for payment methods
const iconMap: Record<string, LucideIcon> = {
Banknote,
CreditCard,
Building2,
};
interface PaymentMethodCardProps {
method: PaymentMethod;
isSelected: boolean;
onSelect: () => void;
disabled?: boolean;
locale: string;
}
export function PaymentMethodCard({
method,
isSelected,
onSelect,
disabled = false,
locale,
}: PaymentMethodCardProps) {
const Icon = method.icon ? iconMap[method.icon] : Banknote;
return (
<label
className={cn(
"relative flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition-all",
"hover:border-gray-400",
isSelected && "border-black bg-gray-50",
!isSelected && "border-gray-200 bg-white",
(disabled || !method.available) && "cursor-not-allowed opacity-50"
)}
>
<input
type="radio"
name="payment-method"
value={method.id}
checked={isSelected}
onChange={onSelect}
disabled={disabled || !method.available}
className="sr-only"
/>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100">
{Icon && <Icon className="h-5 w-5 text-gray-600" />}
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">{method.name}</span>
{method.fee > 0 && (
<span className="text-sm text-amber-600">
+{new Intl.NumberFormat(locale === 'sr' ? 'sr-RS' : 'en-US', {
style: 'currency',
currency: 'RSD',
}).format(method.fee)}
</span>
)}
</div>
<p className="mt-1 text-sm text-gray-600">{method.description}</p>
{!method.available && (
<span className="mt-2 inline-block text-xs text-gray-500">
{locale === 'sr' ? 'Uskoro dostupno' : 'Coming soon'}
</span>
)}
{isSelected && (
<div className="absolute right-4 top-4">
<div className="h-2 w-2 rounded-full bg-black" />
</div>
)}
</div>
</label>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import type { PaymentMethod } from "@/lib/saleor/payments/types";
import { PaymentMethodCard } from "./PaymentMethodCard";
import { useTranslations } from "next-intl";
interface PaymentMethodSelectorProps {
methods: PaymentMethod[];
selectedMethodId: string;
onSelectMethod: (methodId: string) => void;
locale: string;
disabled?: boolean;
}
export function PaymentMethodSelector({
methods,
selectedMethodId,
onSelectMethod,
locale,
disabled = false,
}: PaymentMethodSelectorProps) {
const t = useTranslations("Payment");
// Filter to only available methods
const availableMethods = methods.filter((m) => m.available);
if (availableMethods.length === 0) {
return (
<div className="rounded-lg border border-gray-200 p-4 text-center text-gray-500">
{t("noMethodsAvailable")}
</div>
);
}
// If only one method, show it as selected but don't allow changing
const isSingleMethod = availableMethods.length === 1;
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">{t("title")}</h3>
<div className="space-y-3">
{availableMethods.map((method) => (
<PaymentMethodCard
key={method.id}
method={method}
isSelected={selectedMethodId === method.id}
onSelect={() => onSelectMethod(method.id)}
disabled={disabled || isSingleMethod}
locale={locale}
/>
))}
</div>
{isSingleMethod && (
<p className="text-sm text-gray-500">
{t("singleMethodNotice")}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,4 @@
// Payment components exports
export { PaymentMethodSelector } from "./PaymentMethodSelector";
export { PaymentMethodCard } from "./PaymentMethodCard";
export { CODInstructions } from "./CODInstructions";