Skip to content
Open
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
9 changes: 8 additions & 1 deletion cod-client-astro/locales/ar/orders.json
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,14 @@
"tracking_encaissed": "تم تحصيل المبلغ",
"tracking_payed": "تم الدفع",
"custom_answers": "إجابات العميل",
"custom_answers_note": "إجابات أسئلتك الخاصة في نموذج الطلب. لا تُرسل أبداً لشركات التوصيل."
"custom_answers_note": "إجابات أسئلتك الخاصة في نموذج الطلب. لا تُرسل أبداً لشركات التوصيل.",
"customer_history": {
"new": "زبون جديد",
"pending": "طلبات سابقة قيد المعالجة",
"reliable": "زبون موثوق",
"risky": "إرجاع متكرر",
"summary": "طلبات سابقة: {total} · سُلّمت: {delivered} · أُرجعت: {returned} · أُلغيت: {cancelled}"
}
},
"next_status": {
"new": "تأكيد الطلب",
Expand Down
9 changes: 8 additions & 1 deletion cod-client-astro/locales/en/orders.json
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,14 @@
"tracking_encaissed": "Payment collected",
"tracking_payed": "Paid out",
"custom_answers": "Customer answers",
"custom_answers_note": "Answers to your own checkout questions. They are never sent to carriers."
"custom_answers_note": "Answers to your own checkout questions. They are never sent to carriers.",
"customer_history": {
"new": "New customer",
"pending": "Previous orders in progress",
"reliable": "Reliable customer",
"risky": "Frequent returns",
"summary": "Previous orders: {total} · delivered: {delivered} · returned: {returned} · cancelled: {cancelled}"
}
},
"next_status": {
"new": "Confirm Order",
Expand Down
9 changes: 8 additions & 1 deletion cod-client-astro/locales/fr/orders.json
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,14 @@
"tracking_encaissed": "Montant encaissé",
"tracking_payed": "Paiement effectué",
"custom_answers": "Réponses du client",
"custom_answers_note": "Réponses à vos propres questions de commande. Elles ne sont jamais envoyées aux transporteurs."
"custom_answers_note": "Réponses à vos propres questions de commande. Elles ne sont jamais envoyées aux transporteurs.",
"customer_history": {
"new": "Nouveau client",
"pending": "Commandes précédentes en cours",
"reliable": "Client fiable",
"risky": "Retours fréquents",
"summary": "Commandes précédentes : {total} · livrées : {delivered} · retournées : {returned} · annulées : {cancelled}"
}
},
"next_status": {
"new": "Confirmer la commande",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { History } from "lucide-react";
import { Badge, type BadgeTone } from "@/components/ui";
import { useT } from "@/i18n/react";
import {
customerHistoryTone,
type CustomerHistoryTone,
} from "@/features/orders/model";
import type { CustomerHistory } from "@/features/orders/types";

const BADGE_TONE: Record<CustomerHistoryTone, BadgeTone> = {
new: "neutral",
pending: "info",
reliable: "success",
risky: "critical",
};

/**
* How this customer's other orders ended — lets the confirmation team spot
* repeat refusers (and loyal buyers) before calling or dispatching.
*/
export function CustomerHistoryBadge({ history }: { history: CustomerHistory }) {
const t = useT("orders");
const tone = customerHistoryTone(history);

return (
<div className="mt-2 flex flex-col items-start gap-1">
<Badge tone={BADGE_TONE[tone]}>
<History size={11} className="me-1" />
{t(`detail.customer_history.${tone}`)}
</Badge>
{history.total > 0 && (
<p className="text-xs text-muted-foreground">
{t("detail.customer_history.summary")
.replace("{total}", String(history.total))
.replace("{delivered}", String(history.delivered))
.replace("{returned}", String(history.returned))
.replace("{cancelled}", String(history.cancelled))}
</p>
)}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@ import { Mail, MapPin, Phone, UserRound } from "lucide-react";
import { Card } from "@/components/ui";
import { useT } from "@/i18n/react";
import type { OrderDetail } from "@/features/orders/types";
import { CustomerHistoryBadge } from "@/features/orders/components/CustomerHistoryBadge";

interface OrderCustomerCardProps {
order: Pick<
OrderDetail,
"customerName" | "phone" | "wilaya" | "commune" | "address" | "customerEmail"
| "customerName"
| "phone"
| "wilaya"
| "commune"
| "address"
| "customerEmail"
| "customerHistory"
>;
}

Expand Down Expand Up @@ -45,6 +52,9 @@ export function OrderCustomerCard({ order }: OrderCustomerCardProps) {
{order.customerEmail}
</a>
)}
{order.customerHistory && (
<CustomerHistoryBadge history={order.customerHistory} />
)}
</div>
</div>
<div className="flex items-start gap-3">
Expand Down
30 changes: 30 additions & 0 deletions cod-client-astro/src/features/orders/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
shipmentUpdateFieldSupport,
sortOrders,
abandonedBasketView,
customerHistoryTone,
} from "./model";
import type { OrderListItem } from "./types";

Expand Down Expand Up @@ -375,3 +376,32 @@ describe("abandonedBasketView", () => {
});
});
});

