Use Parcel2Go public API scope by default
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 1m1s

This commit is contained in:
Hickmeister
2026-10-11 16:50:13 +01:00
parent 75d8df532b
commit 61dd05384f
13 changed files with 102 additions and 9 deletions
+1
View File
@@ -68,6 +68,7 @@ P2G_CLIENT_ID=
P2G_CLIENT_SECRET= P2G_CLIENT_SECRET=
P2G_API_AUTH_URL= P2G_API_AUTH_URL=
P2G_API_BASE_URL=https://www.parcel2go.com/api P2G_API_BASE_URL=https://www.parcel2go.com/api
P2G_API_SCOPE=public-api
ETSY_KEYSTRING= ETSY_KEYSTRING=
ETSY_SHARED_SECRET= ETSY_SHARED_SECRET=
+7
View File
@@ -205,7 +205,14 @@ jobs:
- name: Install production PHP dependencies - name: Install production PHP dependencies
working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }}
run: | run: |
rm -f \
/tmp/TechOdyssey_Designs_Dashboard.packages.php \
/tmp/TechOdyssey_Designs_Dashboard.services.php
"$COMPOSER_CMD" install --no-dev --no-interaction --prefer-dist --optimize-autoloader "$COMPOSER_CMD" install --no-dev --no-interaction --prefer-dist --optimize-autoloader
rm -f \
/tmp/TechOdyssey_Designs_Dashboard.packages.php \
/tmp/TechOdyssey_Designs_Dashboard.services.php
"$PHP_CMD" artisan package:discover --ansi --no-interaction
- name: Clear compiled Laravel files - name: Clear compiled Laravel files
working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }}
@@ -46,8 +46,13 @@ class DashboardController extends Controller
$shippingConfig = $settings->for('parcel2go'); $shippingConfig = $settings->for('parcel2go');
if ($isAdmin && filled($shippingConfig['client_id'] ?? null) && filled($shippingConfig['client_secret'] ?? null) && filled($shippingConfig['auth_url'] ?? null)) { if ($isAdmin && filled($shippingConfig['client_id'] ?? null) && filled($shippingConfig['client_secret'] ?? null) && filled($shippingConfig['auth_url'] ?? null)) {
try { try {
if ($parcel2Go->hasScope('my-profile')) {
$shipments = array_slice($parcel2Go->fetch('me/orders/detail')['Orders'] ?? [], 0, 4); $shipments = array_slice($parcel2Go->fetch('me/orders/detail')['Orders'] ?? [], 0, 4);
$shippingStatus = 'Live account'; $shippingStatus = 'Live account';
} else {
$parcel2Go->verifyCredentials();
$shippingStatus = 'Quotes available';
}
} catch (Throwable $exception) { } catch (Throwable $exception) {
report($exception); report($exception);
$shippingStatus = 'Temporarily unavailable'; $shippingStatus = 'Temporarily unavailable';
@@ -35,6 +35,7 @@ class EtsySettingsController extends Controller
'configured' => filled($parcel2go['client_id'] ?? null) && filled($parcel2go['client_secret'] ?? null) && filled($parcel2go['auth_url'] ?? null), 'configured' => filled($parcel2go['client_id'] ?? null) && filled($parcel2go['client_secret'] ?? null) && filled($parcel2go['auth_url'] ?? null),
'auth_url' => $parcel2go['auth_url'] ?? '', 'auth_url' => $parcel2go['auth_url'] ?? '',
'base_url' => $parcel2go['base_url'] ?? '', 'base_url' => $parcel2go['base_url'] ?? '',
'scope' => $parcel2go['scope'] ?? 'public-api',
], ],
'bambuddy' => [ 'bambuddy' => [
'stored' => in_array('bambuddy', $storedIntegrations, true), 'stored' => in_array('bambuddy', $storedIntegrations, true),
@@ -20,6 +20,7 @@ class IntegrationSettingsController extends Controller
'client_secret' => ['nullable', 'string', 'max:255'], 'client_secret' => ['nullable', 'string', 'max:255'],
'auth_url' => ['nullable', 'url:https', 'max:2048'], 'auth_url' => ['nullable', 'url:https', 'max:2048'],
'base_url' => ['nullable', 'url:https', 'max:2048'], 'base_url' => ['nullable', 'url:https', 'max:2048'],
'scope' => ['nullable', 'string', 'in:public-api,public-api my-profile,public-api payment,public-api payment my-profile'],
], ],
default => [ default => [
'url' => ['nullable', 'url:https', 'max:2048'], 'url' => ['nullable', 'url:https', 'max:2048'],
+19 -1
View File
@@ -14,12 +14,28 @@ class ShippingController extends Controller
public function index(Request $request, Parcel2Go $parcel2Go): View public function index(Request $request, Parcel2Go $parcel2Go): View
{ {
abort_unless($request->user()->role === 'admin', 403); abort_unless($request->user()->role === 'admin', 403);
try {
$account = [];
$balance = [];
$orders = [];
$profileAvailable = $parcel2Go->hasScope('my-profile');
$profileNotice = null;
if ($profileAvailable) {
try { try {
$account = $parcel2Go->fetch('me/detail'); $account = $parcel2Go->fetch('me/detail');
$balance = $parcel2Go->fetch('me/prepay/balance'); $balance = $parcel2Go->fetch('me/prepay/balance');
$orders = $parcel2Go->fetch('me/orders/detail'); $orders = $parcel2Go->fetch('me/orders/detail');
} catch (Throwable $exception) {
report($exception);
$profileNotice = 'Parcel2Go connected, but account history is unavailable. Check that this client has my-profile access.';
}
} else {
$parcel2Go->verifyCredentials();
$profileNotice = 'Parcel2Go connected for quotes and collections. Add my-profile access in Parcel2Go to show account history and labels.';
}
return view('shipping', compact('account', 'balance', 'orders') + [ return view('shipping', compact('account', 'balance', 'orders', 'profileAvailable', 'profileNotice') + [
'quotes' => $request->session()->get('shipping_quotes', []), 'quotes' => $request->session()->get('shipping_quotes', []),
'collectionDates' => $request->session()->get('shipping_collection_dates', []), 'collectionDates' => $request->session()->get('shipping_collection_dates', []),
'selectedService' => $request->session()->get('shipping_selected_service'), 'selectedService' => $request->session()->get('shipping_selected_service'),
@@ -31,6 +47,8 @@ class ShippingController extends Controller
return view('shipping', [ return view('shipping', [
'account' => [], 'balance' => [], 'orders' => [], 'account' => [], 'balance' => [], 'orders' => [],
'profileAvailable' => false,
'profileNotice' => null,
'quotes' => $request->session()->get('shipping_quotes', []), 'quotes' => $request->session()->get('shipping_quotes', []),
'collectionDates' => $request->session()->get('shipping_collection_dates', []), 'collectionDates' => $request->session()->get('shipping_collection_dates', []),
'selectedService' => $request->session()->get('shipping_selected_service'), 'selectedService' => $request->session()->get('shipping_selected_service'),
+11 -1
View File
@@ -10,6 +10,16 @@ class Parcel2Go
{ {
public function __construct(private IntegrationSettings $settings) {} public function __construct(private IntegrationSettings $settings) {}
public function hasScope(string $scope): bool
{
return in_array($scope, explode(' ', (string) ($this->settings->for('parcel2go')['scope'] ?? 'public-api')), true);
}
public function verifyCredentials(): void
{
$this->accessToken();
}
public function fetch(string $path): array public function fetch(string $path): array
{ {
return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(15) return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(15)
@@ -104,7 +114,7 @@ class Parcel2Go
$token = Http::asForm()->timeout(10)->post($settings['auth_url'], [ $token = Http::asForm()->timeout(10)->post($settings['auth_url'], [
'grant_type' => 'client_credentials', 'grant_type' => 'client_credentials',
'scope' => 'public-api my-profile', 'scope' => $settings['scope'] ?? 'public-api',
'client_id' => $settings['client_id'], 'client_id' => $settings['client_id'],
'client_secret' => $settings['client_secret'], 'client_secret' => $settings['client_secret'],
])->throw()->json('access_token'); ])->throw()->json('access_token');
+1
View File
@@ -40,6 +40,7 @@ return [
'client_secret' => env('P2G_CLIENT_SECRET'), 'client_secret' => env('P2G_CLIENT_SECRET'),
'auth_url' => env('P2G_API_AUTH_URL'), 'auth_url' => env('P2G_API_AUTH_URL'),
'base_url' => env('P2G_API_BASE_URL', 'https://www.parcel2go.com/api'), 'base_url' => env('P2G_API_BASE_URL', 'https://www.parcel2go.com/api'),
'scope' => env('P2G_API_SCOPE', 'public-api'),
], ],
'etsy' => [ 'etsy' => [
'keystring' => env('ETSY_KEYSTRING'), 'keystring' => env('ETSY_KEYSTRING'),
+8
View File
@@ -55,6 +55,14 @@
<label class="grid gap-2 text-sm text-slate-300">Client secret<input name="client_secret" type="password" autocomplete="new-password" maxlength="255" placeholder="Leave blank to keep current value" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500"></label> <label class="grid gap-2 text-sm text-slate-300">Client secret<input name="client_secret" type="password" autocomplete="new-password" maxlength="255" placeholder="Leave blank to keep current value" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500"></label>
<label class="grid gap-2 text-sm text-slate-300">OAuth token URL<input name="auth_url" type="url" maxlength="2048" value="{{ old('auth_url', $integrations['parcel2go']['auth_url']) }}" placeholder="https://…" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label> <label class="grid gap-2 text-sm text-slate-300">OAuth token URL<input name="auth_url" type="url" maxlength="2048" value="{{ old('auth_url', $integrations['parcel2go']['auth_url']) }}" placeholder="https://…" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
<label class="grid gap-2 text-sm text-slate-300">API base URL<input name="base_url" type="url" maxlength="2048" value="{{ old('base_url', $integrations['parcel2go']['base_url']) }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label> <label class="grid gap-2 text-sm text-slate-300">API base URL<input name="base_url" type="url" maxlength="2048" value="{{ old('base_url', $integrations['parcel2go']['base_url']) }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
<label class="grid gap-2 text-sm text-slate-300">API access
<select name="scope" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">
@foreach(['public-api' => 'Shipping quotes and collections', 'public-api my-profile' => 'Shipping and account history', 'public-api payment' => 'Shipping and PrePay payments', 'public-api payment my-profile' => 'Shipping, account history and PrePay'] as $scope => $label)
<option value="{{ $scope }}" @selected(old('scope', $integrations['parcel2go']['scope']) === $scope)>{{ $label }}</option>
@endforeach
</select>
</label>
<p class="text-xs text-slate-500">Choose account history only if Parcel2Go granted the <code>my-profile</code> scope to this client. An ungranted scope prevents sign in.</p>
<button class="w-fit rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save Parcel2Go</button> <button class="w-fit rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save Parcel2Go</button>
</form> </form>
</section> </section>
+3 -2
View File
@@ -3,12 +3,13 @@
@section('content') @section('content')
<div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Dispatch</p><h2 class="mt-2 text-2xl font-bold">Parcel2Go</h2><p class="mt-1 text-slate-400">Compare services, arrange a collection and download labels.</p></div> <div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Dispatch</p><h2 class="mt-2 text-2xl font-bold">Parcel2Go</h2><p class="mt-1 text-slate-400">Compare services, arrange a collection and download labels.</p></div>
@isset($integrationError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $integrationError }}</div>@endisset @isset($integrationError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $integrationError }}</div>@endisset
@if($profileNotice)<div role="status" class="rounded-xl border border-slate-700 bg-slate-900 p-4 text-sm text-slate-300">{{ $profileNotice }}</div>@endif
@if($bookingPending)<section role="alert" class="rounded-2xl border border-amber-500/40 bg-amber-500/10 p-6"><h3 class="font-semibold text-amber-200">Check Parcel2Go before retrying</h3><p class="mt-2 text-sm text-amber-100/80">The last booking request had no confirmed result. Check your account for the collection order, then reset this form.</p><form method="post" action="{{ route('shipping.reset-booking') }}" class="mt-4 flex flex-wrap items-center gap-3">@csrf<label class="text-sm"><input type="checkbox" name="checked_account" value="1" required> I checked the Parcel2Go account</label><button class="rounded-xl border border-amber-500/50 px-4 py-2 text-sm font-semibold text-amber-200">Reset booking form</button></form></section>@endif @if($bookingPending)<section role="alert" class="rounded-2xl border border-amber-500/40 bg-amber-500/10 p-6"><h3 class="font-semibold text-amber-200">Check Parcel2Go before retrying</h3><p class="mt-2 text-sm text-amber-100/80">The last booking request had no confirmed result. Check your account for the collection order, then reset this form.</p><form method="post" action="{{ route('shipping.reset-booking') }}" class="mt-4 flex flex-wrap items-center gap-3">@csrf<label class="text-sm"><input type="checkbox" name="checked_account" value="1" required> I checked the Parcel2Go account</label><button class="rounded-xl border border-amber-500/50 px-4 py-2 text-sm font-semibold text-amber-200">Reset booking form</button></form></section>@endif
@if($bookingResult) @if($bookingResult)
@php $paymentUrl = $bookingResult['Links']['payment'] ?? null; $paymentHost = is_string($paymentUrl) ? parse_url($paymentUrl, PHP_URL_HOST) : null; @endphp @php $paymentUrl = $bookingResult['Links']['payment'] ?? null; $paymentHost = is_string($paymentUrl) ? parse_url($paymentUrl, PHP_URL_HOST) : null; @endphp
<section class="rounded-2xl border border-brand/40 bg-brand/10 p-6"><h3 class="text-lg font-semibold">Collection order #{{ $bookingResult['OrderId'] ?? '—' }} created</h3><p class="mt-2 text-sm text-slate-300">Total: £{{ number_format((float) ($bookingResult['TotalPrice'] ?? 0), 2) }}. Payment and collection confirmation are completed with Parcel2Go.</p>@if($paymentHost && ($paymentHost === 'parcel2go.com' || str_ends_with($paymentHost, '.parcel2go.com')))<a href="{{ $paymentUrl }}" target="_blank" rel="noopener noreferrer" class="mt-4 inline-flex rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Complete payment ↗</a>@endif</section> <section class="rounded-2xl border border-brand/40 bg-brand/10 p-6"><h3 class="text-lg font-semibold">Collection order #{{ $bookingResult['OrderId'] ?? '—' }} created</h3><p class="mt-2 text-sm text-slate-300">Total: £{{ number_format((float) ($bookingResult['TotalPrice'] ?? 0), 2) }}. Payment and collection confirmation are completed with Parcel2Go.</p>@if($paymentHost && ($paymentHost === 'parcel2go.com' || str_ends_with($paymentHost, '.parcel2go.com')))<a href="{{ $paymentUrl }}" target="_blank" rel="noopener noreferrer" class="mt-4 inline-flex rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Complete payment ↗</a>@endif</section>
@endif @endif
<div class="grid gap-4 md:grid-cols-2"><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Account</p><p class="mt-4 text-xl font-semibold">{{ trim(($account['Forename'] ?? '').' '.($account['Surname'] ?? '')) ?: 'Unavailable' }}</p><p class="mt-2 text-sm text-slate-400">{{ $account['Email'] ?? '' }}</p></div><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Prepay balance</p><p class="mt-4 text-3xl font-bold">£{{ number_format((float) ($balance['Balance'] ?? 0), 2) }}</p></div></div> @if($profileAvailable)<div class="grid gap-4 md:grid-cols-2"><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Account</p><p class="mt-4 text-xl font-semibold">{{ trim(($account['Forename'] ?? '').' '.($account['Surname'] ?? '')) ?: 'Unavailable' }}</p><p class="mt-2 text-sm text-slate-400">{{ $account['Email'] ?? '' }}</p></div><div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">Prepay balance</p><p class="mt-4 text-3xl font-bold">{{ isset($balance['Balance']) ? '£'.number_format((float) $balance['Balance'], 2) : 'Unavailable' }}</p></div></div>@endif
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><div class="mb-5"><h3 class="text-lg font-semibold">1. Compare services</h3><p class="mt-1 text-sm text-slate-400">Enter a UK parcel. Dimensions are centimetres and weight is kilograms.</p></div><form method="post" action="{{ route('shipping.quote') }}" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">@csrf <section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><div class="mb-5"><h3 class="text-lg font-semibold">1. Compare services</h3><p class="mt-1 text-sm text-slate-400">Enter a UK parcel. Dimensions are centimetres and weight is kilograms.</p></div><form method="post" action="{{ route('shipping.quote') }}" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">@csrf
<label class="grid gap-2 text-sm">Collection postcode<input name="collection_postcode" value="{{ old('collection_postcode') }}" required maxlength="12" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label> <label class="grid gap-2 text-sm">Collection postcode<input name="collection_postcode" value="{{ old('collection_postcode') }}" required maxlength="12" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label>
<label class="grid gap-2 text-sm">Delivery postcode<input name="delivery_postcode" value="{{ old('delivery_postcode') }}" required maxlength="12" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label> <label class="grid gap-2 text-sm">Delivery postcode<input name="delivery_postcode" value="{{ old('delivery_postcode') }}" required maxlength="12" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label>
@@ -18,5 +19,5 @@
@if(count($quotes))<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><h3 class="text-lg font-semibold">2. Choose a collection service</h3><div class="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">@foreach($quotes as $quote)@php $service = $quote['Service'] ?? []; @endphp<article class="rounded-xl border border-slate-700 bg-slate-950 p-4"><p class="font-semibold">{{ $service['Name'] ?? 'Courier service' }}</p><p class="mt-1 text-sm text-slate-400">{{ $service['CourierName'] ?? '' }} · {{ $service['CollectionType'] ?? 'Unknown' }}</p><p class="mt-3 text-2xl font-bold text-brand">{{ isset($quote['TotalPrice']) ? '£'.number_format((float) $quote['TotalPrice'], 2) : 'See details' }}</p>@if(($service['CollectionType'] ?? null) === 'Collection' && ! empty($service['Slug']))<form method="post" action="{{ route('shipping.collection-dates') }}" class="mt-4 grid gap-3">@csrf<input type="hidden" name="service" value="{{ $service['Slug'] }}"><label class="grid gap-1 text-xs text-slate-400">Collection street<input name="sender_street" value="{{ old('sender_street') }}" required class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white"></label><label class="grid gap-1 text-xs text-slate-400">Collection town<input name="sender_town" value="{{ old('sender_town') }}" required class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white"></label><button class="rounded-lg border border-brand/40 px-3 py-2 text-sm font-semibold text-brand">Check collection dates</button></form>@endif</article>@endforeach</div></section>@endif @if(count($quotes))<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><h3 class="text-lg font-semibold">2. Choose a collection service</h3><div class="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">@foreach($quotes as $quote)@php $service = $quote['Service'] ?? []; @endphp<article class="rounded-xl border border-slate-700 bg-slate-950 p-4"><p class="font-semibold">{{ $service['Name'] ?? 'Courier service' }}</p><p class="mt-1 text-sm text-slate-400">{{ $service['CourierName'] ?? '' }} · {{ $service['CollectionType'] ?? 'Unknown' }}</p><p class="mt-3 text-2xl font-bold text-brand">{{ isset($quote['TotalPrice']) ? '£'.number_format((float) $quote['TotalPrice'], 2) : 'See details' }}</p>@if(($service['CollectionType'] ?? null) === 'Collection' && ! empty($service['Slug']))<form method="post" action="{{ route('shipping.collection-dates') }}" class="mt-4 grid gap-3">@csrf<input type="hidden" name="service" value="{{ $service['Slug'] }}"><label class="grid gap-1 text-xs text-slate-400">Collection street<input name="sender_street" value="{{ old('sender_street') }}" required class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white"></label><label class="grid gap-1 text-xs text-slate-400">Collection town<input name="sender_town" value="{{ old('sender_town') }}" required class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white"></label><button class="rounded-lg border border-brand/40 px-3 py-2 text-sm font-semibold text-brand">Check collection dates</button></form>@endif</article>@endforeach</div></section>@endif
@if(count($collectionDates) && $selectedService)<section class="rounded-2xl border border-brand/30 bg-slate-900 p-6"><h3 class="text-lg font-semibold">3. Arrange collection</h3><p class="mt-1 text-sm text-slate-400">Creating the order does not take payment. Parcel2Go will provide a payment link.</p><form method="post" action="{{ route('shipping.book-collection') }}" class="mt-5 grid gap-5">@csrf<label class="grid max-w-md gap-2 text-sm">Collection date<select name="collection_date" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"><option value="">Choose a date</option>@foreach($collectionDates as $date)<option value="{{ $date['CollectionDate'] ?? '' }}">{{ isset($date['CollectionDate']) ? \Illuminate\Support\Carbon::parse($date['CollectionDate'])->format('D d M Y') : 'Available date' }}{{ ! empty($date['Surcharge']) ? ' · £'.number_format((float) $date['Surcharge'], 2).' surcharge' : '' }}</option>@endforeach</select></label> @if(count($collectionDates) && $selectedService)<section class="rounded-2xl border border-brand/30 bg-slate-900 p-6"><h3 class="text-lg font-semibold">3. Arrange collection</h3><p class="mt-1 text-sm text-slate-400">Creating the order does not take payment. Parcel2Go will provide a payment link.</p><form method="post" action="{{ route('shipping.book-collection') }}" class="mt-5 grid gap-5">@csrf<label class="grid max-w-md gap-2 text-sm">Collection date<select name="collection_date" required class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"><option value="">Choose a date</option>@foreach($collectionDates as $date)<option value="{{ $date['CollectionDate'] ?? '' }}">{{ isset($date['CollectionDate']) ? \Illuminate\Support\Carbon::parse($date['CollectionDate'])->format('D d M Y') : 'Available date' }}{{ ! empty($date['Surcharge']) ? ' · £'.number_format((float) $date['Surcharge'], 2).' surcharge' : '' }}</option>@endforeach</select></label>
<div class="grid gap-5 lg:grid-cols-2"><fieldset class="grid gap-3 rounded-xl border border-slate-700 p-4"><legend class="px-2 font-semibold">Sender and collection</legend>@foreach([['sender_name','Name'],['sender_email','Email'],['sender_phone','Phone'],['sender_property','Property'],['sender_street','Street'],['sender_town','Town']] as [$field,$label])<label class="grid gap-1 text-sm">{{ $label }}<input name="{{ $field }}" value="{{ old($field, session('shipping_sender_location.'.$field)) }}" required class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2"></label>@endforeach</fieldset><fieldset class="grid gap-3 rounded-xl border border-slate-700 p-4"><legend class="px-2 font-semibold">Recipient and delivery</legend>@foreach([['recipient_name','Name'],['recipient_email','Email'],['recipient_phone','Phone'],['recipient_property','Property'],['recipient_street','Street'],['recipient_town','Town']] as [$field,$label])<label class="grid gap-1 text-sm">{{ $label }}<input name="{{ $field }}" value="{{ old($field) }}" required class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2"></label>@endforeach</fieldset></div><label class="grid max-w-xl gap-2 text-sm">Parcel contents<input name="contents" value="{{ old('contents') }}" required maxlength="200" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label><button class="w-fit rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Create unpaid order</button></form></section>@endif <div class="grid gap-5 lg:grid-cols-2"><fieldset class="grid gap-3 rounded-xl border border-slate-700 p-4"><legend class="px-2 font-semibold">Sender and collection</legend>@foreach([['sender_name','Name'],['sender_email','Email'],['sender_phone','Phone'],['sender_property','Property'],['sender_street','Street'],['sender_town','Town']] as [$field,$label])<label class="grid gap-1 text-sm">{{ $label }}<input name="{{ $field }}" value="{{ old($field, session('shipping_sender_location.'.$field)) }}" required class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2"></label>@endforeach</fieldset><fieldset class="grid gap-3 rounded-xl border border-slate-700 p-4"><legend class="px-2 font-semibold">Recipient and delivery</legend>@foreach([['recipient_name','Name'],['recipient_email','Email'],['recipient_phone','Phone'],['recipient_property','Property'],['recipient_street','Street'],['recipient_town','Town']] as [$field,$label])<label class="grid gap-1 text-sm">{{ $label }}<input name="{{ $field }}" value="{{ old($field) }}" required class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2"></label>@endforeach</fieldset></div><label class="grid max-w-xl gap-2 text-sm">Parcel contents<input name="contents" value="{{ old('contents') }}" required maxlength="200" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label><button class="w-fit rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Create unpaid order</button></form></section>@endif
<section class="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-900"><h3 class="p-6 text-lg font-semibold">Recent shipments and labels</h3><table class="w-full min-w-[750px] text-left text-sm"><thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Order</th><th class="p-4">Courier</th><th class="p-4">Service</th><th class="p-4">Status</th><th class="p-4">Recipient</th><th class="p-4">Label</th></tr></thead><tbody class="divide-y divide-slate-800">@forelse(($orders['Orders'] ?? []) as $order)<tr><td class="p-4">{{ $order['OrderLineId'] ?? '—' }}</td><td class="p-4">{{ $order['Courier'] ?? '—' }}</td><td class="p-4">{{ $order['Service'] ?? '—' }}</td><td class="p-4">{{ ($order['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}</td><td class="p-4">{{ $order['DeliveryAddress']['ContactName'] ?? '—' }}</td><td class="p-4">@if(! empty($order['Hash']) && ! empty($order['OrderLineId']))<a class="font-semibold text-brand hover:underline" href="{{ route('shipping.label', $order['OrderLineId']) }}">Download PDF</a>@else<span class="text-slate-500">Unavailable</span>@endif</td></tr>@empty<tr><td colspan="6" class="p-6 text-slate-400">No shipments available.</td></tr>@endforelse</tbody></table></section> <section class="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-900"><h3 class="p-6 text-lg font-semibold">Recent shipments and labels</h3><table class="w-full min-w-[750px] text-left text-sm"><thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Order</th><th class="p-4">Courier</th><th class="p-4">Service</th><th class="p-4">Status</th><th class="p-4">Recipient</th><th class="p-4">Label</th></tr></thead><tbody class="divide-y divide-slate-800">@forelse(($orders['Orders'] ?? []) as $order)<tr><td class="p-4">{{ $order['OrderLineId'] ?? '—' }}</td><td class="p-4">{{ $order['Courier'] ?? '—' }}</td><td class="p-4">{{ $order['Service'] ?? '—' }}</td><td class="p-4">{{ ($order['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}</td><td class="p-4">{{ $order['DeliveryAddress']['ContactName'] ?? '—' }}</td><td class="p-4">@if(! empty($order['Hash']) && ! empty($order['OrderLineId']))<a class="font-semibold text-brand hover:underline" href="{{ route('shipping.label', $order['OrderLineId']) }}">Download PDF</a>@else<span class="text-slate-500">Unavailable</span>@endif</td></tr>@empty<tr><td colspan="6" class="p-6 text-slate-400">{{ $profileAvailable ? 'No shipments available.' : 'Account history requires my-profile access from Parcel2Go.' }}</td></tr>@endforelse</tbody></table></section>
@endsection @endsection
+4
View File
@@ -47,6 +47,7 @@ class DashboardAccessTest extends TestCase
config()->set('services.parcel2go', [ config()->set('services.parcel2go', [
'client_id' => 'id', 'client_secret' => 'secret', 'client_id' => 'id', 'client_secret' => 'secret',
'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api',
'scope' => 'public-api my-profile',
]); ]);
config()->set('services.dryer', ['url' => 'https://dryer.example.test/api/v1', 'api_key' => 'key']); config()->set('services.dryer', ['url' => 'https://dryer.example.test/api/v1', 'api_key' => 'key']);
EtsyConnection::create(['shop_id' => 42, 'shop_name' => 'HickLab3D', 'access_token' => '123.token', 'refresh_token' => '123.refresh', 'expires_at' => now()->addHour()]); EtsyConnection::create(['shop_id' => 42, 'shop_name' => 'HickLab3D', 'access_token' => '123.token', 'refresh_token' => '123.refresh', 'expires_at' => now()->addHour()]);
@@ -191,10 +192,13 @@ class DashboardAccessTest extends TestCase
'client_id' => 'id', 'client_secret' => 'secret', 'client_id' => 'id', 'client_secret' => 'secret',
'auth_url' => 'https://auth.example.test/token', 'auth_url' => 'https://auth.example.test/token',
'base_url' => 'https://shipping.example.test/api', 'base_url' => 'https://shipping.example.test/api',
'scope' => 'public-api my-profile',
]); ]);
$admin = User::factory()->create(['role' => 'admin']); $admin = User::factory()->create(['role' => 'admin']);
$this->actingAs($admin)->get(route('shipping'))->assertOk()->assertSee('Lee Hickman')->assertSee('12.50'); $this->actingAs($admin)->get(route('shipping'))->assertOk()->assertSee('Lee Hickman')->assertSee('12.50');
Http::assertSentCount(6); Http::assertSentCount(6);
Http::assertSent(fn ($request) => $request->url() === 'https://auth.example.test/token'
&& $request['scope'] === 'public-api my-profile');
} }
} }
+15
View File
@@ -97,4 +97,19 @@ class IntegrationSettingsTest extends TestCase
])->assertSessionHasErrors('url'); ])->assertSessionHasErrors('url');
$this->assertDatabaseCount('integration_settings', 0); $this->assertDatabaseCount('integration_settings', 0);
} }
public function test_parcel2go_scope_is_validated_and_saved(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$this->actingAs($admin)->put(route('settings.integrations.update', 'parcel2go'), [
'scope' => 'admin',
])->assertSessionHasErrors('scope');
$this->assertDatabaseCount('integration_settings', 0);
$this->actingAs($admin)->put(route('settings.integrations.update', 'parcel2go'), [
'scope' => 'public-api my-profile',
])->assertRedirect(route('settings.index'));
$this->assertSame('public-api my-profile', IntegrationSetting::findOrFail('parcel2go')->settings['scope']);
}
} }
+21
View File
@@ -82,6 +82,27 @@ class IntegrationsTest extends TestCase
Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/quotes' && $request['Parcels'][0]['Weight'] === 2); Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/quotes' && $request['Parcels'][0]['Weight'] === 2);
} }
public function test_shipping_page_works_with_public_api_scope_without_profile_access(): void
{
Http::preventStrayRequests();
Http::fake(['https://auth.example.test/token' => function ($request) {
return $request['scope'] === 'public-api'
? Http::response(['access_token' => 'test-token'])
: Http::response(['error' => 'invalid_scope'], 400);
}]);
config()->set('services.parcel2go', [
'client_id' => 'id', 'client_secret' => 'secret',
'auth_url' => 'https://auth.example.test/token',
'base_url' => 'https://shipping.example.test/api',
'scope' => 'public-api',
]);
$this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping'))
->assertOk()->assertSee('Parcel2Go connected for quotes and collections')
->assertDontSee('Parcel2Go is unavailable');
Http::assertSentCount(1);
}
public function test_shipping_label_is_downloaded_for_an_order_in_account_history(): void public function test_shipping_label_is_downloaded_for_an_order_in_account_history(): void
{ {
Http::preventStrayRequests(); Http::preventStrayRequests();