Compare commits

..

2 Commits

8 changed files with 454 additions and 175 deletions

View File

@@ -103,7 +103,8 @@ class RateCardController extends Controller
public function profilePage(): View public function profilePage(): View
{ {
$profile = RateCardProfile::getSingleton(); $profile = RateCardProfile::getSingleton();
return view('rate-cards.profile', compact('profile')); $mediaFiles = \App\Models\MediaFile::latest()->get();
return view('rate-cards.profile', compact('profile', 'mediaFiles'));
} }
public function profileUpdate(Request $request): RedirectResponse public function profileUpdate(Request $request): RedirectResponse
@@ -125,6 +126,11 @@ class RateCardController extends Controller
'brand_logo_size' => 'nullable|in:small,medium,large', 'brand_logo_size' => 'nullable|in:small,medium,large',
'contact_person_html' => 'nullable|string', 'contact_person_html' => 'nullable|string',
'logo' => 'nullable|image|max:2048', 'logo' => 'nullable|image|max:2048',
'content_showcase' => 'nullable|array',
'content_showcase.*.file_id' => 'required_with:content_showcase|string',
'content_showcase.*.name' => 'required_with:content_showcase|string|max:255',
'content_showcase.*.url' => 'nullable|url',
'content_showcase.*.thumbnail_path' => 'nullable|string',
]); ]);
$profile = RateCardProfile::getSingleton(); $profile = RateCardProfile::getSingleton();
@@ -135,6 +141,7 @@ class RateCardController extends Controller
} }
$validated['show_prices'] = $request->has('show_prices'); $validated['show_prices'] = $request->has('show_prices');
$validated['content_showcase'] = $request->input('content_showcase', []);
unset($validated['logo']); unset($validated['logo']);
$profile->update($validated); $profile->update($validated);
@@ -221,6 +228,17 @@ class RateCardController extends Controller
// ── PDF Download ───────────────────────────────────────────── // ── PDF Download ─────────────────────────────────────────────
public function uploadShowcaseThumbnail(Request $request)
{
$request->validate(['thumbnail' => 'required|image|max:2048']);
$path = $request->file('thumbnail')->store('showcase', 'public');
return response()->json([
'path' => $path,
'url' => asset('storage/' . $path)
]);
}
public function pdf(): \Illuminate\Http\Response public function pdf(): \Illuminate\Http\Response
{ {
$profile = RateCardProfile::getSingleton(); $profile = RateCardProfile::getSingleton();

View File

@@ -13,7 +13,7 @@ class GateAuth
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
// Allow the login routes through without auth check // Allow the login routes through without auth check
if ($request->routeIs('gate.login', 'gate.attempt', 'gate.logout')) { if ($request->routeIs('gate.login', 'gate.attempt', 'gate.logout', 'rate-cards.public')) {
return $next($request); return $next($request);
} }

View File

@@ -17,11 +17,13 @@ class RateCardProfile extends Model
'past_brands', 'past_brands',
'brand_logo_size', 'brand_logo_size',
'contact_person_html', 'contact_person_html',
'content_showcase',
]; ];
protected $casts = [ protected $casts = [
'show_prices' => 'boolean', 'show_prices' => 'boolean',
'past_brands' => 'array', 'past_brands' => 'array',
'content_showcase' => 'array',
]; ];
/** Get the singleton profile, creating defaults if needed */ /** Get the singleton profile, creating defaults if needed */

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('rate_card_profiles', function (Blueprint $table) {
$table->json('content_showcase')->nullable();
});
}
public function down(): void
{
Schema::table('rate_card_profiles', function (Blueprint $table) {
$table->dropColumn('content_showcase');
});
}
};

View File

