Skip to content

Commit 9639113

Browse files
committed
feat: Implement SEO service for products, categories, and content pages
- Added SeoService to generate meta tags, Open Graph data, and structured data for products, categories, search pages, and content pages. - Implemented caching for SEO data to improve performance. - Created methods for generating XML sitemaps and robots.txt content. - Added structured data generation for better search engine visibility. feat: Introduce Shipping service for calculating shipping costs - Added ShippingService to calculate shipping costs based on postcode and total weight. - Implemented methods for validating Australian addresses and calculating cart weights. - Created shipping zones, methods, and rates migrations and seeders for database setup. feat: Create migrations and seeders for shipping zones, methods, and rates - Created migration files for shipping_zones, shipping_methods, and shipping_rates tables. - Implemented seeders to populate shipping zones, methods, and rates with initial data. - Added search optimization indexes to products and categories tables for improved query performance.
1 parent 94fe01c commit 9639113

26 files changed

+3198
-10
lines changed

backend/app/Http/Controllers/Api/CheckoutController.php

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use App\Models\Cart;
77
use App\Models\Order;
88
use App\Services\StripeService;
9+
use App\Services\ShippingService;
910
use Illuminate\Http\Request;
1011
use Illuminate\Http\JsonResponse;
1112
use Illuminate\Support\Facades\DB;
@@ -14,10 +15,12 @@
1415
class CheckoutController extends Controller
1516
{
1617
private StripeService $stripeService;
18+
private ShippingService $shippingService;
1719

18-
public function __construct(StripeService $stripeService)
20+
public function __construct(StripeService $stripeService, ShippingService $shippingService)
1921
{
2022
$this->stripeService = $stripeService;
23+
$this->shippingService = $shippingService;
2124
}
2225

2326
/**
@@ -167,7 +170,11 @@ public function process(Request $request): JsonResponse
167170
// Process successful payment
168171
$order = null;
169172
if ($cart) {
170-
$order = $this->processSuccessfulPayment($cart, $paymentIntent);
173+
// Get shipping information from request if provided
174+
$shippingPostcode = $request->input('shipping_postcode');
175+
$shippingMethodCode = $request->input('shipping_method_code');
176+
177+
$order = $this->processSuccessfulPayment($cart, $paymentIntent, $shippingPostcode, $shippingMethodCode);
171178
}
172179

173180
return response()->json([
@@ -345,19 +352,50 @@ private function getCartForCheckout(Request $request, ?int $cartId = null): ?Car
345352
/**
346353
* Process successful payment and create order
347354
*/
348-
private function processSuccessfulPayment(Cart $cart, $paymentIntent): Order
355+
private function processSuccessfulPayment(Cart $cart, $paymentIntent, ?string $shippingPostcode = null, ?string $shippingMethodCode = null): Order
349356
{
350-
return DB::transaction(function () use ($cart, $paymentIntent) {
357+
return DB::transaction(function () use ($cart, $paymentIntent, $shippingPostcode, $shippingMethodCode) {
358+
// Calculate shipping if postcode provided
359+
$shippingAmount = 0;
360+
$shippingData = null;
361+
362+
if ($shippingPostcode) {
363+
$shippingQuote = $this->shippingService->calculateShippingCost(
364+
$shippingPostcode,
365+
$cart->total_weight,
366+
$shippingMethodCode
367+
);
368+
369+
if ($shippingQuote['success'] && !empty($shippingQuote['quotes'])) {
370+
// Use the first (cheapest) option by default, or specific method if provided
371+
$selectedQuote = null;
372+
373+
if ($shippingMethodCode) {
374+
$selectedQuote = collect($shippingQuote['quotes'])
375+
->firstWhere('method.code', $shippingMethodCode);
376+
}
377+
378+
if (!$selectedQuote) {
379+
$selectedQuote = collect($shippingQuote['quotes'])->first();
380+
}
381+
382+
if ($selectedQuote) {
383+
$shippingAmount = $selectedQuote['rate']['price'];
384+
$shippingData = $selectedQuote;
385+
}
386+
}
387+
}
388+
351389
// Create order
352390
$order = Order::create([
353391
'user_id' => $cart->user_id,
354392
'order_number' => Order::generateOrderNumber(),
355393
'status' => 'paid',
356394
'subtotal' => $cart->subtotal,
357395
'tax_amount' => 0, // Placeholder
358-
'shipping_amount' => 0, // Placeholder
359-
'total_amount' => $cart->subtotal,
360-
'currency' => 'USD',
396+
'shipping_amount' => $shippingAmount,
397+
'total_amount' => $cart->subtotal + $shippingAmount,
398+
'currency' => 'AUD', // Changed to AUD for Australian shipping
361399
'payment_status' => 'paid',
362400
'shipping_status' => 'pending',
363401
]);
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Services\SearchService;
7+
use Illuminate\Http\Request;
8+
use Illuminate\Http\JsonResponse;
9+
10+
class SearchController extends Controller
11+
{
12+
protected SearchService $searchService;
13+
14+
public function __construct(SearchService $searchService)
15+
{
16+
$this->searchService = $searchService;
17+
}
18+
19+
/**
20+
* Main search endpoint with comprehensive filtering
21+
*/
22+
public function search(Request $request): JsonResponse
23+
{
24+
try {
25+
// Validate request parameters
26+
$validated = $request->validate([
27+
'q' => 'nullable|string|max:255',
28+
'category_id' => 'nullable|exists:categories,id',
29+
'category_slug' => 'nullable|string|exists:categories,slug',
30+
'min_price' => 'nullable|numeric|min:0',
31+
'max_price' => 'nullable|numeric|min:0',
32+
'attributes' => 'nullable|array',
33+
'attributes.*.attribute_id' => 'required|exists:attributes,id',
34+
'attributes.*.value_ids' => 'required|array',
35+
'attributes.*.value_ids.*' => 'exists:attribute_values,id',
36+
'in_stock' => 'nullable|boolean',
37+
'sort_by' => 'nullable|in:relevance,name,price,created_at,newest',
38+
'sort_direction' => 'nullable|in:asc,desc',
39+
'per_page' => 'nullable|integer|min:1|max:100',
40+
'page' => 'nullable|integer|min:1',
41+
]);
42+
43+
$perPage = min($validated['per_page'] ?? 20, 100);
44+
$page = $validated['page'] ?? 1;
45+
46+
// Execute search using the service
47+
$searchResults = $this->searchService->search($validated, $perPage, $page);
48+
49+
return response()->json([
50+
'success' => true,
51+
'data' => [
52+
'products' => $this->formatPaginatedResults($searchResults),
53+
'search_metadata' => [
54+
'query' => $validated['q'] ?? '',
55+
'result_count' => $searchResults['total'],
56+
'filters_applied' => $this->getAppliedFilters($validated),
57+
'sort_by' => $validated['sort_by'] ?? 'relevance',
58+
'sort_direction' => $validated['sort_direction'] ?? 'desc',
59+
'performance_stats' => $this->searchService->getPerformanceStats(),
60+
]
61+
],
62+
'message' => 'Search completed successfully'
63+
]);
64+
65+
} catch (\Illuminate\Validation\ValidationException $e) {
66+
return response()->json([
67+
'success' => false,
68+
'message' => 'Validation failed',
69+
'errors' => $e->errors()
70+
], 422);
71+
72+
} catch (\Exception $e) {
73+
return response()->json([
74+
'success' => false,
75+
'message' => 'Search failed',
76+
'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
77+
], 500);
78+
}
79+
}
80+
81+
/**
82+
* Autocomplete suggestions endpoint
83+
*/
84+
public function suggestions(Request $request): JsonResponse
85+
{
86+
try {
87+
$request->validate([
88+
'q' => 'required|string|min:2|max:100',
89+
'limit' => 'nullable|integer|min:1|max:20',
90+
]);
91+
92+
$query = $request->q;
93+
$limit = min($request->limit ?? 10, 20);
94+
95+
// Get suggestions using the service
96+
$suggestions = $this->searchService->getSuggestions($query, $limit);
97+
98+
return response()->json([
99+
'success' => true,
100+
'data' => [
101+
'query' => $query,
102+
'suggestions' => $suggestions,
103+
],
104+
'message' => 'Suggestions retrieved successfully'
105+
]);
106+
107+
} catch (\Illuminate\Validation\ValidationException $e) {
108+
return response()->json([
109+
'success' => false,
110+
'message' => 'Validation failed',
111+
'errors' => $e->errors()
112+
], 422);
113+
114+
} catch (\Exception $e) {
115+
return response()->json([
116+
'success' => false,
117+
'message' => 'Failed to retrieve suggestions',
118+
'error' => config('app.debug') ? $e->getMessage() : 'Internal server error'
119+
], 500);
120+
}
121+
}
122+
123+
/**
124+
* Format search results as Laravel paginator for consistent API response
125+
*/
126+
private function formatPaginatedResults(array $searchResults): object
127+
{
128+
// Create a simple paginator-like object for API consistency
129+
return (object) [
130+
'data' => $searchResults['products'],
131+
'total' => $searchResults['total'],
132+
'per_page' => $searchResults['per_page'],
133+
'current_page' => $searchResults['current_page'],
134+
'last_page' => $searchResults['last_page'],
135+
'from' => $searchResults['from'],
136+
'to' => $searchResults['to'],
137+
];
138+
}
139+
140+
/**
141+
* Get applied filters for metadata
142+
*/
143+
private function getAppliedFilters(array $filters): array
144+
{
145+
$applied = [];
146+
147+
if (!empty($filters['category_id'])) {
148+
$applied['category_id'] = $filters['category_id'];
149+
}
150+
151+
if (!empty($filters['category_slug'])) {
152+
$applied['category_slug'] = $filters['category_slug'];
153+
}
154+
155+
if (!empty($filters['min_price'])) {
156+
$applied['min_price'] = $filters['min_price'];
157+
}
158+
159+
if (!empty($filters['max_price'])) {
160+
$applied['max_price'] = $filters['max_price'];
161+
}
162+
163+
if (!empty($filters['attributes'])) {
164+
$applied['attributes'] = $filters['attributes'];
165+
}
166+
167+
if (!empty($filters['in_stock'])) {
168+
$applied['in_stock'] = $filters['in_stock'];
169+
}
170+
171+
return $applied;
172+
}
173+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Services\SeoService;
7+
use Illuminate\Http\Response;
8+
9+
class SeoController extends Controller
10+
{
11+
protected SeoService $seoService;
12+
13+
public function __construct(SeoService $seoService)
14+
{
15+
$this->seoService = $seoService;
16+
}
17+
18+
/**
19+
* Generate and return XML sitemap
20+
*/
21+
public function sitemap(): Response
22+
{
23+
$sitemap = $this->seoService->generateSitemap();
24+
25+
return response($sitemap, 200, [
26+
'Content-Type' => 'application/xml',
27+
'X-Robots-Tag' => 'noindex, nofollow',
28+
]);
29+
}
30+
31+
/**
32+
* Generate and return robots.txt
33+
*/
34+
public function robots(): Response
35+
{
36+
$robotsTxt = $this->seoService->generateRobotsTxt();
37+
38+
return response($robotsTxt, 200, [
39+
'Content-Type' => 'text/plain',
40+
'X-Robots-Tag' => 'noindex, nofollow',
41+
]);
42+
}
43+
44+
/**
45+
* Get SEO configuration
46+
*/
47+
public function config(): Response
48+
{
49+
return response()->json([
50+
'success' => true,
51+
'data' => $this->seoService->getConfig(),
52+
'message' => 'SEO configuration retrieved successfully'
53+
]);
54+
}
55+
56+
/**
57+
* Clear SEO cache (admin function)
58+
*/
59+
public function clearCache(): Response
60+
{
61+
$this->seoService->clearCache();
62+
63+
return response()->json([
64+
'success' => true,
65+
'message' => 'SEO cache cleared successfully'
66+
]);
67+
}
68+
}

0 commit comments

Comments
 (0)