Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b6c9809
Fix QA issues in the panel Customers section
glennjacobs Jul 16, 2026
25454d8
Add breadcrumbs to the customer create and edit pages
glennjacobs Jul 16, 2026
60daddc
Bring the customer edit page up to the design prototype
glennjacobs Jul 16, 2026
f792f38
Show a user count in the customer header for multi-user customers
glennjacobs Jul 16, 2026
d42c803
Label customer notes as "Admin notes"
glennjacobs Jul 16, 2026
28e162d
Rename customers.notes to admin_notes
glennjacobs Jul 16, 2026
5fee73f
Spec 0050: panel order-value chart and public charting component
glennjacobs Jul 16, 2026
76b1057
Add TimeSeriesChart to the panel surface and an order-value chart to …
glennjacobs Jul 16, 2026
6d76140
Animate the TimeSeriesChart draw-in
glennjacobs Jul 16, 2026
3cdd847
Move the add-address form into a modal
glennjacobs Jul 16, 2026
e6d0c26
Edit addresses in the same modal as add
glennjacobs Jul 16, 2026
0cc2b96
Replace the static flash bars with a FlashMessage component
glennjacobs Jul 16, 2026
99c9d5e
Bring the customer index table up to the design prototype
glennjacobs Jul 17, 2026
4fd56b3
Add the average lifetime value KPI with a cached KPI block
glennjacobs Jul 17, 2026
abb3a23
Add a clear-filters text link to the customer index toolbar
glennjacobs Jul 17, 2026
6c1a876
Add a clearable option to TextInput and use it on the customer search
glennjacobs Jul 17, 2026
c92c90a
Render KpiCard as a static div unless a click handler is bound
glennjacobs Jul 17, 2026
c0725fe
Make the breadcrumb bar sticky on desktop per the design prototype
glennjacobs Jul 17, 2026
f89482a
Align the Title field with its siblings via standard FieldLabel spacing
glennjacobs Jul 17, 2026
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ When asked to start a new piece of work that isn't already specced, write the sp

- **Translations**: 16 locales live under each sub-package's `resources/lang/` (`ar, bg, de, en, es, fa, fr, hr, hu, mn, nl, pl, pt_BR, ro, tr, vi`). When adding or renaming a translation key, update **every** locale — English first, then mirror the key (English value is acceptable as a placeholder) across the other 15.
- **Public contract surface**: this package is consumed by downstream apps. Treat anything outside `Models/Concerns/`, `Support/`, and internal namespaces as a contract — breaking changes require a spec and a Rector rule in the `upgrade` package.
- **Migrations**: v2 ships a flat baseline. Schema changes go in a new migration; do not edit the baseline. The `upgrade` package handles v1 → v2 transformations.
- **Migrations**: v2 ships a flat baseline. While v2 is in alpha, fold schema changes into the existing baseline migrations rather than adding change migrations; once v2 is released, schema changes go in new migrations instead. The `upgrade` package handles v1 → v2 transformations.
- **Filament**: v5 with the schemas refactor applied. Use schemas, not the deprecated wrapper traits.
- **Comments**: describe code in its own vocabulary — use the terms the API actually exposes (a `Fulfilment` is a "fulfilment", not a "parcel"). Don't introduce a metaphor that appears nowhere in the code. Keep comments and docblocks ASCII-only, except the established house style — em dash (`—`), arrow (`→`) in state-transition notes, and ellipsis (`…`). No other non-ASCII symbols (`§`, `Σ`, `⟹`, `≥`, emoji, …); spell them out (`section A`, `sum of`, `implies`, `>=`). This applies to comments, docblocks, and user-facing strings — the `resources/lang/` translation files are the only place non-ASCII text belongs.
- **Namespace**: `Lunar\Core\…` for core, `Lunar\Admin\…`, `Lunar\Search\…`, etc. for other sub-packages.
Expand Down
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Items tagged _(judgement)_ are genuine line-calls worth revisiting.
## Outstanding