@@ -299,6 +299,52 @@
</div> </div>
@endif @endif
@if(!empty($profile->content_showcase))
<div style="page-break-before: always;"></div>
<div class="platform-section" style="margin-top:20px; page-break-inside:avoid;">
<div class="platform-title" style="text-align:center;">Content Showcase</div>
<div style="text-align:center; padding-top:10px;">
@foreach($profile->content_showcase as $item)
@php
$isUrl = ($item['file_id'] === 'url');
$file = !$isUrl ? \App\Models\MediaFile::find($item['file_id']) : null;
@endphp
@if($file || $isUrl)
<div style="display:inline-block; width:180px; margin:10px; border:1px solid rgba(0,0,0,0.1); border-radius:8px; overflow:hidden; vertical-align:top; background:#ffffff;">
<div style="height:120px; background:#F9FAFB; border-bottom:1px solid rgba(0,0,0,0.05); position:relative; text-align:center;">
@if(!empty($item['thumbnail_path']) && file_exists(storage_path('app/public/' . $item['thumbnail_path'])))
<a href="{{ $isUrl ? $item['url'] : $file->url }}" target="_blank" style="display:block; text-decoration:none; position:relative;">
<img src="{{ storage_path('app/public/' . $item['thumbnail_path']) }}" style="width:100%; height:120px; object-fit:cover;">
@if(!$isUrl && $file->is_video)
<div style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.2);">
<div style="width:30px; height:30px; background:rgba(255,255,255,0.8); border-radius:50%; display:flex; align-items:center; justify-content:center; color:{{ $accentHex }}; font-size:16px;">▶</div>
</div>
@endif
</a>
@elseif(!$isUrl && $file->is_image && file_exists(storage_path('app/private/' . $file->path)))
<a href="{{ $file->url }}" target="_blank" style="display:block; text-decoration:none;">
<img src="{{ storage_path('app/private/' . $file->path) }}" style="width:100%; height:120px; object-fit:cover;">
</a>
@else
<a href="{{ $isUrl ? $item['url'] : $file->url }}" target="_blank" style="display:block; text-decoration:none; color:{{ $accentHex }}; padding-top:40px; font-weight:bold;">
<span style="font-size:30px; display:block;">{{ $isUrl ? '🌐' : ($file->is_video ? '🎬' : '📁') }}</span>
{{ $isUrl ? 'Open Link' : 'View File' }}
</a>
@endif
</div>
<div style="padding:10px; text-align:center;">
<div style="font-weight:700; font-size:11px;">{{ $item['name'] }}</div>
<div style="font-size:9px; color:#9CA3AF; margin-top:4px;">
{{ $isUrl ? 'External Link' : ($file->is_video ? 'Video' : ($file->is_image ? 'Image' : 'Document')) }}
</div>
</div>
</div>
@endif
@endforeach
</div>
</div>
@endif
@if($profile->terms_and_conditions) @if($profile->terms_and_conditions)
<div style="page-break-before: always;"></div> <div style="page-break-before: always;"></div>
<div class="platform-section" style="margin-top:20px; page-break-inside:avoid;"> <div class="platform-section" style="margin-top:20px; page-break-inside:avoid;">

View File