describe("customerHistoryTone", () => {
const history = (delivered: number, returned: number, cancelled = 0, inProgress = 0) => ({
total: delivered + returned + cancelled + inProgress,
delivered,
returned,
cancelled,
});

it("flags a first-time customer as new", () => {
expect(customerHistoryTone(history(0, 0))).toBe("new");
});

it("is pending while no past order reached the carrier outcome", () => {
expect(customerHistoryTone(history(0, 0, 0, 2))).toBe("pending");
// Cancellations may be the merchant's call — they never colour the badge.
expect(customerHistoryTone(history(0, 0, 3))).toBe("pending");
});

it("is reliable when deliveries outnumber returns", () => {
expect(customerHistoryTone(history(3, 1))).toBe("reliable");
expect(customerHistoryTone(history(1, 0, 4))).toBe("reliable");
});

it("is risky when returns match or exceed deliveries", () => {
expect(customerHistoryTone(history(1, 1))).toBe("risky");
expect(customerHistoryTone(history(0, 2))).toBe("risky");
});
});
20 changes: 20 additions & 0 deletions cod-client-astro/src/features/orders/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Locale } from "@/i18n/config";
import type {
AbandonedOrder,
AbandonedOrderStatus,
CustomerHistory,
OrderListItem,
OrderStatus,
} from "./types";
Expand Down Expand Up @@ -466,3 +467,22 @@ export function filterAbandonedOrders(
return true;
});
}

// ─── Customer delivery history badge ────────────────────────────────────────

export type CustomerHistoryTone = "new" | "pending" | "reliable" | "risky";

/**
* Classify a customer's past orders for the order detail badge.
* Only carrier outcomes judge the customer: a cancellation may be the
* merchant's own call, so it is shown but never colours the badge.
* new → no other orders
* pending → other orders exist but none delivered or returned yet
* risky → returns at least match deliveries
* reliable → more deliveries than returns
*/
export function customerHistoryTone(history: CustomerHistory): CustomerHistoryTone {
if (history.total === 0) return "new";
if (history.delivered === 0 && history.returned === 0) return "pending";
return history.returned >= history.delivered ? "risky" : "reliable";
}
9 changes: 9 additions & 0 deletions cod-client-astro/src/features/orders/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,19 @@ export interface OrderListItem extends OrderBase {
lastUpdatedBy?: string | null;
}

/** How the same customer's OTHER orders ended (current order excluded). */
export interface CustomerHistory {
total: number;
delivered: number;
returned: number;
cancelled: number;
}

export interface OrderDetail extends OrderBase {
products: OrderProduct[];
statusHistory: StatusHistoryItem[];
labelUrl?: string | null;
customerHistory?: CustomerHistory;
}