- Inertia admin panel — new `lunarphp/panel` package: auth, extension points (navigation, slots, table columns, row/bulk/page actions with shared ordering), Customers CRUD, Channels settings (spec 0049)
- Panel order-value chart on the customer edit page + `TimeSeriesChart` on the add-on surface (spec 0050)
- Default professional customer notifications for the order lifecycle (spec 0036) _(judgement)_
- Bulk order operations — goal-oriented bulk actions on the orders table (spec 0026)
- Line-item refunds — refund specific lines/quantities via a dedicated refund page (spec 0028)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public function up(): void
$table->timestamps();
$table->jsonb('attribute_data')->nullable();
$table->string('account_ref')->nullable()->index();
$table->text('admin_notes')->nullable();
});
}

Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/Actions/Customers/CreateCustomerAddress.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,17 @@ class CreateCustomerAddress implements CreatesCustomerAddress
{
public function execute(Customer $customer, array $attributes): Address
{
/** @var Address */
return $customer->addresses()->create($attributes);
/** @var Address $address */
$address = $customer->addresses()->create($attributes);

// Explicit timeline entry on the customer, so address changes show
// up in the customer's activity log alongside created/updated.
activity()
->causedBy(auth()->user())
->performedOn($customer)
->event('address-created')
->log('address-created');

return $address;
}
}
10 changes: 10 additions & 0 deletions packages/core/src/Actions/Customers/DeleteCustomerAddress.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ class DeleteCustomerAddress implements DeletesCustomerAddress
{
public function execute(Address $address): void
{
$customer = $address->customer;

$address->delete();

if ($customer) {
activity()
->causedBy(auth()->user())
->performedOn($customer)
->event('address-deleted')
->log('address-deleted');
}
}
}
6 changes: 6 additions & 0 deletions packages/core/src/Actions/Customers/LinkCustomerUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,11 @@ public function execute(Customer $customer, string $email): void
$user = $userModel::where('email', $email)->firstOrFail();

$customer->users()->syncWithoutDetaching([$user->getKey()]);

activity()
->causedBy(auth()->user())
->performedOn($customer)
->event('user-linked')
->log('user-linked');
}
}
6 changes: 6 additions & 0 deletions packages/core/src/Actions/Customers/UnlinkCustomerUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,11 @@ class UnlinkCustomerUser implements UnlinksCustomerUser
public function execute(Customer $customer, int|string $userId): void
{
$customer->users()->detach($userId);

activity()
->causedBy(auth()->user())
->performedOn($customer)
->event('user-unlinked')
->log('user-unlinked');
}
}
11 changes: 7 additions & 4 deletions packages/core/src/Actions/Customers/UpdateCustomer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@
use Lunar\Core\Models\Customer;