@@ -84,52 +84,52 @@
</div> </div>
<div class="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="sm:col-span-2" x-data="{ <div class="sm:col-span-2" x-data="{
uploading: false, uploading: false,
errorMsg: '', errorMsg: '',
logoUrl: '{{ $profile->logo_path ? route('files.serve-profile-logo') : '' }}', logoUrl: '{{ $profile->logo_path ? route('files.serve-profile-logo') : '' }}',
async pickFile(input) { async pickFile(input) {
if (!input.files.length) return; if (!input.files.length) return;
this.uploading = true; this.uploading = true;
this.errorMsg = ''; this.errorMsg = '';
const fd = new FormData(); const fd = new FormData();
fd.append('logo', input.files[0]); fd.append('logo', input.files[0]);
fd.append('_token', document.querySelector('meta[name=csrf-token]').content); fd.append('_token', document.querySelector('meta[name=csrf-token]').content);
try { try {
const res = await fetch('{{ route('rate-cards.profile.upload-logo', [], false) }}', { const res = await fetch('{{ route('rate-cards.profile.upload-logo', [], false) }}', {
method: 'POST', method: 'POST',
body: fd, body: fd,
headers: { headers: {
'Accept': 'application/json' 'Accept': 'application/json'
}
});
const data = await res.json();
if (res.ok) {
this.logoUrl = data.url + '?t=' + Date.now();
} else {
this.errorMsg = data.message ?? 'Upload failed.';
}
} catch (err) {
console.error(err);
this.errorMsg = 'Network error. Please try again.';
} }
}); this.uploading = false;
const data = await res.json(); input.value = '';
if (res.ok) { },
this.logoUrl = data.url + '?t=' + Date.now();
} else {
this.errorMsg = data.message ?? 'Upload failed.';
}
} catch (err) {
console.error(err);
this.errorMsg = 'Network error. Please try again.';
}
this.uploading = false;
input.value = '';
},
async removeLogo() { async removeLogo() {
if (!confirm('Remove this logo?')) return; if (!confirm('Remove this logo?')) return;
const fd = new FormData(); const fd = new FormData();
fd.append('_token', document.querySelector('meta[name=csrf-token]').content); fd.append('_token', document.querySelector('meta[name=csrf-token]').content);
fd.append('_method', 'DELETE'); fd.append('_method', 'DELETE');
await fetch('{{ route('rate-cards.profile.delete-logo', [], false) }}', { await fetch('{{ route('rate-cards.profile.delete-logo', [], false) }}', {
method: 'POST', method: 'POST',
body: fd, body: fd,
headers: { 'Accept': 'application/json' } headers: { 'Accept': 'application/json' }
}); });
this.logoUrl = ''; this.logoUrl = '';
} }
}"> }">
<label class="block text-xs font-semibold text-gray-600 mb-1">Logo / Profile Image</label> <label class="block text-xs font-semibold text-gray-600 mb-1">Logo / Profile Image</label>
<template x-if="logoUrl"> <template x-if="logoUrl">
@@ -149,9 +149,9 @@
<div class="relative"> <div class="relative">
<input type="file" accept="image/*" @change="pickFile($event.target)" class="w-full text-sm text-gray-600 <input type="file" accept="image/*" @change="pickFile($event.target)" class="w-full text-sm text-gray-600
file:mr-3 file:py-2 file:px-4 file:rounded-full file:border-0 file:mr-3 file:py-2 file:px-4 file:rounded-full file:border-0
file:text-xs file:font-semibold file:bg-pink-50 file:text-pink-600 file:text-xs file:font-semibold file:bg-pink-50 file:text-pink-600
hover:file:bg-pink-100 cursor-pointer" :disabled="uploading"> hover:file:bg-pink-100 cursor-pointer" :disabled="uploading">
<span x-show="uploading" class="text-xs text-pink-500 mt-1 block">Uploading…</span> <span x-show="uploading" class="text-xs text-pink-500 mt-1 block">Uploading…</span>
<span x-show="errorMsg" x-text="errorMsg" class="text-xs text-red-500 mt-1 block"></span> <span x-show="errorMsg" x-text="errorMsg" class="text-xs text-red-500 mt-1 block"></span>
</div> </div>
@@ -190,25 +190,25 @@
</div> </div>
</div> </div>
<div class="sm:col-span-2" x-data="{ <div class="sm:col-span-2" x-data="{
init() { init() {
var quill = new Quill(this.$refs.editor, { var quill = new Quill(this.$refs.editor, {
theme: 'snow', theme: 'snow',
placeholder: 'e.g. 1. 50% down payment required. 2. Rates valid for 30 days.', placeholder: 'e.g. 1. 50% down payment required. 2. Rates valid for 30 days.',
modules: { modules: {
toolbar: [ toolbar: [
[{ 'size': ['small', false, 'large', 'huge'] }], [{ 'size': ['small', false, 'large', 'huge'] }],
['bold', 'italic', 'underline', 'strike'], ['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }], [{ 'color': [] }, { 'background': [] }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }], [{ 'list': 'ordered'}, { 'list': 'bullet' }],
['clean'] ['clean']
] ]
}
});
quill.on('text-change', () => {
this.$refs.input.value = quill.root.innerHTML;
});
} }
}); }">
quill.on('text-change', () => {
this.$refs.input.value = quill.root.innerHTML;
});
}
}">
<label class="block text-xs font-semibold text-gray-600 mb-1">Terms & Conditions</label> <label class="block text-xs font-semibold text-gray-600 mb-1">Terms & Conditions</label>
<input type="hidden" name="terms_and_conditions" x-ref="input" <input type="hidden" name="terms_and_conditions" x-ref="input"
value="{{ old('terms_and_conditions', $profile->terms_and_conditions) }}"> value="{{ old('terms_and_conditions', $profile->terms_and_conditions) }}">
@@ -219,41 +219,41 @@
</div> </div>
</div> </div>
<div class="sm:col-span-2" x-data="{ <div class="sm:col-span-2" x-data="{
init() { init() {
var quill = new Quill(this.$refs.editor, { var quill = new Quill(this.$refs.editor, {
theme: 'snow', theme: 'snow',
placeholder: 'Customize your Contact Person Page formatting...', placeholder: 'Customize your Contact Person Page formatting...',
modules: { modules: {
toolbar: [ toolbar: [
[{ 'size': ['small', false, 'large', 'huge'] }], [{ 'size': ['small', false, 'large', 'huge'] }],
['bold', 'italic', 'underline', 'strike'], ['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }], [{ 'color': [] }, { 'background': [] }],
[{ 'align': [] }], [{ 'align': [] }],
['clean'] ['clean']
] ]
}
});
quill.on('text-change', () => {
this.$refs.input.value = quill.root.innerHTML;
});
} }
}); }">
quill.on('text-change', () => {
this.$refs.input.value = quill.root.innerHTML;
});
}
}">
<label class="block text-xs font-semibold text-gray-600 mb-1">Contact Person Page (PDF)</label> <label class="block text-xs font-semibold text-gray-600 mb-1">Contact Person Page (PDF)</label>
@php @php
$defaultContactHtml = '<div style="text-align: center; margin-top: 40px;"> $defaultContactHtml = '<div style="text-align: center; margin-top: 40px;">
<h1 style="font-family: \'Comic Sans MS\', sans-serif; font-size: 32px; font-weight: 900; letter-spacing: 4px; color: #111827; margin-bottom: 30px; text-transform: uppercase;">CONTACT PERSON</h1>'; <h1 style="font-family: \'Comic Sans MS\', sans-serif; font-size: 32px; font-weight: 900; letter-spacing: 4px; color: #111827; margin-bottom: 30px; text-transform: uppercase;">CONTACT PERSON</h1>';
if ($profile->whatsapp) { if ($profile->whatsapp) {
$defaultContactHtml .= '<div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 36px; font-weight: 900; color: #14b8a6; margin-bottom: 5px;">' . $profile->whatsapp . '</div> $defaultContactHtml .= '<div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 36px; font-weight: 900; color: #14b8a6; margin-bottom: 5px;">' . $profile->whatsapp . '</div>
<div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 20px; font-weight: 800; color: #14b8a6; margin-bottom: 30px; letter-spacing: 2px;">(WHATS APP)</div>'; <div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 20px; font-weight: 800; color: #14b8a6; margin-bottom: 30px; letter-spacing: 2px;">(WHATS APP)</div>';
} }
if ($profile->email) { if ($profile->email) {
$defaultContactHtml .= '<div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 14px; font-weight: 800; color: #f97316; margin-bottom: 10px; letter-spacing: 2px;">EMAIL</div> $defaultContactHtml .= '<div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 14px; font-weight: 800; color: #f97316; margin-bottom: 10px; letter-spacing: 2px;">EMAIL</div>
<div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 32px; font-weight: 900; color: #ef4444; margin-bottom: 40px; letter-spacing: 2px;">' . strtoupper($profile->email) . '</div>'; <div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 32px; font-weight: 900; color: #ef4444; margin-bottom: 40px; letter-spacing: 2px;">' . strtoupper($profile->email) . '</div>';
} }
$defaultContactHtml .= '<div style="font-size: 11px; font-weight: 900; color: #111827; line-height: 1.5; max-width: 80%; margin: 0 auto; text-transform: uppercase;"> $defaultContactHtml .= '<div style="font-size: 11px; font-weight: 900; color: #111827; line-height: 1.5; max-width: 80%; margin: 0 auto; text-transform: uppercase;">
Kami tidak bertanggung jawab atas segala bentuk kerjasama yg timbul dengan pihak selain dari kontak diatas Kami tidak bertanggung jawab atas segala bentuk kerjasama yg timbul dengan pihak selain dari kontak diatas
</div> </div>
</div>'; </div>';
@endphp @endphp
<input type="hidden" name="contact_person_html" x-ref="input" <input type="hidden" name="contact_person_html" x-ref="input"
value="{{ old('contact_person_html', $profile->contact_person_html) }}"> value="{{ old('contact_person_html', $profile->contact_person_html) }}">
@@ -282,93 +282,218 @@
</div> </div>
</div> </div>
{{-- Past Brands --}} {{-- Past Brands --}}
<div class="cat-card overflow-hidden"> <div class="cat-card overflow-hidden">
<div class="flex items-center gap-3 px-5 py-4 border-b border-pink-50"> <div class="flex items-center gap-3 px-5 py-4 border-b border-pink-50">
<span class="text-xl"></span> <span class="text-xl"></span>
<h2 class="font-bold text-gray-700">Brand Collaborations Logo</h2> <h2 class="font-bold text-gray-700">Brand Collaborations Logo</h2>
</div> </div>
<div class="p-5" x-data="{ <div class="p-5" x-data="{
uploading: false, uploading: false,
brands: {{ Illuminate\Support\Js::from($profile->past_brands ?? []) }}, brands: {{ Illuminate\Support\Js::from($profile->past_brands ?? []) }},
async pickBrand(input) { async pickBrand(input) {
if (!input.files.length) return; if (!input.files.length) return;
this.uploading = true; this.uploading = true;
const fd = new FormData(); const fd = new FormData();
for (let i = 0; i < input.files.length; i++) { for (let i = 0; i < input.files.length; i++) {
fd.set('logo', input.files[i]); fd.set('logo', input.files[i]);
fd.set('_token', document.querySelector('meta[name=csrf-token]').content); fd.set('_token', document.querySelector('meta[name=csrf-token]').content);
try { try {
const res = await fetch('{{ route('rate-cards.profile.upload-brand-logo', [], false) }}', { method: 'POST', body: fd, headers: { 'Accept': 'application/json' } }); const res = await fetch('{{ route('rate-cards.profile.upload-brand-logo', [], false) }}', { method: 'POST', body: fd, headers: { 'Accept': 'application/json' } });
const data = await res.json(); const data = await res.json();
if (res.ok) this.brands.push(data.path); if (res.ok) this.brands.push(data.path);
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
this.uploading = false; this.uploading = false;
input.value = ''; input.value = '';
}, },
async removeBrand(path, index) { async removeBrand(path, index) {
if(!confirm('Remove this brand?')) return; if(!confirm('Remove this brand?')) return;
const fd = new FormData(); const fd = new FormData();
fd.append('_token', document.querySelector('meta[name=csrf-token]').content); fd.append('_token', document.querySelector('meta[name=csrf-token]').content);
fd.append('_method', 'DELETE'); fd.append('_method', 'DELETE');
fd.append('path', path); fd.append('path', path);
await fetch('{{ route('rate-cards.profile.delete-brand-logo', [], false) }}', { method: 'POST', body: fd, headers: {'Accept': 'application/json'} }); await fetch('{{ route('rate-cards.profile.delete-brand-logo', [], false) }}', { method: 'POST', body: fd, headers: {'Accept': 'application/json'} });
this.brands.splice(index, 1); this.brands.splice(index, 1);
} }
}"> }">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<label class="block text-xs font-semibold text-gray-600">Brand Logos</label> <label class="block text-xs font-semibold text-gray-600">Brand Logos</label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-[10px] text-gray-500 uppercase tracking-widest font-semibold">Display Size on PDF:</span> <span class="text-[10px] text-gray-500 uppercase tracking-widest font-semibold">Display Size on
<select name="brand_logo_size" class="text-xs border-pink-200 rounded-md py-1 pr-8 pl-2 focus:ring-pink-500 focus:border-pink-500 bg-white"> PDF:</span>
<option value="small" {{ old('brand_logo_size', $profile->brand_logo_size ?? 'medium') == 'small' ? 'selected' : '' }}>Small</option> <select name="brand_logo_size"
<option value="medium" {{ old('brand_logo_size', $profile->brand_logo_size ?? 'medium') == 'medium' ? 'selected' : '' }}>Medium</option> class="text-xs border-pink-200 rounded-md py-1 pr-8 pl-2 focus:ring-pink-500 focus:border-pink-500 bg-white">
<option value="large" {{ old('brand_logo_size', $profile->brand_logo_size ?? 'medium') == 'large' ? 'selected' : '' }}>Large</option> <option value="small" {{ old('brand_logo_size', $profile->brand_logo_size ?? 'medium') == 'small' ? 'selected' : '' }}>Small</option>
</select> <option value="medium" {{ old('brand_logo_size', $profile->brand_logo_size ?? 'medium') == 'medium' ? 'selected' : '' }}>Medium</option>
<option value="large" {{ old('brand_logo_size', $profile->brand_logo_size ?? 'medium') == 'large' ? 'selected' : '' }}>Large</option>
</select>
</div>
</div>
<div class="flex flex-wrap gap-4 mb-3">
<template x-for="(path, index) in brands" :key="index">
<div class="relative rounded-2xl border border-pink-100 p-2 flex items-center justify-center bg-white group shrink-0"
style="width: 100px; height: 100px;">
<img :src="'/storage/' + path" class="max-w-full max-h-full object-contain">
<button type="button" @click="removeBrand(path, index)"
class="absolute -bottom-2 -right-2 bg-red-100 text-red-500 rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-200 shadow text-[10px] z-10 transition-colors">🗑️</button>
</div>
</template>
<label
class="rounded-2xl border-2 border-dashed border-pink-200 flex items-center justify-center text-pink-300 hover:bg-pink-50 hover:text-pink-400 cursor-pointer transition-colors shrink-0"
style="width: 100px; height: 100px;" :class="{'opacity-50 pointer-events-none': uploading}">
<span x-show="!uploading" class="text-2xl">+</span>
<span x-show="uploading" class="text-xs">...</span>
<input type="file" multiple accept="image/*" class="hidden" @change="pickBrand($event.target)">
</label>
</div>
<p class="text-xs text-gray-400 mt-1">Upload brand logos you've worked with. They will be displayed on
your rate
cards.</p>
</div> </div>
</div> </div>
<div class="flex flex-wrap gap-4 mb-3">
<template x-for="(path, index) in brands" :key="index">
<div
class="relative rounded-2xl border border-pink-100 p-2 flex items-center justify-center bg-white group shrink-0"
style="width: 100px; height: 100px;">
<img :src="'/storage/' + path" class="max-w-full max-h-full object-contain">
<button type="button" @click="removeBrand(path, index)"
class="absolute -bottom-2 -right-2 bg-red-100 text-red-500 rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-200 shadow text-[10px] z-10 transition-colors">🗑️</button>
</div>
</template>
<label {{-- Content Showcase --}}
class="rounded-2xl border-2 border-dashed border-pink-200 flex items-center justify-center text-pink-300 hover:bg-pink-50 hover:text-pink-400 cursor-pointer transition-colors shrink-0" <div class="cat-card overflow-hidden">
style="width: 100px; height: 100px;" <div class="flex items-center gap-3 px-5 py-4 border-b border-pink-50">
:class="{'opacity-50 pointer-events-none': uploading}"> <span class="text-xl">📺</span>
<span x-show="!uploading" class="text-2xl">+</span> <h2 class="font-bold text-gray-700">Content Showcase</h2>
<span x-show="uploading" class="text-xs">...</span> </div>
<input type="file" multiple accept="image/*" class="hidden" @change="pickBrand($event.target)"> <div class="p-5" x-data="{
</label> items: {{ Illuminate\Support\Js::from(old('content_showcase', $profile->content_showcase ?? [])) }}.map(i => ({ file_id: i.file_id || '', name: i.name || '', url: i.url || '', thumbnail_path: i.thumbnail_path || '', uploading: false })),
addItem() {
this.items.push({ file_id: '', name: '', url: '', thumbnail_path: '', uploading: false });
},
removeItem(index) {
this.items.splice(index, 1);
},
async uploadThumb(input, item) {
if (!input.files.length) return;
item.uploading = true;
const fd = new FormData();
fd.append('thumbnail', input.files[0]);
fd.append('_token', document.querySelector('meta[name=csrf-token]').content);
try {
const res = await fetch('{{ route('rate-cards.profile.upload-showcase-thumbnail', [], false) }}', { method: 'POST', body: fd, headers: { 'Accept': 'application/json' } });
const data = await res.json();
if (res.ok) {
item.thumbnail_path = data.path;
} else {
alert(data.message || 'Upload failed');
}
} catch (e) {
console.error(e);
alert('Network Error. Please try again.');
}
item.uploading = false;
input.value = '';
}
}">
<template x-for="(item, index) in items" :key="index">
<div class="flex flex-col gap-3 mb-4 bg-pink-50/30 p-3 rounded-2xl border border-pink-100 relative">
<div class="flex flex-col sm:flex-row gap-3 items-start w-full">
<div class="flex-1 w-full">
<label class="block text-[10px] font-semibold text-gray-600 mb-1">Select File <span
class="text-pink-400">*</span></label>
<select :name="'content_showcase['+index+'][file_id]'" x-model="item.file_id" required
class="input-soft w-full py-1.5 text-xs">
<option value="">-- Choose a File --</option>
<option value="url" class="font-bold text-pink-600">+ External Link</option>
@foreach($mediaFiles as $file)
<option value="{{ $file->id }}">{{ $file->name }}</option>
@endforeach
</select>
</div>
<div class="flex-1 w-full">
<label class="block text-[10px] font-semibold text-gray-600 mb-1">Custom Display Name
<span class="text-pink-400">*</span></label>
<input type="text" :name="'content_showcase['+index+'][name]'" x-model="item.name"
required class="input-soft w-full py-1.5 text-xs" placeholder="e.g. Viral Vlog">
</div>
<div class="shrink-0 flex items-end pt-5">
<button type="button" @click="removeItem(index)"
class="bg-red-100 text-red-500 rounded-lg px-3 py-1.5 hover:bg-red-200 transition-colors text-[10px] font-semibold">
Remove
</button>
</div>
</div>
<template x-if="item.file_id">
<div class="w-full border-t border-pink-100 pt-3 flex flex-wrap gap-3 items-center">
<div class="shrink-0 flex items-center justify-center">
<input type="hidden" :name="'content_showcase['+index+'][thumbnail_path]'"
x-model="item.thumbnail_path">
<template x-if="item.thumbnail_path">
<div class="relative rounded-xl border border-pink-100 p-1 flex items-center justify-center bg-white group shrink-0"
style="width: 54px; height: 54px;">
<img :src="'/storage/' + item.thumbnail_path"
class="max-w-full max-h-full object-contain rounded">
<button type="button" @click="item.thumbnail_path = ''"
class="absolute -top-2 -right-2 bg-red-100 text-red-500 rounded-full w-5 h-5 flex items-center justify-center hover:bg-red-200 shadow text-[9px] z-10 transition-colors">🗑️</button>
</div>
</template>
<template x-if="!item.thumbnail_path">
<label
class="rounded-xl border-2 border-dashed border-pink-200 flex items-center justify-center text-pink-300 hover:bg-pink-50 hover:text-pink-400 cursor-pointer transition-colors shrink-0"
style="width: 54px; height: 54px;"
:class="{'opacity-50 pointer-events-none': item.uploading}"
title="Upload Thumbnail (Optional)">
<span x-show="!item.uploading"
class="text-xl font-light leading-none mb-1">+</span>
<span x-show="item.uploading" class="text-[10px]">...</span>
<input type="file" accept="image/*" class="hidden"
@change="uploadThumb($event.target, item)">
</label>
</template>
</div>
<template x-if="item.file_id === 'url'">
<div class="flex-1 min-w-[200px]">
<input type="url" :name="'content_showcase['+index+'][url]'" x-model="item.url"
class="input-soft w-full py-2 px-3 text-xs"
placeholder="URL (https://instagram.com/p/...)" required>
</div>
</template>
<template x-if="item.file_id !== 'url'">
<div class="flex-1 text-xs text-gray-400">
Uploaded Thumbnail: Use this to override the default display icon for videos and documents.
</div>
</template>
</div>
</template>
</div>
</template>
<button type="button" @click="addItem()"
class="text-sm px-4 py-2 rounded-full border border-pink-200 text-pink-500 hover:bg-pink-50 font-semibold bg-white mb-2 shadow-sm">
+ Add Content to Showcase
</button>
<p class="text-xs text-gray-400 mt-1">Select files you've uploaded in the Files page to feature them
directly on your public rate card.</p>
</div>
</div> </div>
<p class="text-xs text-gray-400 mt-1">Upload brand logos you've worked with. They will be displayed on your rate
cards.</p>
</div>
</div>
{{-- Actions --}} {{-- Actions --}}
<div class="flex flex-wrap items-center gap-2 w-full"> <div class="flex flex-wrap items-center gap-2 w-full">
<button type="submit" class="btn-sakura text-xs sm:text-sm shrink-0 px-4 py-2 sm:px-6 sm:py-2.5">💾 Save Profile</button> <button type="submit" class="btn-sakura text-xs sm:text-sm shrink-0 px-4 py-2 sm:px-6 sm:py-2.5">💾 Save
<a href="{{ route('rate-cards.public') }}" target="_blank" Profile</button>
class="bg-white text-xs sm:text-sm px-3 py-1.5 sm:px-4 sm:py-2 rounded-full border border-pink-200 text-pink-600 hover:bg-pink-50 shadow-sm shrink-0"> <a href="{{ route('rate-cards.public') }}" target="_blank"
👁️ Preview class="bg-white text-xs sm:text-sm px-3 py-1.5 sm:px-4 sm:py-2 rounded-full border border-pink-200 text-pink-600 hover:bg-pink-50 shadow-sm shrink-0">
</a> 👁️ Preview
<a href="{{ route('rate-cards.pdf') }}" </a>
class="bg-white text-xs sm:text-sm px-3 py-1.5 sm:px-4 sm:py-2 rounded-full border border-blue-200 text-blue-600 hover:bg-blue-50 shadow-sm shrink-0"> <a href="{{ route('rate-cards.pdf') }}"
📄 Download PDF class="bg-white text-xs sm:text-sm px-3 py-1.5 sm:px-4 sm:py-2 rounded-full border border-blue-200 text-blue-600 hover:bg-blue-50 shadow-sm shrink-0">
</a> 📄 Download PDF
<a href="{{ route('rate-cards.index') }}" </a>
class="bg-white text-xs sm:text-sm px-3 py-1.5 sm:px-4 sm:py-2 rounded-full border border-gray-200 text-gray-600 hover:bg-gray-50 shadow-sm shrink-0"> <a href="{{ route('rate-cards.index') }}"
← Back class="bg-white text-xs sm:text-sm px-3 py-1.5 sm:px-4 sm:py-2 rounded-full border border-gray-200 text-gray-600 hover:bg-gray-50 shadow-sm shrink-0">
</a> ← Back
</div> </a>
</form> </div>
</form>
</div> </div>
@endsection @endsection

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rate Card — {{ $profile->owner_name }}</title> <title>Rate Card — {{ $profile->owner_name }}</title>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style> <style>
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -240,7 +241,7 @@
</style> </style>
</head> </head>
<body> <body x-data="{ popupOpen: false, popupImage: '' }">
<div class="container"> <div class="container">
<div class="header"> <div class="header">
@if($profile->logo_path) @if($profile->logo_path)
@@ -328,9 +329,66 @@
</div> </div>
@endif @endif
@if(!empty($profile->content_showcase))
<div class="platform-section" style="margin-top:40px;">
<div class="platform-title" style="justify-content:center;">Content Showcase</div>
<div class="cards">
@foreach($profile->content_showcase as $item)
@php
$isUrl = ($item['file_id'] === 'url');
$file = !$isUrl ? \App\Models\MediaFile::find($item['file_id']) : null;
@endphp
@if($file || $isUrl)
<div class="card" style="padding:0; overflow:hidden; display:flex; flex-direction:column;">
<div style="flex:1; background:#F9FAFB; display:flex; align-items:center; justify-content:center; position:relative;">
@if(!empty($item['thumbnail_path']))
@if(!$isUrl && $file->is_image)
<div style="width:100%; height:180px; cursor:pointer; position:relative;" @click="popupImage = '{{ $file->url }}'; popupOpen = true;">
<img src="{{ asset('storage/' . $item['thumbnail_path']) }}" style="width:100%; height:100%; object-fit:cover; display:block;">
</div>
@else
<a href="{{ $isUrl ? ($item['url'] ?? '#') : $file->url }}" target="_blank" style="display:block; width:100%; height:180px; position:relative;">
<img src="{{ asset('storage/' . $item['thumbnail_path']) }}" style="width:100%; height:100%; object-fit:cover; display:block;">
@if(!$isUrl && $file->is_video)
<div style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.2);">
<div style="width:48px; height:48px; background:rgba(255,255,255,0.8); border-radius:50%; display:flex; align-items:center; justify-content:center; color:{{ $profile->accent_color }}; font-size:24px;">▶</div>
</div>
@endif
</a>
@endif
@elseif($isUrl)
<div style="height:180px; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:20px; text-align:center;">
<span style="font-size:48px;">🌐</span>
<a href="{{ $item['url'] ?? '#' }}" target="_blank" style="margin-top:10px; font-size:12px; color:{{ $profile->accent_color }}; text-decoration:none; font-weight:700;">Open Link</a>
</div>
@else
@if($file->is_image)
<div style="width:100%; height:180px; cursor:pointer;" @click="popupImage = '{{ $file->url }}'; popupOpen = true;">
<img src="{{ $file->url }}" style="width:100%; height:100%; object-fit:cover; display:block;">
</div>
@elseif($file->is_video)
<video src="{{ $file->url }}" style="width:100%; height:180px; object-fit:cover; display:block;" controls></video>
@else
<div style="height:180px; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:20px; text-align:center;">
<span style="font-size:48px;">{{ $file->icon }}</span>
<a href="{{ $file->url }}" target="_blank" style="margin-top:10px; font-size:12px; color:{{ $profile->accent_color }}; text-decoration:none; font-weight:700;">View File</a>
</div>
@endif
@endif
</div>
<div style="padding:16px;">
<div class="service" style="margin-bottom:0; text-align:center; font-weight:700;">{{ $item['name'] }}</div>
</div>
</div>
@endif
@endforeach
</div>
</div>
@endif
@if($profile->terms_and_conditions) @if($profile->terms_and_conditions)
<div class="platform-section" style="margin-top:40px;"> <div class="platform-section" style="margin-top:40px;">
<div class="platform-title">Terms & Conditions</div> <div class="platform-title" style="justify-content:center;">Terms & Conditions</div>
<div class="card" style="font-size:13px; color:#6B7280; line-height:1.6;"> <div class="card" style="font-size:13px; color:#6B7280; line-height:1.6;">
{!! $profile->terms_and_conditions !!}</div> {!! $profile->terms_and_conditions !!}</div>
</div> </div>
@@ -341,6 +399,13 @@
<!--<br>Generated by Cathy's App · {{ now()->format('Y-M-D') }}--> <!--<br>Generated by Cathy's App · {{ now()->format('Y-M-D') }}-->
</div> </div>
</div> </div>
<!-- Image Popup Modal -->
<div x-show="popupOpen" x-transition.opacity style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.8); z-index:9999; align-items:center; justify-content:center; padding:20px;" :style="popupOpen ? 'display:flex' : 'display:none'">
<div style="position:absolute; inset:0; cursor:pointer;" @click="popupOpen = false"></div>
<img :src="popupImage" style="max-width:100%; max-height:100%; object-fit:contain; position:relative; z-index:10000; border-radius:12px; box-shadow:0 10px 25px rgba(0,0,0,0.5);">
<button @click="popupOpen = false" style="position:absolute; top:20px; right:20px; background:rgba(255,255,255,0.2); border:none; color:white; font-size:30px; width:40px; height:40px; border-radius:50%; cursor:pointer; z-index:10001; display:flex; align-items:center; justify-content:center;">&times;</button>
</div>
</body> </body>
</html> </html>

View File

@@ -88,6 +88,7 @@ Route::post('/rate-cards/profile/logo', [RateCardController::class, 'uploadLogo'
Route::delete('/rate-cards/profile/logo', [RateCardController::class, 'deleteLogo'])->name('rate-cards.profile.delete-logo'); Route::delete('/rate-cards/profile/logo', [RateCardController::class, 'deleteLogo'])->name('rate-cards.profile.delete-logo');
Route::post('/rate-cards/profile/brand-logo', [RateCardController::class, 'uploadBrandLogo'])->name('rate-cards.profile.upload-brand-logo'); Route::post('/rate-cards/profile/brand-logo', [RateCardController::class, 'uploadBrandLogo'])->name('rate-cards.profile.upload-brand-logo');
Route::delete('/rate-cards/profile/brand-logo', [RateCardController::class, 'deleteBrandLogo'])->name('rate-cards.profile.delete-brand-logo'); Route::delete('/rate-cards/profile/brand-logo', [RateCardController::class, 'deleteBrandLogo'])->name('rate-cards.profile.delete-brand-logo');
Route::post('/rate-cards/profile/content-thumbnail', [RateCardController::class, 'uploadShowcaseThumbnail'])->name('rate-cards.profile.upload-showcase-thumbnail');
Route::get('/files/profile-logo', [RateCardController::class, 'serveProfileLogo'])->name('files.serve-profile-logo'); Route::get('/files/profile-logo', [RateCardController::class, 'serveProfileLogo'])->name('files.serve-profile-logo');
Route::resource('rate-cards', RateCardController::class)->except(['show']); Route::resource('rate-cards', RateCardController::class)->except(['show']);