export interface Driver {
Expand Down
166 changes: 166 additions & 0 deletions cod-server/src/endpoints/orders/orders.customer-history-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* getOrderById → customerHistory — real-D1 E2E + summarizer unit tests.
*
* customerHistory tells the merchant how this customer's OTHER orders ended
* (shown as a badge on the order detail page):
* • the current order is always excluded
* • outcome counts cover terminal statuses only (delivered/returned/cancelled)
* • total also counts in-progress orders
* • another customer's orders never leak in
*/

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { Miniflare } from "miniflare";
import { readFileSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "@/db/schema";
import type { AppDb } from "@/db";
import {
getOrderById,
summarizeCustomerHistory,
} from "../../../../cod-shared/queries/orders";

const registry: Miniflare[] = [];
let db: AppDb;

type Status = (typeof schema.orders.$inferInsert)["status"];

beforeAll(async () => {
const mf = new Miniflare({
script: "export default { fetch() { return new Response('ok'); } }",
modules: true,
d1Databases: { DB: "test-db" },
});
registry.push(mf);
const d1 = await mf.getD1Database("DB");
const dir = resolve(__dirname, "../../db/migrations");
const preparedStatements: D1PreparedStatement[] = [];
for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) {
const statements = readFileSync(`${dir}/${file}`, "utf8")
.split("--> statement-breakpoint")
.flatMap((s) => s.split(/;\s*\n/))
.map((s) => s.replace(/;+\s*$/, "").trim())
.filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0);
for (const statement of statements) {
preparedStatements.push(d1.prepare(statement));
}
}
for (let i = 0; i < preparedStatements.length; i += 50) {
await d1.batch(preparedStatements.slice(i, i + 50));
}
db = drizzle(d1 as unknown as D1Database, { schema }) as unknown as AppDb;

const now = new Date().toISOString();

await db.insert(schema.customers).values([
{ id: "cust-repeat", name: "Repeat Buyer", phone: "0770000101", wilaya: "الجزائر", createdAt: now },
{ id: "cust-new", name: "New Buyer", phone: "0770000102", wilaya: "الجزائر", createdAt: now },
{ id: "cust-other", name: "Other Buyer", phone: "0770000103", wilaya: "الجزائر", createdAt: now },
]);

const order = (id: string, customerId: string, phone: string, status: Status) => ({
id,
orderNumber: `ORD-${id}`,
customerId,
customerName: "Buyer",
phone,
price: 1000,
status,
deliveryMethod: "unassigned" as const,
deliveryType: "home" as const,
deliveryFee: 0,
driverFee: 0,
codAmount: 1000,
createdAt: now,
updatedAt: now,
});

// One insert per row — D1 caps bound variables per statement.
const seedOrders = [
// Repeat buyer: current order + 6 others (2 delivered, 2 returned, 1 cancelled, 1 in progress)
order("rep-current", "cust-repeat", "0770000101", "new"),
order("rep-d1", "cust-repeat", "0770000101", "delivered"),
order("rep-d2", "cust-repeat", "0770000101", "delivered"),
order("rep-r1", "cust-repeat", "0770000101", "returned"),
order("rep-r2", "cust-repeat", "0770000101", "returned"),
order("rep-c1", "cust-repeat", "0770000101", "cancelled"),
order("rep-p1", "cust-repeat", "0770000101", "dispatched"),
// First-time buyer: only the current order
order("new-current", "cust-new", "0770000102", "new"),
// Another customer's history must not leak into the repeat buyer's counts
order("oth-r1", "cust-other", "0770000103", "returned"),
order("oth-r2", "cust-other", "0770000103", "returned"),
];
for (const row of seedOrders) {
await db.insert(schema.orders).values(row);
}
}, 120_000);

afterAll(async () => {
for (const mf of registry) await mf.dispose();
});

describe("getOrderById customerHistory — real D1", () => {
it("counts the customer's other orders by outcome, excluding the current order", async () => {
const order = await getOrderById(db, "rep-current");

expect(order!.customerHistory).toEqual({
total: 6,
delivered: 2,
returned: 2,
cancelled: 1,
});
});

it("excludes the viewed order even when it is terminal", async () => {
const order = await getOrderById(db, "rep-d1");

expect(order!.customerHistory).toEqual({
total: 6,
delivered: 1,
returned: 2,
cancelled: 1,
});
});

it("returns zeros for a first-time customer", async () => {
const order = await getOrderById(db, "new-current");

expect(order!.customerHistory).toEqual({
total: 0,
delivered: 0,
returned: 0,
cancelled: 0,
});
});
});

describe("summarizeCustomerHistory", () => {
it("returns zeros for no rows", () => {
expect(summarizeCustomerHistory([])).toEqual({
total: 0,
delivered: 0,
returned: 0,
cancelled: 0,
});
});

it("counts in-progress statuses in total only", () => {
expect(
summarizeCustomerHistory([
{ status: "delivered", count: 3 },
{ status: "confirmed", count: 2 },
{ status: "out_for_delivery", count: 1 },
]),
).toEqual({ total: 6, delivered: 3, returned: 0, cancelled: 0 });
});

it("coerces string counts from the driver", () => {
expect(
summarizeCustomerHistory([
{ status: "returned", count: "4" as unknown as number },
]),
).toEqual({ total: 4, delivered: 0, returned: 4, cancelled: 0 });
});
});
Loading