/**
* Update a customer's attributes and sync it to the given customer groups —
* an empty set clears any groups the customer currently belongs to.
* Update a customer's attributes and, when a group set is given, sync it to
* those customer groups — an empty set clears any current groups, while null
* leaves group membership untouched.
*/
class UpdateCustomer implements UpdatesCustomer
{
public function execute(Customer $customer, array $attributes, array $customerGroupIds = []): Customer
public function execute(Customer $customer, array $attributes, ?array $customerGroupIds = null): Customer
{
$customer->update($attributes);

$customer->customerGroups()->sync($customerGroupIds);
if ($customerGroupIds !== null) {
$customer->customerGroups()->sync($customerGroupIds);
}

return $customer;
}
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/Actions/Customers/UpdateCustomerAddress.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ public function execute(Address $address, array $attributes): Address
{
$address->update($attributes);

if ($address->customer) {
activity()
->causedBy(auth()->user())
->performedOn($address->customer)
->event('address-updated')
->log('address-updated');
}

return $address;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface UpdatesCustomer
{
/**
* @param array<string, mixed> $attributes
* @param array<int, int> $customerGroupIds
* @param ?array<int, int> $customerGroupIds null leaves group membership untouched; an empty array clears it
*/
public function execute(Customer $customer, array $attributes, array $customerGroupIds = []): Customer;
public function execute(Customer $customer, array $attributes, ?array $customerGroupIds = null): Customer;
}
1 change: 1 addition & 0 deletions packages/core/src/Models/Customer.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
* @property ?string $company_name
* @property ?string $tax_identifier
* @property ?string $account_ref
* @property ?string $admin_notes
* @property ?array $attribute_data
* @property ?array $meta
* @property ?Carbon $created_at
Expand Down
24 changes: 20 additions & 4 deletions packages/panel-addon-example/resources/js/pages/Widgets/Index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { computed } from 'vue';
// under `example-addon::{group}` keys — served, cached and versioned by the
// panel's translations endpoint like the panel's own strings.
import { useI18n } from 'vue-i18n';
import { Breadcrumbs, DataTable, PageHeader, PageZone, Button, SideCard, StatusBadge } from '@lunarphp/panel';
import { Breadcrumbs, DataTable, FlashMessage, PageHeader, PageZone, Button, SideCard, StatusBadge, TimeSeriesChart } from '@lunarphp/panel';

defineProps<{
message?: string;
Expand Down Expand Up @@ -43,6 +43,16 @@ const columns = [
const rowActions = [
{ key: 'ping', label: 'Ping', icon: 'refresh', method: 'get', primary: false },
];

// Static demo series for the panel's TimeSeriesChart, also on the public surface.
const chartPoints = [
{ label: 'Jan', value: 120, display: '£120.00' },
{ label: 'Feb', value: 210, display: '£210.00' },
{ label: 'Mar', value: 160, display: '£160.00' },
{ label: 'Apr', value: 290, display: '£290.00' },
{ label: 'May', value: 240, display: '£240.00' },
{ label: 'Jun', value: 330, display: '£330.00' },
];
</script>

<template>
Expand All @@ -62,9 +72,7 @@ const rowActions = [
<div class="px-4 sm:px-5 lg:px-7 max-w-[1400px] w-full mx-auto pt-5 pb-7">
<PageZone region="main" position="before" />

<div v-if="flashSuccess" class="mb-4 rounded-md border border-sage-border bg-sage-soft px-3 py-2 text-[12px] text-sage-ink">
{{ flashSuccess }}
</div>
<FlashMessage :message="flashSuccess" class="mb-4" />

<p class="text-[13px] text-ink-700">
{{ message ?? 'This page is served by a separately-compiled add-on package.' }}
Expand Down Expand Up @@ -99,6 +107,14 @@ const rowActions = [
</div>
</SideCard>

<SideCard title="Charting from an add-on" class="mt-5 max-w-md">
<p class="text-[12.5px] text-ink-700 mb-2">
The panel's <code>TimeSeriesChart</code> is on the same public
surface, so add-ons can plot their own data.
</p>
<TimeSeriesChart :points="chartPoints" :height="140" ariaLabel="Example widget sales" />
</SideCard>

<PageZone region="main" position="after" />
</div>
</div>
Expand Down
9 changes: 9 additions & 0 deletions packages/panel/resources/js/components/ActivityTimeline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ const TYPE_META: Record<string, ToneMeta> = {
group_removed: { icon: 'flag', tone: 'neutral' },
address_added: { icon: 'pin', tone: 'neutral' },
user_invited: { icon: 'mail', tone: 'neutral' },
// spatie/activitylog model events plus the customer action events.
created: { icon: 'userPlus', tone: 'sage' },
updated: { icon: 'edit', tone: 'neutral' },
deleted: { icon: 'trash', tone: 'danger' },
address_created: { icon: 'fileText', tone: 'neutral' },
address_updated: { icon: 'edit', tone: 'neutral' },
address_deleted: { icon: 'trash', tone: 'archived' },
user_linked: { icon: 'userPlus', tone: 'sage' },
user_unlinked: { icon: 'x', tone: 'archived' },
};

// intentionally omits 'archived' to match the prototype — it falls back to TONE.neutral below.
Expand Down
152 changes: 152 additions & 0 deletions packages/panel/resources/js/components/AddressFormFields.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import Checkbox from './Checkbox.vue';
import FieldLabel from './FieldLabel.vue';
import Select from './Select.vue';
import Textarea from './Textarea.vue';
import TextInput from './TextInput.vue';

export interface AddressFormValues {
title: string;
first_name: string;
last_name: string;
company_name: string;
tax_identifier: string;
line_one: string;
line_two: string;
line_three: string;
city: string;
state: string;
postcode: string;
country_id: number | string;
delivery_instructions: string;
contact_email: string;
contact_phone: string;
shipping_default: boolean;
billing_default: boolean;
}

/** An Inertia useForm over the address fields — values and errors are read and written directly. */
type AddressForm = AddressFormValues & {
errors: Partial<Record<keyof AddressFormValues, string>>;
};

const props = defineProps<{
form: AddressForm;
countries: { id: number; name: string }[];
/** Prefixes input ids so two address forms on one page keep unique label associations. */
idPrefix: string;
}>();

const { t } = useI18n();

const fieldId = (name: string): string => `${props.idPrefix}-${name}`;
</script>

<template>
<div class="flex flex-col gap-3">
<div class="grid grid-cols-2 gap-3">
<div>
<FieldLabel :for="fieldId('first-name')" required>{{ t('customers.field_first_name') }}</FieldLabel>
<TextInput :id="fieldId('first-name')" v-model="form.first_name" :invalid="!!form.errors.first_name" />
<div v-if="form.errors.first_name" class="mt-1 text-[11px] text-danger">{{ form.errors.first_name }}</div>
</div>
<div>
<FieldLabel :for="fieldId('last-name')" required>{{ t('customers.field_last_name') }}</FieldLabel>
<TextInput :id="fieldId('last-name')" v-model="form.last_name" :invalid="!!form.errors.last_name" />
<div v-if="form.errors.last_name" class="mt-1 text-[11px] text-danger">{{ form.errors.last_name }}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<FieldLabel :for="fieldId('title')">{{ t('customers.field_title') }}</FieldLabel>
<TextInput :id="fieldId('title')" v-model="form.title" :placeholder="t('common.optional')" :invalid="!!form.errors.title" />
<div v-if="form.errors.title" class="mt-1 text-[11px] text-danger">{{ form.errors.title }}</div>
</div>
<div>
<FieldLabel :for="fieldId('company-name')">{{ t('customers.field_company_name') }}</FieldLabel>
<TextInput :id="fieldId('company-name')" v-model="form.company_name" :invalid="!!form.errors.company_name" />
<div v-if="form.errors.company_name" class="mt-1 text-[11px] text-danger">{{ form.errors.company_name }}</div>
</div>
</div>
<div>
<FieldLabel :for="fieldId('tax-identifier')">{{ t('customers.field_tax_identifier') }}</FieldLabel>
<TextInput :id="fieldId('tax-identifier')" v-model="form.tax_identifier" mono :invalid="!!form.errors.tax_identifier" />
<div v-if="form.errors.tax_identifier" class="mt-1 text-[11px] text-danger">{{ form.errors.tax_identifier }}</div>
</div>
<div>
<FieldLabel :for="fieldId('line-one')" required>{{ t('customers.field_line_one') }}</FieldLabel>
<TextInput :id="fieldId('line-one')" v-model="form.line_one" :invalid="!!form.errors.line_one" />
<div v-if="form.errors.line_one" class="mt-1 text-[11px] text-danger">{{ form.errors.line_one }}</div>
</div>
<div>
<FieldLabel :for="fieldId('line-two')">{{ t('customers.field_line_two') }}</FieldLabel>
<TextInput :id="fieldId('line-two')" v-model="form.line_two" :invalid="!!form.errors.line_two" />
<div v-if="form.errors.line_two" class="mt-1 text-[11px] text-danger">{{ form.errors.line_two }}</div>
</div>
<div>
<FieldLabel :for="fieldId('line-three')">{{ t('customers.field_line_three') }}</FieldLabel>
<TextInput :id="fieldId('line-three')" v-model="form.line_three" :invalid="!!form.errors.line_three" />
<div v-if="form.errors.line_three" class="mt-1 text-[11px] text-danger">{{ form.errors.line_three }}</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<FieldLabel :for="fieldId('city')" required>{{ t('customers.field_city') }}</FieldLabel>
<TextInput :id="fieldId('city')" v-model="form.city" :invalid="!!form.errors.city" />
<div v-if="form.errors.city" class="mt-1 text-[11px] text-danger">{{ form.errors.city }}</div>
</div>
<div>
<FieldLabel :for="fieldId('state')">{{ t('customers.field_state') }}</FieldLabel>
<TextInput :id="fieldId('state')" v-model="form.state" :invalid="!!form.errors.state" />
<div v-if="form.errors.state" class="mt-1 text-[11px] text-danger">{{ form.errors.state }}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<FieldLabel :for="fieldId('postcode')">{{ t('customers.field_postcode') }}</FieldLabel>
<TextInput :id="fieldId('postcode')" v-model="form.postcode" :invalid="!!form.errors.postcode" />
<div v-if="form.errors.postcode" class="mt-1 text-[11px] text-danger">{{ form.errors.postcode }}</div>
</div>
<div>
<FieldLabel :for="fieldId('country')" required>{{ t('customers.field_country') }}</FieldLabel>
<Select
:id="fieldId('country')"
:model-value="form.country_id"
:invalid="!!form.errors.country_id"
@update:model-value="(value) => (form.country_id = value ? Number(value) : '')"
>
<option value="">{{ t('customers.select_country') }}</option>
<option v-for="country in countries" :key="country.id" :value="country.id">{{ country.name }}</option>
</Select>
<div v-if="form.errors.country_id" class="mt-1 text-[11px] text-danger">{{ form.errors.country_id }}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<FieldLabel :for="fieldId('contact-email')">{{ t('customers.field_contact_email') }}</FieldLabel>
<TextInput :id="fieldId('contact-email')" v-model="form.contact_email" type="email" :invalid="!!form.errors.contact_email" />
<div v-if="form.errors.contact_email" class="mt-1 text-[11px] text-danger">{{ form.errors.contact_email }}</div>
</div>
<div>
<FieldLabel :for="fieldId('contact-phone')">{{ t('customers.field_contact_phone') }}</FieldLabel>
<TextInput :id="fieldId('contact-phone')" v-model="form.contact_phone" type="tel" :invalid="!!form.errors.contact_phone" />
<div v-if="form.errors.contact_phone" class="mt-1 text-[11px] text-danger">{{ form.errors.contact_phone }}</div>
</div>
</div>
<div>
<FieldLabel :for="fieldId('delivery-instructions')">{{ t('customers.field_delivery_instructions') }}</FieldLabel>
<Textarea :id="fieldId('delivery-instructions')" v-model="form.delivery_instructions" :rows="2" :invalid="!!form.errors.delivery_instructions" />
<div v-if="form.errors.delivery_instructions" class="mt-1 text-[11px] text-danger">{{ form.errors.delivery_instructions }}</div>
</div>
<div class="flex gap-4">
<label class="inline-flex items-center gap-2 text-[12.5px] text-ink-700 select-none cursor-pointer">
<Checkbox v-model="form.shipping_default" />
{{ t('customers.default_shipping') }}
</label>
<label class="inline-flex items-center gap-2 text-[12.5px] text-ink-700 select-none cursor-pointer">
<Checkbox v-model="form.billing_default" />
{{ t('customers.default_billing') }}
</label>
</div>
</div>
</template>
5 changes: 3 additions & 2 deletions packages/panel/resources/js/components/Breadcrumbs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ const { t } = useI18n();
<template>
<!-- Top bar: breadcrumb trail on the left, page-level actions on the right. The
min-height matches the sidebar's brand header so the two top bands line up,
whether or not a page fills the actions slot. -->
<div class="flex items-center gap-2 sm:gap-3 px-4 sm:px-5 py-2.5 min-h-[50px] border-b border-line bg-paper">
whether or not a page fills the actions slot. Sticky on desktop only — on
mobile the layouts' own panel-name bar holds the top-0 sticky slot. -->
<div class="lg:sticky lg:top-0 z-30 flex items-center gap-2 sm:gap-3 px-4 sm:px-5 py-2.5 min-h-[50px] border-b border-line bg-paper/75 backdrop-saturate-[1.6] backdrop-blur-md">
<nav :aria-label="t('common.breadcrumb')" class="flex items-center gap-1.5 text-xs text-ink-500 min-w-0">
<template v-for="(crumb, i) in items" :key="i">
<Icon v-if="i > 0" name="chevronRight" cls="sm" class="text-ink-300 hidden sm:inline" />
Expand Down
Loading
Loading