5a5f43f2de
- WorkOrder and InjectorProtocol PDF routes now respond with Content-Disposition: inline (opens in browser tab so the user can read/print/save from the built-in PDF viewer). ?download=1 forces the classic attachment download. - CalendarBoard exportPdf() switched to the same inline default. - WO edit action + WO table row action + injector protocol actions now use ->url(...)->openUrlInNewTab() instead of streaming the PDF inline into the current tab. - Dashboard Docs tab has both a preview link and a "Descarcă" link. - Vehicle card photo falls back to WO's first uploaded photo when Vehicle model has no MediaLibrary integration of its own. Test CalendarEnhancementsTest updated to assert the new inline response headers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
378 lines
18 KiB
PHP
378 lines
18 KiB
PHP
<?php
|
|
|
|
use App\Tenancy\TenantManager;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Route;
|
|
|
|
Route::get('/', function () {
|
|
// On a tenant subdomain → public landing page.
|
|
$tenant = app(TenantManager::class)->current();
|
|
if ($tenant) {
|
|
return view('site.landing', [
|
|
'name' => $tenant->display_name ?? $tenant->name,
|
|
'city' => $tenant->city,
|
|
'phone' => $tenant->phone,
|
|
'email' => $tenant->email,
|
|
'themeColor' => $tenant->settings['theme_color'] ?? '#3B82F6',
|
|
'services' => (array) ($tenant->settings['services'] ?? []),
|
|
'cars' => (array) ($tenant->settings['cars'] ?? []),
|
|
'logoUrl' => $tenant->getLogoUrl(),
|
|
'faviconUrl' => $tenant->getFaviconUrl(),
|
|
'shopEnabled' => (bool) data_get($tenant->settings, 'shop.enabled', false),
|
|
]);
|
|
}
|
|
// On the central domain → redirect to admin.
|
|
return redirect('/admin');
|
|
});
|
|
|
|
// ─── Plăți / Billing (tenant-side) ──────────────────────────────────
|
|
// /billing — listă facturi tenant + buton plată
|
|
// /pay/{id} — start checkout (Stripe / PayPal / bank)
|
|
Route::get('/billing', [\App\Http\Controllers\PaymentController::class, 'billing'])->name('billing');
|
|
Route::post('/pay/{subscription}', [\App\Http\Controllers\PaymentController::class, 'startCheckout'])
|
|
->where('subscription', '\d+')
|
|
->name('pay.start');
|
|
Route::get('/pay/{subscription}/success', [\App\Http\Controllers\PaymentController::class, 'success'])
|
|
->where('subscription', '\d+')
|
|
->name('pay.success');
|
|
Route::get('/pay/{subscription}/cancel', [\App\Http\Controllers\PaymentController::class, 'cancel'])
|
|
->where('subscription', '\d+')
|
|
->name('pay.cancel');
|
|
|
|
// ─── Webhooks (central, no auth) ────────────────────────────────────
|
|
Route::post('/payments/stripe/webhook', [\App\Http\Controllers\PaymentController::class, 'stripeWebhook'])
|
|
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
|
|
Route::post('/payments/paypal/webhook', [\App\Http\Controllers\PaymentController::class, 'paypalWebhook'])
|
|
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
|
|
Route::post('/payments/paynet/webhook', [\App\Http\Controllers\PaymentController::class, 'paynetWebhook'])
|
|
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
|
|
|
|
// PDF for a work order. Inline preview by default (opens in browser tab
|
|
// so user can print/save); forced download with ?download=1.
|
|
Route::get('/app/work-orders/{workOrder}/pdf', function (Request $request, int $workOrder) {
|
|
abort_unless(auth('web')->check(), 403);
|
|
$wo = \App\Models\Tenant\WorkOrder::findOrFail($workOrder);
|
|
$svc = app(\App\Services\WorkOrderPdfService::class);
|
|
$pdf = $svc->generate($wo);
|
|
$bytes = $pdf->output();
|
|
$filename = $svc->filename($wo);
|
|
$disposition = $request->boolean('download') ? 'attachment' : 'inline';
|
|
return response($bytes, 200, [
|
|
'Content-Type' => 'application/pdf',
|
|
'Content-Disposition' => $disposition . '; filename="' . $filename . '"',
|
|
'Cache-Control' => 'private, no-store',
|
|
]);
|
|
})->name('work-orders.pdf');
|
|
|
|
// User invitation accept flow (no auth required — token is the credential).
|
|
Route::get('/invitations/{token}', [\App\Http\Controllers\InvitationController::class, 'show'])
|
|
->name('invitation.show');
|
|
Route::post('/invitations/{token}', [\App\Http\Controllers\InvitationController::class, 'accept'])
|
|
->name('invitation.accept');
|
|
|
|
// Stub `login` route — needed because Laravel's auth middleware tries to
|
|
// route('login') when redirecting unauthenticated requests. We don't have a
|
|
// global /login (panels use /admin/login and /app/login), so stub it.
|
|
Route::get('/login', function (Request $request) {
|
|
if ($request->expectsJson() || $request->is('api/*')) {
|
|
return response()->json(['message' => 'Unauthenticated.'], 401);
|
|
}
|
|
$tenant = app(TenantManager::class)->current();
|
|
return redirect($tenant ? '/app/login' : '/admin/login');
|
|
})->name('login');
|
|
|
|
// ─── Print sheets + push subscriptions (auth, tenant-scoped) ───────
|
|
Route::middleware(['web', 'auth'])->group(function () {
|
|
Route::get('/parts/labels', [\App\Http\Controllers\PartLabelsController::class, 'sheet'])
|
|
->name('parts.labels');
|
|
Route::post('/push/subscribe', [\App\Http\Controllers\PushSubscriptionController::class, 'subscribe'])
|
|
->name('push.subscribe');
|
|
Route::post('/push/unsubscribe', [\App\Http\Controllers\PushSubscriptionController::class, 'unsubscribe'])
|
|
->name('push.unsubscribe');
|
|
|
|
Route::get('/app/injector-protocols/{record}/pdf', function (Request $request, \App\Models\Tenant\InjectorProtocol $record) {
|
|
abort_unless(
|
|
auth()->user()?->canDo(\App\Auth\Permissions::INJECTOR_PROTOCOLS_VIEW),
|
|
403
|
|
);
|
|
$svc = app(\App\Services\InjectorProtocolPdfService::class);
|
|
$bytes = $svc->render($record);
|
|
$filename = $svc->filename($record);
|
|
$disposition = $request->boolean('download') ? 'attachment' : 'inline';
|
|
return response($bytes, 200, [
|
|
'Content-Type' => 'application/pdf',
|
|
'Content-Disposition' => $disposition . '; filename="' . $filename . '"',
|
|
'Cache-Control' => 'private, no-store',
|
|
]);
|
|
})->name('tenant.injector-protocols.pdf');
|
|
});
|
|
|
|
// ─── Telegram webhook (per-tenant, on central domain) ──────────────
|
|
Route::post('/telegram/webhook/{slug}', [\App\Http\Controllers\TelegramWebhookController::class, 'handle'])
|
|
->where('slug', '[a-z0-9\-]+')
|
|
->withoutMiddleware([
|
|
\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class,
|
|
\App\Http\Middleware\ResolveTenant::class,
|
|
\App\Http\Middleware\CheckTenantStatus::class,
|
|
])
|
|
->name('telegram.webhook');
|
|
|
|
// ─── Online Store (public, tenant-scoped via subdomain) ────────────
|
|
Route::controller(\App\Http\Controllers\ShopController::class)->prefix('shop')->group(function () {
|
|
Route::get('/', 'catalog')->name('shop.catalog');
|
|
Route::get('/vin', 'vin')->name('shop.vin');
|
|
Route::get('/cart', 'showCart')->name('shop.cart');
|
|
Route::post('/cart/update', 'updateCart')->name('shop.cart.update');
|
|
Route::get('/checkout', 'checkout')->name('shop.checkout');
|
|
Route::post('/checkout', 'placeOrder')->name('shop.order.place');
|
|
Route::get('/order/{token}', 'orderStatus')
|
|
->where('token', '[A-Za-z0-9]{16,32}')
|
|
->name('shop.order');
|
|
Route::get('/part/{id}', 'part')->where('id', '\d+')->name('shop.part');
|
|
Route::post('/part/{id}/add', 'addToCart')->where('id', '\d+')->name('shop.cart.add');
|
|
});
|
|
|
|
// ─── Shop customer auth ────────────────────────────────────────────
|
|
// Aggressive throttle on auth POSTs (per IP) to prevent brute force and
|
|
// credential stuffing; GET views and account stay unthrottled.
|
|
Route::controller(\App\Http\Controllers\ShopAuthController::class)->prefix('shop')->group(function () {
|
|
Route::get('/register', 'showRegister')->name('shop.register');
|
|
Route::post('/register', 'register')->middleware('throttle:5,1,shop-register');
|
|
Route::get('/login', 'showLogin')->name('shop.login');
|
|
Route::post('/login', 'login')->middleware('throttle:5,1,shop-login');
|
|
Route::post('/logout', 'logout')->name('shop.logout');
|
|
Route::get('/account', 'account')->name('shop.account');
|
|
|
|
Route::get('/password/forgot', 'showForgotPassword')->name('shop.password.forgot');
|
|
Route::post('/password/email', 'sendResetLink')->name('shop.password.email')->middleware('throttle:3,1,shop-pw-email');
|
|
Route::get('/password/reset/{token}', 'showResetPassword')->name('password.reset');
|
|
Route::post('/password/reset', 'resetPassword')->name('shop.password.update')->middleware('throttle:5,1,shop-pw-reset');
|
|
});
|
|
|
|
// ─── Public WO tracking (no auth, tenant-scoped via subdomain) ──────
|
|
Route::get('/t/{token}', [\App\Http\Controllers\TrackingController::class, 'show'])
|
|
->where('token', '[A-Za-z0-9]{16,32}')
|
|
->name('tracking.show');
|
|
Route::get('/t/{token}/qr.svg', [\App\Http\Controllers\TrackingController::class, 'qr'])
|
|
->where('token', '[A-Za-z0-9]{16,32}')
|
|
->name('tracking.qr');
|
|
Route::post('/t/{token}/approve/{kind}/{lineToken}', [\App\Http\Controllers\TrackingController::class, 'approve'])
|
|
->where('token', '[A-Za-z0-9]{16,32}')
|
|
->where('kind', 'work|part')
|
|
->where('lineToken', '[A-Za-z0-9]{16,32}')
|
|
->name('tracking.approve');
|
|
|
|
// Locale switch — GET/POST /locale/{lang} sets session and persists to user.
|
|
// GET intentionally supported so simple <a> links (no CSRF token) work; anyone
|
|
// setting their own language is not a security concern.
|
|
Route::match(['get', 'post'], '/locale/{lang}', function (Request $request, string $lang) {
|
|
if (! in_array($lang, ['ro', 'ru', 'en'], true)) {
|
|
abort(404);
|
|
}
|
|
$request->session()->put('locale', $lang);
|
|
$request->session()->save();
|
|
if ($u = $request->user()) {
|
|
$u->forceFill(['locale' => $lang])->saveQuietly();
|
|
}
|
|
$to = $request->query('redirect')
|
|
?: $request->header('referer')
|
|
?: '/';
|
|
|
|
// Also set a persistent cookie as fallback — sessions can be regenerated,
|
|
// scoped per-subdomain differently, or lost on error pages. Cookie survives
|
|
// every kind of session issue.
|
|
return redirect($to)->cookie(
|
|
'app_locale', $lang,
|
|
60 * 24 * 365, // 1 year
|
|
'/', null, false, false // path, domain, secure, httpOnly
|
|
);
|
|
})->name('locale.switch');
|
|
|
|
// PWA — manifest pentru panou central (service.mir.md).
|
|
Route::get('/admin-manifest.json', function () {
|
|
return response()->json([
|
|
'name' => 'AutoCRM Admin',
|
|
'short_name' => 'AutoCRM',
|
|
'description' => 'Panou administrativ AutoCRM SaaS',
|
|
'start_url' => '/admin',
|
|
'scope' => '/',
|
|
'display' => 'standalone',
|
|
'orientation' => 'any',
|
|
'background_color' => '#ffffff',
|
|
'theme_color' => '#6366f1',
|
|
'lang' => 'ro',
|
|
'icons' => [
|
|
['src' => '/pwa/admin-192.png', 'sizes' => '192x192', 'type' => 'image/png', 'purpose' => 'any'],
|
|
['src' => '/pwa/admin-512.png', 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'any'],
|
|
['src' => '/pwa/admin-512.png', 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'maskable'],
|
|
],
|
|
])->header('Cache-Control', 'public, max-age=3600');
|
|
});
|
|
|
|
// SVG favicon for the central panel (referenced from <link rel="icon">)
|
|
Route::get('/pwa/admin-icon.svg', function () {
|
|
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
<rect width="100" height="100" rx="20" fill="#6366f1"/>
|
|
<text x="50" y="68" font-size="56" text-anchor="middle" fill="#fff" font-family="system-ui,-apple-system,sans-serif" font-weight="700">A</text>
|
|
</svg>';
|
|
return response($svg, 200, ['Content-Type' => 'image/svg+xml', 'Cache-Control' => 'public, max-age=86400']);
|
|
});
|
|
|
|
// PNG icons generated on-the-fly with GD if available, with PNG fallback baked from SVG.
|
|
// Browsers (Chrome) require real PNG bytes for the install prompt.
|
|
Route::get('/pwa/admin-{size}.png', function (int $size) {
|
|
if (! in_array($size, [192, 512], true)) abort(404);
|
|
|
|
if (! extension_loaded('gd')) {
|
|
// Fallback: serve a transparent 1x1 PNG. Install prompt may not show.
|
|
return response(base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='),
|
|
200, ['Content-Type' => 'image/png']);
|
|
}
|
|
|
|
$img = imagecreatetruecolor($size, $size);
|
|
$bg = imagecolorallocate($img, 99, 102, 241); // #6366f1
|
|
$fg = imagecolorallocate($img, 255, 255, 255);
|
|
imagefilledrectangle($img, 0, 0, $size, $size, $bg);
|
|
|
|
$letter = 'A';
|
|
$fontSize = (int) ($size * 0.55);
|
|
// Use bundled GD font 5 (largest built-in) repeated.
|
|
if (function_exists('imagettftext')) {
|
|
$font = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
|
|
if (file_exists($font)) {
|
|
$box = imagettfbbox($fontSize, 0, $font, $letter);
|
|
$w = $box[2] - $box[0];
|
|
$h = $box[1] - $box[7];
|
|
$x = (int) (($size - $w) / 2);
|
|
$y = (int) (($size + $h) / 2);
|
|
imagettftext($img, $fontSize, 0, $x, $y, $fg, $font, $letter);
|
|
}
|
|
} else {
|
|
// Fallback: built-in font
|
|
$charW = imagefontwidth(5);
|
|
$charH = imagefontheight(5);
|
|
$scale = max(1, (int) ($size / 8));
|
|
imagestring($img, 5, (int) (($size - $charW * $scale) / 2), (int) (($size - $charH * $scale) / 2), $letter, $fg);
|
|
}
|
|
|
|
ob_start();
|
|
imagepng($img);
|
|
$png = ob_get_clean();
|
|
imagedestroy($img);
|
|
|
|
return response($png, 200, [
|
|
'Content-Type' => 'image/png',
|
|
'Cache-Control' => 'public, max-age=86400',
|
|
]);
|
|
})->where('size', '\d+');
|
|
|
|
// Service worker pentru PWA central (necesar pentru prompt-ul de install).
|
|
// Header `Service-Worker-Allowed: /admin` permite SW-ului servit de la root
|
|
// să controleze scope-ul `/admin/*` (cerut de manifest).
|
|
Route::get('/admin-sw.js', function () {
|
|
return response(<<<'JS'
|
|
const CACHE = 'autocrm-admin-shell-v1';
|
|
const SHELL = ['/admin-manifest.json'];
|
|
self.addEventListener('install', e => {
|
|
e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL).catch(() => {})));
|
|
self.skipWaiting();
|
|
});
|
|
self.addEventListener('activate', e => {
|
|
e.waitUntil(caches.keys().then(keys =>
|
|
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
|
|
));
|
|
self.clients.claim();
|
|
});
|
|
self.addEventListener('fetch', e => {
|
|
if (e.request.method !== 'GET') return;
|
|
const u = new URL(e.request.url);
|
|
if (u.pathname.startsWith('/build/') || u.pathname.startsWith('/pwa/')) {
|
|
e.respondWith(caches.match(e.request).then(m => m || fetch(e.request).then(r => {
|
|
const copy = r.clone();
|
|
caches.open(CACHE).then(c => c.put(e.request, copy));
|
|
return r;
|
|
}).catch(() => caches.match(e.request))));
|
|
}
|
|
});
|
|
JS, 200, [
|
|
'Content-Type' => 'application/javascript',
|
|
'Cache-Control' => 'public, max-age=3600',
|
|
'Service-Worker-Allowed' => '/',
|
|
]);
|
|
});
|
|
|
|
// PWA — manifest dinamic per tenant.
|
|
Route::get('/manifest.json', function (Request $request) {
|
|
$tenant = app(TenantManager::class)->current();
|
|
$name = $tenant?->display_name ?? $tenant?->name ?? 'AutoCRM';
|
|
$themeColor = $tenant?->settings['theme_color'] ?? '#3B82F6';
|
|
$shortName = $tenant?->slug ?? 'autocrm';
|
|
|
|
return response()->json([
|
|
'name' => $name,
|
|
'short_name' => mb_substr($shortName, 0, 12),
|
|
'description' => 'CRM autoservice — ' . $name,
|
|
'start_url' => '/app',
|
|
'display' => 'standalone',
|
|
'orientation' => 'any',
|
|
'background_color' => '#ffffff',
|
|
'theme_color' => $themeColor,
|
|
'lang' => $tenant?->settings['language'] ?? 'ro',
|
|
'icons' => [
|
|
['src' => '/pwa/icon-192.png', 'sizes' => '192x192', 'type' => 'image/png'],
|
|
['src' => '/pwa/icon-512.png', 'sizes' => '512x512', 'type' => 'image/png'],
|
|
['src' => '/pwa/icon-maskable.png', 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'maskable'],
|
|
],
|
|
])->header('Cache-Control', 'public, max-age=3600');
|
|
});
|
|
|
|
// Service worker — shell cache + Web Push handlers.
|
|
Route::get('/sw.js', function () {
|
|
return response(<<<'JS'
|
|
const CACHE = 'autocrm-shell-v2';
|
|
const SHELL = ['/manifest.json'];
|
|
self.addEventListener('install', e => {
|
|
e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)));
|
|
self.skipWaiting();
|
|
});
|
|
self.addEventListener('activate', e => {
|
|
e.waitUntil(caches.keys().then(keys =>
|
|
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
|
|
));
|
|
self.clients.claim();
|
|
});
|
|
self.addEventListener('fetch', e => {
|
|
const u = new URL(e.request.url);
|
|
if (e.request.method !== 'GET') return;
|
|
if (u.pathname.startsWith('/build/') || u.pathname.startsWith('/pwa/')) {
|
|
e.respondWith(caches.match(e.request).then(m => m || fetch(e.request).then(r => {
|
|
const copy = r.clone();
|
|
caches.open(CACHE).then(c => c.put(e.request, copy));
|
|
return r;
|
|
})));
|
|
}
|
|
});
|
|
// Web Push: show notification from server payload.
|
|
self.addEventListener('push', e => {
|
|
let data = { title: 'AutoCRM', body: '', url: '/app', tag: 'autocrm' };
|
|
try { if (e.data) data = Object.assign(data, e.data.json()); } catch (err) {}
|
|
e.waitUntil(self.registration.showNotification(data.title, {
|
|
body: data.body,
|
|
tag: data.tag,
|
|
data: { url: data.url },
|
|
icon: '/pwa/icon-192.png',
|
|
badge: '/pwa/icon-192.png',
|
|
}));
|
|
});
|
|
// Focus or open the target URL on click.
|
|
self.addEventListener('notificationclick', e => {
|
|
e.notification.close();
|
|
const url = (e.notification.data && e.notification.data.url) || '/app';
|
|
e.waitUntil(clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
|
|
for (const c of list) { if ('focus' in c) { c.navigate(url); return c.focus(); } }
|
|
if (clients.openWindow) return clients.openWindow(url);
|
|
}));
|
|
});
|
|
JS, 200, ['Content-Type' => 'application/javascript', 'Cache-Control' => 'public, max-age=3600']);
|
|
});
|