Skip to content

Commit 95e40e9

Browse files
Order Timeline (#88)
Co-authored-by: Claude <[email protected]>
1 parent c02a45b commit 95e40e9

28 files changed

+889
-16
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"providers": [
3434
"DuncanMcClean\\Cargo\\ServiceProvider",
3535
"DuncanMcClean\\Cargo\\Discounts\\DiscountServiceProvider",
36+
"DuncanMcClean\\Cargo\\Orders\\OrderServiceProvider",
3637
"DuncanMcClean\\Cargo\\Payments\\PaymentServiceProvider",
3738
"DuncanMcClean\\Cargo\\Shipping\\ShippingServiceProvider"
3839
]

docs/docs/migrating-from-simple-commerce.md

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,6 @@ Since orders are no longer stored in collections, Cargo provides its own "Order"
160160

161161
During the migration process, Cargo will have attempted to copy across any custom fields you added to the order collection's blueprint.
162162

163-
### Status Log
164-
The "Status Log" feature present in Simple Commerce doesn't exist in Cargo. Cargo simply displays the current status of orders.
165-
166-
If you need to occasionally reference the history of order statuses, you may wish to take advantage of [Statamic's Git Automation](https://statamic.dev/git-automation) if you're storing orders in flat files, or use a package like [`spatie/laravel-activitylog`](https://github.com/spatie/laravel-activitylog) if you're using a database.
167-
168163
## Customers
169164
By default, Simple Commerce stored information about your customers in a customers collection or `customers` database table.
170165

docs/docs/orders.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,72 @@ If you want to customise the packing slip, you may publish it with the following
3636
php artisan vendor:publish --tag=cargo-packing-slip
3737
```
3838

39+
## Timeline
40+
The Timeline feature provides a complete audit trail of order changes, visible when viewing orders in the Control Panel. It automatically tracks key events including:
41+
42+
- Order creation and updates
43+
- Status changes
44+
- Refunds
45+
46+
Each event records the authenticated user who made the change and any relevant metadata.
47+
48+
![Order Timeline](/images/order-timeline.png)
49+
50+
### Custom Timeline Events
51+
You can extend the Timeline by registering custom event types to track additional order activities beyond the built-in events.
52+
53+
Create a PHP class that extends `TimelineEventType` to represent your custom event:
54+
55+
```php
56+
// app/TimelineEventTypes/OrderDelivered.php
57+
58+
use DuncanMcClean\Cargo\Orders\TimelineEventType;
59+
60+
class OrderDelivered extends TimelineEventType
61+
{
62+
public function message() : string
63+
{
64+
return "Order Delivered by Royal Mail";
65+
}
66+
}
67+
```
68+
69+
Register your custom event type in your `AppServiceProvider`:
70+
71+
```php
72+
// app/Providers/AppServiceProvider.php
73+
74+
public function boot(): void
75+
{
76+
OrderDelivered::register();
77+
}
78+
```
79+
80+
Then listen for the relevant event in your application and append your custom timeline event to the order:
81+
82+
```php
83+
// app/Listeners/RoyalMailPackageDeliveredListener.php
84+
85+
use DuncanMcClean\Cargo\Facades\Order;
86+
use RoyalMail\Events\PackageDelivered;
87+
88+
class RoyalMailPackageDeliveredListener
89+
{
90+
public function handle(PackageDelivered $event)
91+
{
92+
$order = Order::find('the-order-id');
93+
94+
$order->appendTimelineEvent(
95+
type: OrderDelivered::class,
96+
metadata: [
97+
'Foo' => 'bar',
98+
'Baz' => 'qux',
99+
],
100+
);
101+
}
102+
}
103+
```
104+
39105
## Storage
40106
Out of the box, orders are stored as YAML files in the `content/cargo/orders` directory. If you wish, you can change the directory in the `cargo.php` config file:
41107

docs/extending/events/list.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,20 @@ public function handle(OrderSaved $event)
195195
}
196196
```
197197

198+
### OrderStatusUpdated
199+
`DuncanMcClean\Cargo\Events\OrderStatusUpdated`
200+
201+
Dispatched when an order's status is changed.
202+
203+
```php
204+
public function handle(OrderStatusUpdated $event)
205+
{
206+
$event->order;
207+
$event->originalStatus;
208+
$event->updatedStatus;
209+
}
210+
```
211+
198212
### OrderShipped
199213
`DuncanMcClean\Cargo\Events\OrderShipped`
200214

docs/images/order-timeline.png

117 KB
Loading

lang/en/messages.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,10 @@
2424
'tax_zones_intro' => 'Tax Rates allow you to define the tax rates for each tax class, with different rates per country, state or postal code.',
2525
'tax_zones_rates_instructions' => 'Define the tax rates available for this zone, per tax class.',
2626
'tax_zones_type_instructions' => 'Where should this tax zone apply?',
27+
'timeline_events' => [
28+
'order_created' => 'Order was created',
29+
'order_refunded' => 'Order was refunded for :amount',
30+
'order_status_changed' => 'Status changed to :status',
31+
'order_updated' => 'Order was updated',
32+
],
2733
];
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<script setup>
2+
import { Fieldtype, DateFormatter } from '@statamic/cms';
3+
import { Heading, Subheading, Avatar, Table, TableRows, TableRow, TableCell, Icon } from '@statamic/cms/ui';
4+
import { ref } from 'vue';
5+
6+
const emit = defineEmits(Fieldtype.emits);
7+
const props = defineProps(Fieldtype.props);
8+
const { expose } = Fieldtype.use(emit, props);
9+
defineExpose(expose);
10+
11+
const events = ref(props.value);
12+
13+
const formatRelativeDate = (value) => {
14+
const today = new Date();
15+
today.setHours(0, 0, 0, 0);
16+
17+
const isToday = value === Math.floor(today.getTime() / 1000);
18+
19+
return isToday
20+
? __('Today')
21+
: DateFormatter.format(value * 1000, {
22+
month: 'long',
23+
day: 'numeric',
24+
year: 'numeric',
25+
});
26+
};
27+
28+
const formatTime = (date) => DateFormatter.format(date * 1000, 'time');
29+
30+
const selected = ref([]);
31+
32+
const toggleSelection = (event) => {
33+
if (selected.value.includes(event.id)) {
34+
selected.value = selected.value.filter((id) => id !== event.id);
35+
return;
36+
}
37+
38+
if (event.metadata.length === 0) return;
39+
40+
selected.value.push(event.id);
41+
};
42+
</script>
43+
44+
<template>
45+
<div class="flex flex-col gap-y-6">
46+
<div v-for="group in events" :key="group.day">
47+
<Heading size="sm" class="mb-1 text-gray-600 dark:text-gray-300" v-text="formatRelativeDate(group.day)" />
48+
49+
<div class="relative grid">
50+
<div class="absolute inset-y-0 left-3 top-3 border-l-1 border-gray-400 dark:border-gray-600 border-dashed" />
51+
52+
<div v-for="event in group.events" class="relative block py-2">
53+
<div class="flex gap-3">
54+
<Avatar v-if="event.user" :user="event.user" class="size-6 shrink-0 mt-1" />
55+
56+
<div v-else class="size-6 flex items-center justify-center shrink-0 bg-white dark:bg-gray-900 rounded-full p-1 border border-gray-200 dark:border-white/15">
57+
<Icon :name="meta.cargoMark" class="w-full" />
58+
</div>
59+
60+
<div class="grid gap-1 w-full">
61+
<button
62+
class="flex items-center gap-1"
63+
:class="{ 'cursor-pointer': event.metadata.length != 0 }"
64+
@click="toggleSelection(event)"
65+
>
66+
<Heading class="font-medium" v-text="event.message" />
67+
<Icon
68+
v-if="event.metadata.length != 0"
69+
name="chevron-down"
70+
class="text-gray-400 dark:text-white/40"
71+
:class="{ 'rotate-180': selected.includes(event.id) }"
72+
/>
73+
</button>
74+
75+
<Subheading
76+
class="text-xs text-gray-500! dark:text-gray-400!"
77+
:class="{ 'text-gray-800! dark:text-white!': false }"
78+
>
79+
{{ formatTime(event.timestamp) }}
80+
<template v-if="event.user">
81+
by {{ event.user.name || event.user.email }}
82+
</template>
83+
</Subheading>
84+
85+
<div
86+
v-if="event.metadata && selected.includes(event.id)"
87+
class="border border-gray-200 dark:border-white/15 rounded-md py-0.5 px-2 mt-2 mb-4"
88+
>
89+
<Table class="w-full">
90+
<TableRows>
91+
<TableRow v-for="(value, key) in event.metadata">
92+
<TableCell class="font-medium text-sm" v-text="__(key)" />
93+
<TableCell class="break-all text-sm" v-text="__(value)" />
94+
</TableRow>
95+
</TableRows>
96+
</Table>
97+
</div>
98+
</div>
99+
</div>
100+
</div>
101+
</div>
102+
</div>
103+
</div>
104+
</template>

resources/js/cp.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import MoneyFieldtype from './components/fieldtypes/MoneyFieldtype.vue';
77
import OrderReceiptFieldtype from './components/fieldtypes/OrderReceiptFieldtype.vue';
88
import ProductVariantsFieldtype from './components/fieldtypes/ProductVariantsFieldtype.vue';
99
import OrderStatusFieldtypeIndex from './components/fieldtypes/OrderStatusFieldtypeIndex.vue';
10+
import OrderTimelineFieldtype from './components/fieldtypes/OrderTimelineFieldtype.vue';
1011
import PaymentDetailsFieldtype from './components/fieldtypes/PaymentDetailsFieldtype.vue';
1112
import ShippingDetailsFieldtype from './components/fieldtypes/ShippingDetailsFieldtype.vue';
1213
import StatesFieldtype from './components/fieldtypes/StatesFieldtype.vue';
@@ -31,7 +32,7 @@ Statamic.booting(() => {
3132
Statamic.$components.register('order_receipt-fieldtype', OrderReceiptFieldtype);
3233
Statamic.$components.register('product_variants-fieldtype', ProductVariantsFieldtype);
3334
Statamic.$components.register('order_status-fieldtype-index', OrderStatusFieldtypeIndex);
34-
35+
Statamic.$components.register('order_timeline-fieldtype', OrderTimelineFieldtype);
3536
Statamic.$components.register('payment_details-fieldtype', PaymentDetailsFieldtype);
3637
Statamic.$components.register('shipping_details-fieldtype', ShippingDetailsFieldtype);
3738
Statamic.$components.register('states-fieldtype', StatesFieldtype);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace DuncanMcClean\Cargo\Commands\Migration\Concerns;
4+
5+
use Carbon\Carbon;
6+
use Illuminate\Support\Collection;
7+
8+
trait MapsTimelineEvents
9+
{
10+
private function mapTimelineEvents(Collection $data): array
11+
{
12+
$previousStatus = null;
13+
14+
return collect($data->get('status_log'))
15+
->map(function (array $statusLogEvent) use (&$previousStatus): ?array {
16+
$status = $statusLogEvent['status'];
17+
$datetime = Carbon::parse($statusLogEvent['timestamp'])->format('Y-m-d H:i:s');
18+
19+
$mappedStatus = $this->mapStatusLogStatus($status);
20+
21+
if ($status === 'placed') {
22+
return [
23+
'datetime' => $datetime,
24+
'type' => 'order_created',
25+
];
26+
}
27+
28+
$event = [
29+
'datetime' => $datetime,
30+
'type' => 'order_status_changed',
31+
'metadata' => array_filter([
32+
'Original Status' => $previousStatus,
33+
'New Status' => $mappedStatus,
34+
]),
35+
];
36+
37+
$previousStatus = $mappedStatus;
38+
39+
return $event;
40+
})
41+
->values()
42+
->all();
43+
}
44+
45+
private function mapStatusLogStatus(string $status): string
46+
{
47+
return match ($status) {
48+
'placed' => 'payment_pending',
49+
'paid' => 'payment_received',
50+
'dispatched', 'delivered' => 'shipped',
51+
'cancelled' => 'cancelled',
52+
'returned' => 'returned',
53+
default => $status,
54+
};
55+
}
56+
}

src/Commands/Migration/MigrateCarts.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222

2323
class MigrateCarts extends Command
2424
{
25-
use \DuncanMcClean\Cargo\Commands\Migration\Concerns\MapsAddresses, \DuncanMcClean\Cargo\Commands\Migration\Concerns\MapsCustomData, \DuncanMcClean\Cargo\Commands\Migration\Concerns\MapsLineItems, \DuncanMcClean\Cargo\Commands\Migration\Concerns\MapsOrderDates, RunsInPlease;
25+
use Concerns\MapsAddresses,
26+
Concerns\MapsCustomData,
27+
Concerns\MapsLineItems,
28+
Concerns\MapsOrderDates,
29+
RunsInPlease;
2630

2731
protected $signature = 'statamic:cargo:migrate:carts';
2832

0 commit comments

Comments
 (0)