649 lines
23 KiB
PHP
649 lines
23 KiB
PHP
<?php
|
|
/**
|
|
* Copyright seit 2024 Webshop System
|
|
*
|
|
* Mobile API Controller für das Webshop-System
|
|
*
|
|
* @author Webshop System
|
|
* @license GPL v3
|
|
*/
|
|
|
|
namespace App\API\Controllers;
|
|
|
|
use Doctrine\DBAL\DriverManager;
|
|
use Doctrine\DBAL\Exception;
|
|
|
|
class MobileApiController extends ApiController
|
|
{
|
|
/**
|
|
* Mobile-optimierte Produktliste
|
|
*/
|
|
public function getMobileProducts()
|
|
{
|
|
$page = max(1, intval($_GET['page'] ?? 1));
|
|
$limit = min(20, max(1, intval($_GET['limit'] ?? 10))); // Kleinere Limit für Mobile
|
|
$category = $_GET['category'] ?? null;
|
|
$search = $_GET['search'] ?? null;
|
|
$sort = $_GET['sort'] ?? 'newest'; // newest, price_asc, price_desc, rating
|
|
|
|
$offset = ($page - 1) * $limit;
|
|
|
|
try {
|
|
$whereConditions = ['p.active = 1'];
|
|
$params = [];
|
|
|
|
if ($category) {
|
|
$whereConditions[] = 'p.category_id = ?';
|
|
$params[] = $category;
|
|
}
|
|
|
|
if ($search) {
|
|
$whereConditions[] = '(p.name LIKE ? OR p.description LIKE ?)';
|
|
$params[] = '%' . $search . '%';
|
|
$params[] = '%' . $search . '%';
|
|
}
|
|
|
|
$whereClause = implode(' AND ', $whereConditions);
|
|
|
|
// Sortierung
|
|
$orderBy = match($sort) {
|
|
'price_asc' => 'p.price ASC',
|
|
'price_desc' => 'p.price DESC',
|
|
'rating' => 'p.avg_rating DESC',
|
|
default => 'p.created_at DESC'
|
|
};
|
|
|
|
// Gesamtanzahl
|
|
$countSql = "
|
|
SELECT COUNT(*) as total
|
|
FROM ws_product p
|
|
LEFT JOIN ws_category c ON p.category_id = c.id
|
|
WHERE $whereClause
|
|
";
|
|
|
|
$stmt = $this->conn->prepare($countSql);
|
|
$stmt->execute($params);
|
|
$totalCount = $stmt->fetchAssociative()['total'];
|
|
|
|
// Mobile-optimierte Produktdaten
|
|
$sql = "
|
|
SELECT p.id, p.name, p.price, p.avg_rating, p.review_count,
|
|
p.stock, p.image_url, c.name as category_name,
|
|
p.created_at, p.updated_at
|
|
FROM ws_product p
|
|
LEFT JOIN ws_category c ON p.category_id = c.id
|
|
WHERE $whereClause
|
|
ORDER BY $orderBy
|
|
LIMIT $limit OFFSET $offset
|
|
";
|
|
|
|
$stmt = $this->conn->prepare($sql);
|
|
$stmt->execute($params);
|
|
$products = $stmt->fetchAllAssociative();
|
|
|
|
// Mobile-optimierte Antwort
|
|
$mobileProducts = [];
|
|
foreach ($products as $product) {
|
|
$mobileProducts[] = [
|
|
'id' => $product['id'],
|
|
'name' => $product['name'],
|
|
'price' => floatval($product['price']),
|
|
'formatted_price' => '€' . number_format($product['price'], 2, ',', '.'),
|
|
'rating' => floatval($product['avg_rating'] ?? 0),
|
|
'review_count' => intval($product['review_count'] ?? 0),
|
|
'stock_status' => $this->getStockStatus($product['stock']),
|
|
'image_url' => $product['image_url'],
|
|
'category' => $product['category_name'],
|
|
'is_new' => $this->isNewProduct($product['created_at']),
|
|
'has_discount' => false, // TODO: Implement discount logic
|
|
'quick_add' => $product['stock'] > 0
|
|
];
|
|
}
|
|
|
|
$this->sendResponse([
|
|
'success' => true,
|
|
'data' => [
|
|
'products' => $mobileProducts,
|
|
'pagination' => [
|
|
'page' => $page,
|
|
'limit' => $limit,
|
|
'total' => $totalCount,
|
|
'pages' => ceil($totalCount / $limit),
|
|
'has_next' => $page < ceil($totalCount / $limit),
|
|
'has_prev' => $page > 1
|
|
],
|
|
'filters' => [
|
|
'categories' => $this->getMobileCategories(),
|
|
'sort_options' => [
|
|
['value' => 'newest', 'label' => 'Neueste'],
|
|
['value' => 'price_asc', 'label' => 'Preis: Niedrig zu Hoch'],
|
|
['value' => 'price_desc', 'label' => 'Preis: Hoch zu Niedrig'],
|
|
['value' => 'rating', 'label' => 'Beste Bewertung']
|
|
]
|
|
]
|
|
]
|
|
]);
|
|
|
|
} catch (Exception $e) {
|
|
$this->sendError('Failed to fetch mobile products: ' . $e->getMessage(), 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mobile-optimierte Produktdetails
|
|
*/
|
|
public function getMobileProduct($id)
|
|
{
|
|
try {
|
|
$stmt = $this->conn->prepare('
|
|
SELECT p.*, c.name as category_name
|
|
FROM ws_product p
|
|
LEFT JOIN ws_category c ON p.category_id = c.id
|
|
WHERE p.id = ? AND p.active = 1
|
|
');
|
|
$stmt->execute([$id]);
|
|
$product = $stmt->fetchAssociative();
|
|
|
|
if (!$product) {
|
|
$this->sendError('Product not found', 404);
|
|
}
|
|
|
|
// Mobile-optimierte Bewertungen
|
|
$stmt = $this->conn->prepare('
|
|
SELECT r.rating, r.title, r.comment, r.created_at,
|
|
c.first_name, c.last_name
|
|
FROM ws_review r
|
|
LEFT JOIN ws_customer c ON r.customer_id = c.id
|
|
WHERE r.product_id = ? AND r.active = 1
|
|
ORDER BY r.created_at DESC
|
|
LIMIT 5
|
|
');
|
|
$stmt->execute([$id]);
|
|
$reviews = $stmt->fetchAllAssociative();
|
|
|
|
// Mobile-optimierte Antwort
|
|
$mobileProduct = [
|
|
'id' => $product['id'],
|
|
'name' => $product['name'],
|
|
'description' => $product['description'],
|
|
'price' => floatval($product['price']),
|
|
'formatted_price' => '€' . number_format($product['price'], 2, ',', '.'),
|
|
'rating' => floatval($product['avg_rating'] ?? 0),
|
|
'review_count' => intval($product['review_count'] ?? 0),
|
|
'stock' => intval($product['stock']),
|
|
'stock_status' => $this->getStockStatus($product['stock']),
|
|
'category' => [
|
|
'id' => $product['category_id'],
|
|
'name' => $product['category_name']
|
|
],
|
|
'images' => $this->getProductImages($id),
|
|
'variants' => $this->getProductVariants($id),
|
|
'reviews' => array_map(function($review) {
|
|
return [
|
|
'rating' => intval($review['rating']),
|
|
'title' => $review['title'],
|
|
'comment' => $review['comment'],
|
|
'author' => $review['first_name'] . ' ' . $review['last_name'],
|
|
'date' => $review['created_at']
|
|
];
|
|
}, $reviews),
|
|
'related_products' => $this->getRelatedProducts($id, $product['category_id']),
|
|
'can_add_to_cart' => $product['stock'] > 0,
|
|
'can_add_to_wishlist' => true
|
|
];
|
|
|
|
$this->sendResponse([
|
|
'success' => true,
|
|
'data' => $mobileProduct
|
|
]);
|
|
|
|
} catch (Exception $e) {
|
|
$this->sendError('Failed to fetch mobile product: ' . $e->getMessage(), 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mobile Warenkorb
|
|
*/
|
|
public function getMobileCart()
|
|
{
|
|
$token = $this->getBearerToken();
|
|
$decodedToken = $this->validateJWT($token);
|
|
|
|
if (!$decodedToken) {
|
|
$this->sendError('Authentication required', 401);
|
|
}
|
|
|
|
try {
|
|
$stmt = $this->conn->prepare('
|
|
SELECT ci.*, p.name, p.price, p.image_url, p.stock
|
|
FROM ws_cart_item ci
|
|
LEFT JOIN ws_product p ON ci.product_id = p.id
|
|
WHERE ci.customer_id = ?
|
|
ORDER BY ci.created_at DESC
|
|
');
|
|
$stmt->execute([$decodedToken['customer_id']]);
|
|
$cartItems = $stmt->fetchAllAssociative();
|
|
|
|
$total = 0;
|
|
$itemCount = 0;
|
|
$mobileCartItems = [];
|
|
|
|
foreach ($cartItems as $item) {
|
|
$subtotal = $item['price'] * $item['quantity'];
|
|
$total += $subtotal;
|
|
$itemCount += $item['quantity'];
|
|
|
|
$mobileCartItems[] = [
|
|
'id' => $item['id'],
|
|
'product_id' => $item['product_id'],
|
|
'name' => $item['name'],
|
|
'price' => floatval($item['price']),
|
|
'formatted_price' => '€' . number_format($item['price'], 2, ',', '.'),
|
|
'quantity' => intval($item['quantity']),
|
|
'subtotal' => floatval($subtotal),
|
|
'formatted_subtotal' => '€' . number_format($subtotal, 2, ',', '.'),
|
|
'image_url' => $item['image_url'],
|
|
'stock' => intval($item['stock']),
|
|
'can_update' => $item['stock'] >= $item['quantity'],
|
|
'max_quantity' => min($item['stock'], 10)
|
|
];
|
|
}
|
|
|
|
$this->sendResponse([
|
|
'success' => true,
|
|
'data' => [
|
|
'items' => $mobileCartItems,
|
|
'summary' => [
|
|
'item_count' => $itemCount,
|
|
'total' => floatval($total),
|
|
'formatted_total' => '€' . number_format($total, 2, ',', '.'),
|
|
'can_checkout' => $itemCount > 0 && $total > 0
|
|
]
|
|
]
|
|
]);
|
|
|
|
} catch (Exception $e) {
|
|
$this->sendError('Failed to fetch mobile cart: ' . $e->getMessage(), 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mobile Push-Benachrichtigungen
|
|
*/
|
|
public function subscribeToPush()
|
|
{
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
$this->sendError('Method not allowed', 405);
|
|
}
|
|
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
|
|
if (!$input || empty($input['subscription'])) {
|
|
$this->sendError('Subscription data required', 400);
|
|
}
|
|
|
|
$token = $this->getBearerToken();
|
|
$decodedToken = $this->validateJWT($token);
|
|
|
|
if (!$decodedToken) {
|
|
$this->sendError('Authentication required', 401);
|
|
}
|
|
|
|
try {
|
|
// Prüfen ob bereits abonniert
|
|
$stmt = $this->conn->prepare('
|
|
SELECT id FROM ws_push_subscription
|
|
WHERE customer_id = ? AND endpoint = ?
|
|
');
|
|
$stmt->execute([$decodedToken['customer_id'], $input['subscription']['endpoint']]);
|
|
$existing = $stmt->fetchAssociative();
|
|
|
|
if ($existing) {
|
|
// Update existing subscription
|
|
$stmt = $this->conn->prepare('
|
|
UPDATE ws_push_subscription
|
|
SET auth = ?, p256dh = ?, updated_at = NOW()
|
|
WHERE id = ?
|
|
');
|
|
$stmt->execute([
|
|
$input['subscription']['keys']['auth'],
|
|
$input['subscription']['keys']['p256dh'],
|
|
$existing['id']
|
|
]);
|
|
} else {
|
|
// Create new subscription
|
|
$stmt = $this->conn->prepare('
|
|
INSERT INTO ws_push_subscription (customer_id, endpoint, auth, p256dh, created_at)
|
|
VALUES (?, ?, ?, ?, NOW())
|
|
');
|
|
$stmt->execute([
|
|
$decodedToken['customer_id'],
|
|
$input['subscription']['endpoint'],
|
|
$input['subscription']['keys']['auth'],
|
|
$input['subscription']['keys']['p256dh']
|
|
]);
|
|
}
|
|
|
|
$this->sendResponse([
|
|
'success' => true,
|
|
'message' => 'Push subscription updated successfully'
|
|
]);
|
|
|
|
} catch (Exception $e) {
|
|
$this->sendError('Failed to subscribe to push: ' . $e->getMessage(), 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Offline-Synchronisation
|
|
*/
|
|
public function syncOfflineData()
|
|
{
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
$this->sendError('Method not allowed', 405);
|
|
}
|
|
|
|
$input = json_decode(file_get_contents('php://input'), true);
|
|
|
|
if (!$input || empty($input['data'])) {
|
|
$this->sendError('Sync data required', 400);
|
|
}
|
|
|
|
$token = $this->getBearerToken();
|
|
$decodedToken = $this->validateJWT($token);
|
|
|
|
if (!$decodedToken) {
|
|
$this->sendError('Authentication required', 401);
|
|
}
|
|
|
|
try {
|
|
$syncedItems = [];
|
|
$errors = [];
|
|
|
|
foreach ($input['data'] as $item) {
|
|
try {
|
|
switch ($item['type']) {
|
|
case 'cart_add':
|
|
$result = $this->syncCartAdd($decodedToken['customer_id'], $item['data']);
|
|
$syncedItems[] = ['id' => $item['id'], 'type' => 'cart_add', 'result' => $result];
|
|
break;
|
|
|
|
case 'review_create':
|
|
$result = $this->syncReviewCreate($decodedToken['customer_id'], $item['data']);
|
|
$syncedItems[] = ['id' => $item['id'], 'type' => 'review_create', 'result' => $result];
|
|
break;
|
|
|
|
case 'wishlist_add':
|
|
$result = $this->syncWishlistAdd($decodedToken['customer_id'], $item['data']);
|
|
$syncedItems[] = ['id' => $item['id'], 'type' => 'wishlist_add', 'result' => $result];
|
|
break;
|
|
|
|
default:
|
|
$errors[] = ['id' => $item['id'], 'error' => 'Unknown sync type'];
|
|
}
|
|
} catch (Exception $e) {
|
|
$errors[] = ['id' => $item['id'], 'error' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
$this->sendResponse([
|
|
'success' => true,
|
|
'data' => [
|
|
'synced_items' => $syncedItems,
|
|
'errors' => $errors,
|
|
'total_synced' => count($syncedItems),
|
|
'total_errors' => count($errors)
|
|
]
|
|
]);
|
|
|
|
} catch (Exception $e) {
|
|
$this->sendError('Failed to sync offline data: ' . $e->getMessage(), 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mobile-spezifische Kategorien
|
|
*/
|
|
private function getMobileCategories()
|
|
{
|
|
try {
|
|
$stmt = $this->conn->prepare('
|
|
SELECT id, name, description, image_url
|
|
FROM ws_category
|
|
WHERE active = 1
|
|
ORDER BY sort_order ASC, name ASC
|
|
');
|
|
$stmt->execute();
|
|
$categories = $stmt->fetchAllAssociative();
|
|
|
|
return array_map(function($category) {
|
|
return [
|
|
'id' => $category['id'],
|
|
'name' => $category['name'],
|
|
'description' => $category['description'],
|
|
'image_url' => $category['image_url']
|
|
];
|
|
}, $categories);
|
|
|
|
} catch (Exception $e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lagerbestand-Status
|
|
*/
|
|
private function getStockStatus($stock)
|
|
{
|
|
if ($stock <= 0) {
|
|
return 'out_of_stock';
|
|
} elseif ($stock <= 5) {
|
|
return 'low_stock';
|
|
} else {
|
|
return 'in_stock';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Neues Produkt prüfen
|
|
*/
|
|
private function isNewProduct($createdAt)
|
|
{
|
|
$created = strtotime($createdAt);
|
|
$now = time();
|
|
$daysDiff = ($now - $created) / (60 * 60 * 24);
|
|
|
|
return $daysDiff <= 30; // 30 Tage als "neu"
|
|
}
|
|
|
|
/**
|
|
* Produktbilder abrufen
|
|
*/
|
|
private function getProductImages($productId)
|
|
{
|
|
try {
|
|
$stmt = $this->conn->prepare('
|
|
SELECT image_url, alt_text, sort_order
|
|
FROM ws_product_image
|
|
WHERE product_id = ? AND active = 1
|
|
ORDER BY sort_order ASC
|
|
');
|
|
$stmt->execute([$productId]);
|
|
$images = $stmt->fetchAllAssociative();
|
|
|
|
return array_map(function($image) {
|
|
return [
|
|
'url' => $image['image_url'],
|
|
'alt' => $image['alt_text'],
|
|
'order' => intval($image['sort_order'])
|
|
];
|
|
}, $images);
|
|
|
|
} catch (Exception $e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Produktvarianten abrufen
|
|
*/
|
|
private function getProductVariants($productId)
|
|
{
|
|
try {
|
|
$stmt = $this->conn->prepare('
|
|
SELECT id, name, price, stock, sku
|
|
FROM ws_product_variant
|
|
WHERE product_id = ? AND active = 1
|
|
ORDER BY sort_order ASC
|
|
');
|
|
$stmt->execute([$productId]);
|
|
$variants = $stmt->fetchAllAssociative();
|
|
|
|
return array_map(function($variant) {
|
|
return [
|
|
'id' => $variant['id'],
|
|
'name' => $variant['name'],
|
|
'price' => floatval($variant['price']),
|
|
'formatted_price' => '€' . number_format($variant['price'], 2, ',', '.'),
|
|
'stock' => intval($variant['stock']),
|
|
'sku' => $variant['sku'],
|
|
'available' => $variant['stock'] > 0
|
|
];
|
|
}, $variants);
|
|
|
|
} catch (Exception $e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verwandte Produkte
|
|
*/
|
|
private function getRelatedProducts($productId, $categoryId)
|
|
{
|
|
try {
|
|
$stmt = $this->conn->prepare('
|
|
SELECT p.id, p.name, p.price, p.avg_rating, p.image_url
|
|
FROM ws_product p
|
|
WHERE p.category_id = ? AND p.id != ? AND p.active = 1
|
|
ORDER BY p.avg_rating DESC, p.created_at DESC
|
|
LIMIT 4
|
|
');
|
|
$stmt->execute([$categoryId, $productId]);
|
|
$related = $stmt->fetchAllAssociative();
|
|
|
|
return array_map(function($product) {
|
|
return [
|
|
'id' => $product['id'],
|
|
'name' => $product['name'],
|
|
'price' => floatval($product['price']),
|
|
'formatted_price' => '€' . number_format($product['price'], 2, ',', '.'),
|
|
'rating' => floatval($product['avg_rating'] ?? 0),
|
|
'image_url' => $product['image_url']
|
|
];
|
|
}, $related);
|
|
|
|
} catch (Exception $e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Warenkorb-Synchronisation
|
|
*/
|
|
private function syncCartAdd($customerId, $data)
|
|
{
|
|
$stmt = $this->conn->prepare('
|
|
INSERT INTO ws_cart_item (customer_id, product_id, quantity, created_at)
|
|
VALUES (?, ?, ?, NOW())
|
|
ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity)
|
|
');
|
|
$stmt->execute([$customerId, $data['product_id'], $data['quantity']]);
|
|
|
|
return ['success' => true, 'cart_item_id' => $this->conn->lastInsertId()];
|
|
}
|
|
|
|
/**
|
|
* Bewertungs-Synchronisation
|
|
*/
|
|
private function syncReviewCreate($customerId, $data)
|
|
{
|
|
$stmt = $this->conn->prepare('
|
|
INSERT INTO ws_review (product_id, customer_id, rating, title, comment, active, created_at)
|
|
VALUES (?, ?, ?, ?, ?, 1, NOW())
|
|
');
|
|
$stmt->execute([
|
|
$data['product_id'],
|
|
$customerId,
|
|
$data['rating'],
|
|
$data['title'],
|
|
$data['comment']
|
|
]);
|
|
|
|
return ['success' => true, 'review_id' => $this->conn->lastInsertId()];
|
|
}
|
|
|
|
/**
|
|
* Wunschliste-Synchronisation
|
|
*/
|
|
private function syncWishlistAdd($customerId, $data)
|
|
{
|
|
$stmt = $this->conn->prepare('
|
|
INSERT INTO ws_wishlist_item (customer_id, product_id, created_at)
|
|
VALUES (?, ?, NOW())
|
|
ON DUPLICATE KEY UPDATE updated_at = NOW()
|
|
');
|
|
$stmt->execute([$customerId, $data['product_id']]);
|
|
|
|
return ['success' => true, 'wishlist_item_id' => $this->conn->lastInsertId()];
|
|
}
|
|
|
|
/**
|
|
* JWT-Token validieren (aus CustomerApiController)
|
|
*/
|
|
private function validateJWT($token)
|
|
{
|
|
if (!$token) {
|
|
return false;
|
|
}
|
|
|
|
$parts = explode('.', $token);
|
|
if (count($parts) !== 3) {
|
|
return false;
|
|
}
|
|
|
|
list($header, $payload, $signature) = $parts;
|
|
|
|
$validSignature = hash_hmac('sha256', $header . "." . $payload,
|
|
getenv('JWT_SECRET') ?: 'webshop_secret_key', true);
|
|
$validSignature = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($validSignature));
|
|
|
|
if ($signature !== $validSignature) {
|
|
return false;
|
|
}
|
|
|
|
$payload = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $payload)), true);
|
|
|
|
if (!$payload || $payload['exp'] < time()) {
|
|
return false;
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
|
|
/**
|
|
* Bearer-Token aus Header extrahieren
|
|
*/
|
|
private function getBearerToken()
|
|
{
|
|
$headers = getallheaders();
|
|
$authHeader = $headers['Authorization'] ?? '';
|
|
|
|
if (preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|