From cd8197388196573815afbb799771233c583bde91 Mon Sep 17 00:00:00 2001 From: Ian Macabulos Date: Tue, 7 Apr 2026 21:58:31 +0800 Subject: [PATCH 1/6] init: cv generator --- app/Livewire/CoverLetterGenerator.php | 95 +++++++++++++++ app/Livewire/EditApplication.php | 41 +++++++ app/Models/JobPosting.php | 3 +- app/Models/User.php | 19 ++- config/services.php | 1 + ...add_cover_letter_to_applications_table.php | 22 ++++ resources/views/layouts/app.blade.php | 12 ++ .../livewire/cover-letter-generator.blade.php | 113 ++++++++++++++++++ routes/web.php | 3 + 9 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 app/Livewire/CoverLetterGenerator.php create mode 100644 database/migrations/2026_04_07_125131_add_cover_letter_to_applications_table.php create mode 100644 resources/views/livewire/cover-letter-generator.blade.php diff --git a/app/Livewire/CoverLetterGenerator.php b/app/Livewire/CoverLetterGenerator.php new file mode 100644 index 0000000..f978e26 --- /dev/null +++ b/app/Livewire/CoverLetterGenerator.php @@ -0,0 +1,95 @@ +jobs = auth()->user()->jobPostings()->latest()->get(); + + if (request()->has('job_id')) { + $this->jobId = request('job_id'); + $this->loadJob(); + } + } + + public function updatedJobId() + { + $this->loadJob(); + } + + public function loadJob() + { + if (empty($this->jobId)) { + $this->selectedJob = null; + $this->coverLetterContent = ''; + return; + } + + $this->selectedJob = auth()->user()->jobPostings()->find($this->jobId); + $this->coverLetterContent = $this->selectedJob->cover_letter ?? ''; + } + + public function generate(GroqCoverLetterService $service) + { + if (!$this->selectedJob) { + session()->flash('error', 'Please select an application first.'); + return; + } + + $this->isGenerating = true; + + try { + $primaryResume = auth()->user()->resumes()->where('is_primary', true)->first(); + + if (!$primaryResume || empty($primaryResume->content_raw)) { + throw new Exception('Please upload and set a primary resume in your profile first.'); + } + + // Generate draft and populate the editor + $this->coverLetterContent = $service->generate( + $primaryResume->content_raw, + "Company: {$this->selectedJob->company}\nJob Title: {$this->selectedJob->title}\nDescription: {$this->selectedJob->description}" + ); + + session()->flash('success', 'Draft generated successfully! You can now edit and save it.'); + + } catch (Exception $e) { + session()->flash('error', $e->getMessage()); + } finally { + $this->isGenerating = false; + } + } + + public function save() + { + if (!$this->selectedJob) return; + + $this->validate([ + 'coverLetterContent' => 'required|string', + ]); + + $this->selectedJob->update([ + 'cover_letter' => $this->coverLetterContent + ]); + + session()->flash('success', 'Cover letter saved to application successfully.'); + } + + public function render() + { + return view('livewire.cover-letter-generator'); + } +} \ No newline at end of file diff --git a/app/Livewire/EditApplication.php b/app/Livewire/EditApplication.php index 86bfa37..2f8db37 100644 --- a/app/Livewire/EditApplication.php +++ b/app/Livewire/EditApplication.php @@ -3,7 +3,9 @@ namespace App\Livewire; use App\Models\JobPosting; +use App\Services\GroqCoverLetterService; use Livewire\Component; +use Exception; class EditApplication extends Component { @@ -13,6 +15,8 @@ class EditApplication extends Component public string $company = ''; public ?string $url = null; public string $description = ''; + public string $generatedCoverLetter = ''; + public bool $isGenerating = false; public function mount(JobPosting $jobPosting) { @@ -26,6 +30,41 @@ public function mount(JobPosting $jobPosting) $this->company = $this->jobPosting->company ?? ''; $this->url = $this->jobPosting->source_url ?? ''; $this->description = $this->jobPosting->description ?? ''; + $this->generatedCoverLetter = $this->jobPosting->cover_letter ?? ''; + } + + public function generateCoverLetter(GroqCoverLetterService $service) + { + $this->isGenerating = true; + + try { + + $primaryResume = auth()->user()->resumes()->where('is_primary', true)->first(); + + $resumeContent = $primaryResume->content_raw ?? ''; + $jobDescription = $this->description; + + if (empty($resumeContent)) { + throw new Exception('No primary resume found. Please upload or set a primary resume first.'); + } + + if (empty($jobDescription)) { + throw new Exception('Job description is required to generate a cover letter.'); + } + + $this->generatedCoverLetter = $service->generate($resumeContent, $jobDescription); + + $this->jobPosting->update([ + 'cover_letter' => $this->generatedCoverLetter + ]); + + session()->flash('success', 'Cover letter generated successfully.'); + + } catch (Exception $e) { + session()->flash('error', $e->getMessage()); + } finally { + $this->isGenerating = false; + } } public function update() @@ -35,6 +74,7 @@ public function update() 'company' => 'required|string|max:255', 'url' => 'nullable|url|max:255', 'description' => 'required|string', + 'generatedCoverLetter' => 'nullable|string', ]); $this->jobPosting->update([ @@ -42,6 +82,7 @@ public function update() 'company' => $this->company, 'source_url' => $this->url, 'description' => $this->description, + 'cover_letter' => $this->generatedCoverLetter, ]); session()->flash('success', 'Job posting updated successfully.'); diff --git a/app/Models/JobPosting.php b/app/Models/JobPosting.php index f8747b0..15beb12 100644 --- a/app/Models/JobPosting.php +++ b/app/Models/JobPosting.php @@ -18,6 +18,7 @@ class JobPosting extends Model 'description', 'source_url', 'status', + 'cover_letter', ]; protected $casts = [ @@ -33,4 +34,4 @@ public function user(): BelongsTo { return $this->belongsTo(User::class); } -} +} \ No newline at end of file diff --git a/app/Models/User.php b/app/Models/User.php index 33e928e..4cf6cd2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -31,4 +32,20 @@ protected function casts(): array 'password' => 'hashed', ]; } -} + + /** + * Get the job postings associated with the user. + */ + public function jobPostings(): HasMany + { + return $this->hasMany(JobPosting::class); + } + + /** + * Get the resumes associated with the user. + */ + public function resumes(): HasMany + { + return $this->hasMany(Resume::class); + } +} \ No newline at end of file diff --git a/config/services.php b/config/services.php index 9e2a891..2e40adb 100644 --- a/config/services.php +++ b/config/services.php @@ -43,6 +43,7 @@ 'groq' => [ 'api_key' => env('GROQ_API_KEY'), + 'cover_letter_prompt' => env('GROQ_COVER_LETTER_PROMPT', 'You are an expert career coach. Based on the provided resume and job description, extract the top three overlapping skills and generate a concise, professional cover letter. Return the output strictly as a JSON object with a single key "cover_letter" containing the text.'), ], ]; diff --git a/database/migrations/2026_04_07_125131_add_cover_letter_to_applications_table.php b/database/migrations/2026_04_07_125131_add_cover_letter_to_applications_table.php new file mode 100644 index 0000000..36336fb --- /dev/null +++ b/database/migrations/2026_04_07_125131_add_cover_letter_to_applications_table.php @@ -0,0 +1,22 @@ +text('cover_letter')->nullable()->after('status'); + }); + } + + public function down(): void + { + Schema::table('job_postings', function (Blueprint $table) { + $table->dropColumn('cover_letter'); + }); + } +}; \ No newline at end of file diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 46c41aa..b4a82f3 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -135,6 +135,18 @@ class="group flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium Interview Prep + + + + Cover Letters +
diff --git a/resources/views/livewire/cover-letter-generator.blade.php b/resources/views/livewire/cover-letter-generator.blade.php new file mode 100644 index 0000000..b52deba --- /dev/null +++ b/resources/views/livewire/cover-letter-generator.blade.php @@ -0,0 +1,113 @@ +
+
+
+

Cover Letters

+

Draft, edit, and manage cover letters for your applications.

+
+ + @if($selectedJob) + + + + + View Application + + @endif +
+ + @if (session()->has('success')) +
+ + {{ session('success') }} +
+ @endif + @if (session()->has('error')) +
+ + {{ session('error') }} +
+ @endif + +
+
+
+

Target Application

+ +
+
+ +
+ + @if($selectedJob) +
+
+ Company + {{ $selectedJob->company }} +
+
+ Role + {{ $selectedJob->title }} +
+
+ + + @endif +
+
+
+ +
+
+
+
+ Live Editor +
+ +
+ @if($selectedJob && $coverLetterContent) + + + @endif +
+
+ +
+ @if($selectedJob) + + @else +
+
+ +
+

Workspace Ready

+

Select an application from the sidebar to view, edit, or generate its cover letter.

+
+ @endif +
+
+
+
+
\ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 0492872..e4c3d4a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,7 @@ use App\Http\Controllers\ResumeController; use App\Livewire\ApplicationBoard; use App\Livewire\InterviewIndex; +use App\Livewire\CoverLetterGenerator;; use App\Livewire\InterviewPrep; use Illuminate\Support\Facades\Route; @@ -76,6 +77,8 @@ Route::get('/interviews', InterviewIndex::class)->name('interviews.index'); Route::get('/matches/{matchReport}/interview', InterviewPrep::class)->name('interviews.prep'); + + Route::get('/cover-letters', CoverLetterGenerator::class)->name('cover-letters.index'); }); }); From 94e80bef278a1a43b24d8e7afdd37eb3d1169817 Mon Sep 17 00:00:00 2001 From: Ian Macabulos Date: Tue, 7 Apr 2026 23:18:26 +0800 Subject: [PATCH 2/6] init: design the cv --- app/Livewire/CoverLetterEditor.php | 107 +++++++++ app/Livewire/CoverLetterIndex.php | 86 ++++++++ app/Services/GroqCoverLetterService.php | 43 ++++ .../livewire/cover-letter-editor.blade.php | 127 +++++++++++ .../livewire/cover-letter-generator.blade.php | 113 ---------- .../livewire/cover-letter-index.blade.php | 208 ++++++++++++++++++ routes/web.php | 8 +- 7 files changed, 576 insertions(+), 116 deletions(-) create mode 100644 app/Livewire/CoverLetterEditor.php create mode 100644 app/Livewire/CoverLetterIndex.php create mode 100644 app/Services/GroqCoverLetterService.php create mode 100644 resources/views/livewire/cover-letter-editor.blade.php delete mode 100644 resources/views/livewire/cover-letter-generator.blade.php create mode 100644 resources/views/livewire/cover-letter-index.blade.php diff --git a/app/Livewire/CoverLetterEditor.php b/app/Livewire/CoverLetterEditor.php new file mode 100644 index 0000000..2c5daa6 --- /dev/null +++ b/app/Livewire/CoverLetterEditor.php @@ -0,0 +1,107 @@ +user_id !== auth()->id()) { + abort(403); + } + + $this->jobPosting = $jobPosting; + $this->content = $jobPosting->cover_letter ?? ''; + } + + public function generate(GroqCoverLetterService $service) + { + $this->isGenerating = true; + $this->errorMessage = ''; + + try { + if (empty(config('services.groq.api_key'))) { + throw new Exception('GROQ_API_KEY is missing in your configuration.'); + } + + $resumeContent = null; + $resumeSource = ''; + + // Priority: Match Report Resume -> Primary Resume + $latestMatch = $this->jobPosting->matchReports()->latest()->first(); + + if ($latestMatch && $latestMatch->resume && !empty($latestMatch->resume->content_raw)) { + $resumeContent = $latestMatch->resume->content_raw; + $resumeSource = 'Targeted Resume (Match Report)'; + } else { + $primaryResume = auth()->user()->resumes()->where('is_primary', true)->first(); + if ($primaryResume && !empty($primaryResume->content_raw)) { + $resumeContent = $primaryResume->content_raw; + $resumeSource = 'Primary Resume'; + } + } + + if (empty($resumeContent)) { + throw new Exception('No resume found. Please generate a Match Report or set a Primary Resume.'); + } + + if (empty($this->jobPosting->description)) { + throw new Exception('Job description is missing. The AI needs this context.'); + } + + $this->content = $service->generate( + $resumeContent, + "Company: {$this->jobPosting->company}\nJob Title: {$this->jobPosting->title}\nDescription: {$this->jobPosting->description}" + ); + + $this->dispatch('toast', message: "Draft generated using your {$resumeSource}!", type: 'success'); + + } catch (Exception $e) { + Log::error('Cover Letter Gen Error: ' . $e->getMessage()); + $this->errorMessage = $e->getMessage(); + $this->dispatch('toast', message: 'Generation failed.', type: 'error'); + } finally { + $this->isGenerating = false; + } + } + + public function save() + { + $this->validate([ + 'content' => 'required|string', + ]); + + $this->jobPosting->update([ + 'cover_letter' => $this->content + ]); + + $this->dispatch('toast', message: 'Cover letter saved to application.', type: 'success'); + } + + public function executeDelete() + { + $this->jobPosting->update(['cover_letter' => null]); + session()->flash('success', 'Cover letter draft deleted successfully.'); + + // Redirect back to index after deleting the content + return redirect()->route('cover-letters.index'); + } + + public function render() + { + return view('livewire.cover-letter-editor'); + } +} \ No newline at end of file diff --git a/app/Livewire/CoverLetterIndex.php b/app/Livewire/CoverLetterIndex.php new file mode 100644 index 0000000..a19c707 --- /dev/null +++ b/app/Livewire/CoverLetterIndex.php @@ -0,0 +1,86 @@ +resetPage(); + } + + public function updatingStatus() + { + $this->resetPage(); + } + + public function resetFilters() + { + $this->reset(['search', 'status']); + $this->resetPage(); + } + + public function confirmDelete($id) + { + $this->coverLetterToDelete = $id; + } + + public function executeDelete() + { + if ($this->coverLetterToDelete) { + $job = auth()->user()->jobPostings()->find($this->coverLetterToDelete); + + if ($job) { + $job->update(['cover_letter' => null]); + $this->dispatch('toast', message: 'Cover letter draft deleted successfully.', type: 'success'); + } + + $this->coverLetterToDelete = null; + } + } + + public function cancelDelete() + { + $this->coverLetterToDelete = null; + } + + public function render() + { + $query = auth()->user()->jobPostings(); + + if (!empty($this->search)) { + $query->where(function ($q) { + $q->where('title', 'like', '%' . $this->search . '%') + ->orWhere('company', 'like', '%' . $this->search . '%'); + }); + } + + if ($this->status === 'drafted') { + $query->whereNotNull('cover_letter'); + } elseif ($this->status === 'pending') { + $query->whereNull('cover_letter'); + } + + $jobs = $query->latest()->paginate(12); + + return view('livewire.cover-letter-index', [ + 'jobs' => $jobs, + 'totalJobs' => auth()->user()->jobPostings()->count(), + 'draftedCount' => auth()->user()->jobPostings()->whereNotNull('cover_letter')->count(), + 'pendingCount' => auth()->user()->jobPostings()->whereNull('cover_letter')->count(), + 'hasAnyJobs' => auth()->user()->jobPostings()->exists(), + ]); + } +} \ No newline at end of file diff --git a/app/Services/GroqCoverLetterService.php b/app/Services/GroqCoverLetterService.php new file mode 100644 index 0000000..31a9bce --- /dev/null +++ b/app/Services/GroqCoverLetterService.php @@ -0,0 +1,43 @@ +timeout(60) + ->post('https://api.groq.com/openai/v1/chat/completions', [ + 'model' => 'llama-3.3-70b-versatile', + 'messages' => [ + ['role' => 'system', 'content' => $prompt], + ['role' => 'user', 'content' => "Resume: \n" . $resumeContent . "\n\nJob Context: \n" . $jobContext], + ], + 'response_format' => ['type' => 'json_object'], + ]); + + if ($response->failed()) { + Log::error('Groq API Error (Cover Letter): ' . $response->body()); + throw new Exception('Failed to connect to Groq AI. Please try again later.'); + } + + $content = $response->json('choices.0.message.content'); + $decoded = json_decode($content, true); + + return $decoded['cover_letter'] ?? 'Failed to generate content.'; + } +} \ No newline at end of file diff --git a/resources/views/livewire/cover-letter-editor.blade.php b/resources/views/livewire/cover-letter-editor.blade.php new file mode 100644 index 0000000..7d1e4d0 --- /dev/null +++ b/resources/views/livewire/cover-letter-editor.blade.php @@ -0,0 +1,127 @@ +
+ +
+
+ + + Back to Cover Letters + +
+
+ +
+
+

Document Editor

+

Editing cover letter for {{ $jobPosting->title }}

+
+
+
+ +
+ + + View App + + + @if($jobPosting->cover_letter) + + @endif + + +
+
+ +
+ +
+
+
+

AI Generation

+
+ +
+
+ +
+

Target Role

+

{{ $jobPosting->title }}

+

{{ $jobPosting->company }}

+
+ +
+ @if($errorMessage) +
+
+ +
+ Generation Failed + {{ $errorMessage }} +
+
+
+ @endif + +

Let Grit's AI write your first draft using the best available resume context.

+ + +
+
+
+ +
+
+ +
+
+
+ Document Workspace +
+ @if($content) + + @endif +
+ +
+ +
+
+ + Drafting your letter... +
+
+ +
+ +
+
+
+
+
+ + +
\ No newline at end of file diff --git a/resources/views/livewire/cover-letter-generator.blade.php b/resources/views/livewire/cover-letter-generator.blade.php deleted file mode 100644 index b52deba..0000000 --- a/resources/views/livewire/cover-letter-generator.blade.php +++ /dev/null @@ -1,113 +0,0 @@ -
-
-
-

Cover Letters

-

Draft, edit, and manage cover letters for your applications.

-
- - @if($selectedJob) - - - - - View Application - - @endif -
- - @if (session()->has('success')) -
- - {{ session('success') }} -
- @endif - @if (session()->has('error')) -
- - {{ session('error') }} -
- @endif - -
-
-
-

Target Application

- -
-
- -
- - @if($selectedJob) -
-
- Company - {{ $selectedJob->company }} -
-
- Role - {{ $selectedJob->title }} -
-
- - - @endif -
-
-
- -
-
-
-
- Live Editor -
- -
- @if($selectedJob && $coverLetterContent) - - - @endif -
-
- -
- @if($selectedJob) - - @else -
-
- -
-

Workspace Ready

-

Select an application from the sidebar to view, edit, or generate its cover letter.

-
- @endif -
-
-
-
-
\ No newline at end of file diff --git a/resources/views/livewire/cover-letter-index.blade.php b/resources/views/livewire/cover-letter-index.blade.php new file mode 100644 index 0000000..0e0229c --- /dev/null +++ b/resources/views/livewire/cover-letter-index.blade.php @@ -0,0 +1,208 @@ +
+ +
+
+
+
+ +
+

Cover Letters

+
+

+ Manage, generate, and edit tailored cover letters for your applications. +

+
+ + + Browse Applications + +
+ +
+
+
+ +
+
+

Total Targets

+

{{ $totalJobs }}

+
+
+
+
+ +
+
+

Drafted Letters

+

{{ $draftedCount }}

+
+
+
+
+ +
+
+

Pending Generation

+

{{ $pendingCount }}

+
+
+
+ +
+
+
+ +
+ +
+ +
+ + + +
+
+ +
+
+ @for ($i = 0; $i < 6; $i++) +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @endfor +
+ +
+ @if($jobs->count() > 0) +
+ @foreach($jobs as $job) +
+ +
+ @if($job->cover_letter) +
+ Drafted +
+ @else +
+ Pending +
+ @endif + +
+ + {{ $job->created_at->format('M d') }} +
+
+ +
+

+ {{ $job->title }} +

+
+ + {{ $job->company }} +
+
+ +
+ + {{ $job->cover_letter ? 'Edit Letter' : 'Generate Letter' }} + + + + @if($job->cover_letter) +
+ +
+ @endif +
+
+ @endforeach +
+ + @if($jobs->hasPages()) +
+ {{ $jobs->links() }} +
+ @endif + @else + @if(!$hasAnyJobs) +
+
+ +
+

No Cover Letters Yet

+

+ You haven't tracked any job applications yet. Browse applications and save a job posting first to generate a tailored cover letter. +

+ + + Browse Applications + +
+ @else +
+
+ +
+

No matching cover letters

+

+ We couldn't find any applications matching your current search or filters. +

+ +
+ @endif + @endif +
+
+ + +
\ No newline at end of file diff --git a/routes/web.php b/routes/web.php index e4c3d4a..dca618d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,7 +8,8 @@ use App\Http\Controllers\ResumeController; use App\Livewire\ApplicationBoard; use App\Livewire\InterviewIndex; -use App\Livewire\CoverLetterGenerator;; +use App\Livewire\CoverLetterIndex; +use App\Livewire\CoverLetterEditor; use App\Livewire\InterviewPrep; use Illuminate\Support\Facades\Route; @@ -78,8 +79,9 @@ Route::get('/interviews', InterviewIndex::class)->name('interviews.index'); Route::get('/matches/{matchReport}/interview', InterviewPrep::class)->name('interviews.prep'); - Route::get('/cover-letters', CoverLetterGenerator::class)->name('cover-letters.index'); + Route::get('/cover-letters', CoverLetterIndex::class)->name('cover-letters.index'); + Route::get('/cover-letters/{jobPosting}', CoverLetterEditor::class)->name('cover-letters.edit'); }); }); -require __DIR__.'/auth.php'; +require __DIR__ . '/auth.php'; From 673afec9b95b38ae8df604534ec031624271322a Mon Sep 17 00:00:00 2001 From: Ian Macabulos Date: Wed, 8 Apr 2026 00:18:39 +0800 Subject: [PATCH 3/6] fix: fix ai feature for CV --- app/Livewire/CoverLetterEditor.php | 50 +++-- app/Services/GroqCoverLetterService.php | 3 +- resources/views/components/toast.blade.php | 30 +-- .../livewire/cover-letter-editor.blade.php | 201 +++++++++++------- 4 files changed, 174 insertions(+), 110 deletions(-) diff --git a/app/Livewire/CoverLetterEditor.php b/app/Livewire/CoverLetterEditor.php index 2c5daa6..8453672 100644 --- a/app/Livewire/CoverLetterEditor.php +++ b/app/Livewire/CoverLetterEditor.php @@ -7,15 +7,17 @@ use Livewire\Component; use Exception; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Title; -#[Title('Edit Cover Letter')] +#[Title('Cover Letter Workspace')] class CoverLetterEditor extends Component { public JobPosting $jobPosting; public string $content = ''; public bool $isGenerating = false; public string $errorMessage = ''; + public bool $showDeleteModal = false; public function mount(JobPosting $jobPosting) { @@ -24,11 +26,33 @@ public function mount(JobPosting $jobPosting) } $this->jobPosting = $jobPosting; - $this->content = $jobPosting->cover_letter ?? ''; + $this->content = $this->formatLetterText($jobPosting->cover_letter ?? ''); + } + + private function formatLetterText(string $text): string + { + if (empty($text)) return ''; + + $text = trim(str_replace(['**', '##', '#', '*'], '', $text)); + + if (strpos($text, "\n\n") === false && strpos($text, "\n") !== false) { + $text = str_replace("\n", "\n\n", $text); + } + + return $text; } public function generate(GroqCoverLetterService $service) { + $rateLimitKey = 'generate-cover-letter:' . auth()->id() . ':' . $this->jobPosting->id; + + if (RateLimiter::tooManyAttempts($rateLimitKey, 3)) { + $seconds = RateLimiter::availableIn($rateLimitKey); + $this->errorMessage = "Please wait {$seconds} seconds before regenerating."; + $this->dispatch('notify', message: 'Rate limit exceeded.', type: 'error'); + return; + } + $this->isGenerating = true; $this->errorMessage = ''; @@ -40,12 +64,11 @@ public function generate(GroqCoverLetterService $service) $resumeContent = null; $resumeSource = ''; - // Priority: Match Report Resume -> Primary Resume $latestMatch = $this->jobPosting->matchReports()->latest()->first(); if ($latestMatch && $latestMatch->resume && !empty($latestMatch->resume->content_raw)) { $resumeContent = $latestMatch->resume->content_raw; - $resumeSource = 'Targeted Resume (Match Report)'; + $resumeSource = 'Targeted Resume'; } else { $primaryResume = auth()->user()->resumes()->where('is_primary', true)->first(); if ($primaryResume && !empty($primaryResume->content_raw)) { @@ -62,17 +85,19 @@ public function generate(GroqCoverLetterService $service) throw new Exception('Job description is missing. The AI needs this context.'); } - $this->content = $service->generate( - $resumeContent, - "Company: {$this->jobPosting->company}\nJob Title: {$this->jobPosting->title}\nDescription: {$this->jobPosting->description}" - ); + $strictContext = "Company: {$this->jobPosting->company}\nJob Title: {$this->jobPosting->title}\nDescription: {$this->jobPosting->description}\n\nCRITICAL AI INSTRUCTION: Output PLAIN TEXT ONLY. You MUST use double newlines (\\n\\n) between paragraphs to format it as a business letter."; + + $generatedText = $service->generate($resumeContent, $strictContext); + $this->content = $this->formatLetterText($generatedText); - $this->dispatch('toast', message: "Draft generated using your {$resumeSource}!", type: 'success'); + // Successfully notify the user asynchronously + $this->dispatch('notify', message: "Draft generated using {$resumeSource}!", type: 'success'); + RateLimiter::hit($rateLimitKey, 60); } catch (Exception $e) { Log::error('Cover Letter Gen Error: ' . $e->getMessage()); $this->errorMessage = $e->getMessage(); - $this->dispatch('toast', message: 'Generation failed.', type: 'error'); + $this->dispatch('notify', message: 'Generation failed.', type: 'error'); } finally { $this->isGenerating = false; } @@ -88,15 +113,14 @@ public function save() 'cover_letter' => $this->content ]); - $this->dispatch('toast', message: 'Cover letter saved to application.', type: 'success'); + // Triggers the application's global toast listener without refreshing + $this->dispatch('notify', message: 'Cover letter saved successfully.', type: 'success'); } public function executeDelete() { $this->jobPosting->update(['cover_letter' => null]); session()->flash('success', 'Cover letter draft deleted successfully.'); - - // Redirect back to index after deleting the content return redirect()->route('cover-letters.index'); } diff --git a/app/Services/GroqCoverLetterService.php b/app/Services/GroqCoverLetterService.php index 31a9bce..0e775bd 100644 --- a/app/Services/GroqCoverLetterService.php +++ b/app/Services/GroqCoverLetterService.php @@ -17,7 +17,8 @@ public function generate(string $resumeContent, string $jobContext): string throw new Exception('Groq API key is missing. Please check your configuration.'); } - $prompt = config('services.groq.cover_letter_prompt', 'You are an expert career coach. Based on the provided resume and job context, write a concise, professional cover letter. Return the output strictly as a JSON object with a single key "cover_letter" containing the text.'); + // Enforce strict paragraph formatting in the prompt + $prompt = config('services.groq.cover_letter_prompt', 'You are an expert career coach. Based on the provided resume and job context, write a highly professional cover letter. IMPORTANT: Output ONLY plain text. NO Markdown. You MUST structure the letter with clear paragraphs separated by double line breaks (\n\n). Return the output strictly as a JSON object with a single key "cover_letter" containing the text.'); $response = Http::withToken($apiKey) ->timeout(60) diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php index ba20800..830d387 100644 --- a/resources/views/components/toast.blade.php +++ b/resources/views/components/toast.blade.php @@ -17,8 +17,13 @@ setTimeout(() => show = false, 5000); } " @notify.window=" - message = $event.detail.message; - isError = $event.detail.isError ?? false; + // Livewire 3 sends objects in an array, Alpine sends direct objects. This handles both. + let detail = $event.detail; + let payload = Array.isArray(detail) ? detail[0] : detail; + + message = payload.message ?? payload; + isError = payload.isError ?? false; + show = true; setTimeout(() => show = false, 5000); " x-show="show" x-cloak x-transition:enter="transition ease-out duration-300" @@ -26,31 +31,28 @@ x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100" x-transition:leave-end="opacity-0 translate-y-8 sm:translate-y-0 sm:scale-95" - class="fixed bottom-6 right-6 z-[100] flex items-center p-4 text-gray-800 bg-white rounded-xl shadow-[0_8px_30px_rgb(0,0,0,0.08)] border border-gray-100" + class="fixed bottom-6 right-6 z-[100] flex items-center p-4 text-gray-800 bg-white rounded-xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] border border-gray-200/80" role="alert" style="display: none;"> -
-
+
-
-
+
-
+
\ No newline at end of file diff --git a/resources/views/livewire/cover-letter-editor.blade.php b/resources/views/livewire/cover-letter-editor.blade.php index 7d1e4d0..d3419f0 100644 --- a/resources/views/livewire/cover-letter-editor.blade.php +++ b/resources/views/livewire/cover-letter-editor.blade.php @@ -1,127 +1,164 @@ -
+
-
-
- - - Back to Cover Letters - -
-
- -
-
-

Document Editor

-

Editing cover letter for {{ $jobPosting->title }}

-
+ + + Back to Cover Letters + + +
+
+ +
+

Cover Letter Editor

+

+ Target: {{ $jobPosting->title }} at {{ $jobPosting->company }} +

- -
- + +
+ - View App + View Job - - @if($jobPosting->cover_letter) - - @endif - -
-
+
-
-
-
-

AI Generation

-
- +
+ +
+

Application Context

+
+
+

Target Role

+

{{ $jobPosting->title }}

+
+
+

Company

+

{{ $jobPosting->company }}

-
- -
-

Target Role

-

{{ $jobPosting->title }}

-

{{ $jobPosting->company }}

-
+ @if($jobPosting->cover_letter) +
+ +
+ @endif +
+ +
+
+
+
+ +

Grit Co-Pilot

+
+
+

+ Instantly write a highly targeted cover letter using the job description and your best matching resume. +

+ @if($errorMessage) -
-
- -
- Generation Failed - {{ $errorMessage }} -
+
+
+ + {{ $errorMessage }}
@endif -

Let Grit's AI write your first draft using the best available resume context.

- -
-
-
+
+
-
+
- Document Workspace + Document Text
@if($content) - @endif
- -
+ +
-
-
- - Drafting your letter... +
+
+ + Drafting Document...
-
-
- +
+
+
+ +
+
+
+
+
+
+ +
- +
\ No newline at end of file From 0bc48e7d628e7d974930760ef087a3cd2c7f73e0 Mon Sep 17 00:00:00 2001 From: Ian Macabulos Date: Wed, 8 Apr 2026 10:29:12 +0800 Subject: [PATCH 4/6] feature: ai cv generator implemented --- app/Livewire/CoverLetterEditor.php | 62 ++++++++------ resources/views/components/toast.blade.php | 36 ++++---- .../livewire/cover-letter-editor.blade.php | 83 +++++++++++-------- 3 files changed, 106 insertions(+), 75 deletions(-) diff --git a/app/Livewire/CoverLetterEditor.php b/app/Livewire/CoverLetterEditor.php index 8453672..42f2360 100644 --- a/app/Livewire/CoverLetterEditor.php +++ b/app/Livewire/CoverLetterEditor.php @@ -19,6 +19,10 @@ class CoverLetterEditor extends Component public string $errorMessage = ''; public bool $showDeleteModal = false; + // SaaS Configuration + public int $dailyLimit = 5; // Reduced to 5 for Free Tier + private int $burstLimit = 2; // Max generations per minute per user + public function mount(JobPosting $jobPosting) { if ($jobPosting->user_id !== auth()->id()) { @@ -32,24 +36,36 @@ public function mount(JobPosting $jobPosting) private function formatLetterText(string $text): string { if (empty($text)) return ''; - $text = trim(str_replace(['**', '##', '#', '*'], '', $text)); - if (strpos($text, "\n\n") === false && strpos($text, "\n") !== false) { $text = str_replace("\n", "\n\n", $text); } - return $text; } + public function getCreditsRemainingProperty(): int + { + $dailyKey = 'cv-gen-daily:' . auth()->id(); + $attempts = RateLimiter::attempts($dailyKey); + return max(0, $this->dailyLimit - $attempts); + } + public function generate(GroqCoverLetterService $service) { - $rateLimitKey = 'generate-cover-letter:' . auth()->id() . ':' . $this->jobPosting->id; + $userId = auth()->id(); + $minuteKey = 'cv-gen-min:' . $userId; + $dailyKey = 'cv-gen-daily:' . $userId; - if (RateLimiter::tooManyAttempts($rateLimitKey, 3)) { - $seconds = RateLimiter::availableIn($rateLimitKey); - $this->errorMessage = "Please wait {$seconds} seconds before regenerating."; - $this->dispatch('notify', message: 'Rate limit exceeded.', type: 'error'); + if (RateLimiter::tooManyAttempts($dailyKey, $this->dailyLimit)) { + $this->errorMessage = "You have exhausted your {$this->dailyLimit} daily AI generations. Please try again tomorrow."; + $this->dispatch('toast', message: 'Daily limit reached.', type: 'error'); + return; + } + + if (RateLimiter::tooManyAttempts($minuteKey, $this->burstLimit)) { + $seconds = RateLimiter::availableIn($minuteKey); + $this->errorMessage = "System cooling down. Please wait {$seconds} seconds to ensure high-quality generation."; + $this->dispatch('toast', message: 'Generating too fast.', type: 'error'); return; } @@ -68,7 +84,7 @@ public function generate(GroqCoverLetterService $service) if ($latestMatch && $latestMatch->resume && !empty($latestMatch->resume->content_raw)) { $resumeContent = $latestMatch->resume->content_raw; - $resumeSource = 'Targeted Resume'; + $resumeSource = 'Targeted Match Resume'; } else { $primaryResume = auth()->user()->resumes()->where('is_primary', true)->first(); if ($primaryResume && !empty($primaryResume->content_raw)) { @@ -78,11 +94,11 @@ public function generate(GroqCoverLetterService $service) } if (empty($resumeContent)) { - throw new Exception('No resume found. Please generate a Match Report or set a Primary Resume.'); + throw new Exception('No resume found. Please set a Primary Resume.'); } if (empty($this->jobPosting->description)) { - throw new Exception('Job description is missing. The AI needs this context.'); + throw new Exception('Job description is missing. AI needs context.'); } $strictContext = "Company: {$this->jobPosting->company}\nJob Title: {$this->jobPosting->title}\nDescription: {$this->jobPosting->description}\n\nCRITICAL AI INSTRUCTION: Output PLAIN TEXT ONLY. You MUST use double newlines (\\n\\n) between paragraphs to format it as a business letter."; @@ -90,14 +106,15 @@ public function generate(GroqCoverLetterService $service) $generatedText = $service->generate($resumeContent, $strictContext); $this->content = $this->formatLetterText($generatedText); - // Successfully notify the user asynchronously - $this->dispatch('notify', message: "Draft generated using {$resumeSource}!", type: 'success'); - RateLimiter::hit($rateLimitKey, 60); + RateLimiter::hit($minuteKey, 60); + RateLimiter::hit($dailyKey, 86400); + + $this->dispatch('toast', message: "Draft generated using {$resumeSource}!", type: 'success'); } catch (Exception $e) { Log::error('Cover Letter Gen Error: ' . $e->getMessage()); $this->errorMessage = $e->getMessage(); - $this->dispatch('notify', message: 'Generation failed.', type: 'error'); + $this->dispatch('toast', message: 'Generation failed.', type: 'error'); } finally { $this->isGenerating = false; } @@ -105,22 +122,15 @@ public function generate(GroqCoverLetterService $service) public function save() { - $this->validate([ - 'content' => 'required|string', - ]); - - $this->jobPosting->update([ - 'cover_letter' => $this->content - ]); - - // Triggers the application's global toast listener without refreshing - $this->dispatch('notify', message: 'Cover letter saved successfully.', type: 'success'); + $this->validate(['content' => 'required|string']); + $this->jobPosting->update(['cover_letter' => $this->content]); + $this->dispatch('toast', message: 'Cover letter saved securely!', type: 'success'); } public function executeDelete() { $this->jobPosting->update(['cover_letter' => null]); - session()->flash('success', 'Cover letter draft deleted successfully.'); + session()->flash('success', 'Cover letter draft discarded.'); return redirect()->route('cover-letters.index'); } diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php index 830d387..6c2f84d 100644 --- a/resources/views/components/toast.blade.php +++ b/resources/views/components/toast.blade.php @@ -1,6 +1,6 @@ @php $initialMessage = session('success') ?? session('status') ?? session('error') ?? ''; - $isError = session('error') || $errors->any(); + $initialType = session('error') || $errors->any() ? 'error' : 'success'; if (!$initialMessage && $errors->any()) { $initialMessage = $errors->first(); @@ -10,37 +10,41 @@
show = false, 5000); + " + x-show="show" x-cloak + x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-8 sm:translate-y-0 sm:scale-95" - x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:leave="transition ease-in duration-200" + x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" + x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100" x-transition:leave-end="opacity-0 translate-y-8 sm:translate-y-0 sm:scale-95" class="fixed bottom-6 right-6 z-[100] flex items-center p-4 text-gray-800 bg-white rounded-xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] border border-gray-200/80" role="alert" style="display: none;"> -
+
-
+
diff --git a/resources/views/livewire/cover-letter-editor.blade.php b/resources/views/livewire/cover-letter-editor.blade.php index d3419f0..367b928 100644 --- a/resources/views/livewire/cover-letter-editor.blade.php +++ b/resources/views/livewire/cover-letter-editor.blade.php @@ -24,15 +24,11 @@ View Job -
@@ -85,50 +81,71 @@
@endif - + +
+ Daily Limit + + {{ $this->creditsRemaining }} / {{ $this->dailyLimit }} remaining + +
-
+
-
-
+
Document Text
@if($content) @endif
-
+
-
+
Drafting Document... @@ -149,7 +166,7 @@ class="inline-flex items-center gap-1.5 text-xs font-bold transition-all px-3 py From c31529f7f24b0f2f62202341cda86cde5018c102 Mon Sep 17 00:00:00 2001 From: Ian Macabulos Date: Wed, 8 Apr 2026 11:34:19 +0800 Subject: [PATCH 5/6] implement auto feature --- app/Livewire/CoverLetterEditor.php | 10 +- app/Livewire/CoverLetterIndex.php | 48 ++-- app/Livewire/CreateApplication.php | 31 ++- app/Services/GroqFormatterService.php | 43 ++++ app/Support/Csp/CustomPolicy.php | 5 +- config/services.php | 4 +- resources/css/app.css | 22 ++ resources/views/layouts/app.blade.php | 44 ++-- .../livewire/cover-letter-index.blade.php | 230 ++++++++++-------- .../livewire/create-application.blade.php | 36 ++- 10 files changed, 310 insertions(+), 163 deletions(-) create mode 100644 app/Services/GroqFormatterService.php diff --git a/app/Livewire/CoverLetterEditor.php b/app/Livewire/CoverLetterEditor.php index 42f2360..ed92afb 100644 --- a/app/Livewire/CoverLetterEditor.php +++ b/app/Livewire/CoverLetterEditor.php @@ -19,9 +19,7 @@ class CoverLetterEditor extends Component public string $errorMessage = ''; public bool $showDeleteModal = false; - // SaaS Configuration - public int $dailyLimit = 5; // Reduced to 5 for Free Tier - private int $burstLimit = 2; // Max generations per minute per user + private int $burstLimit = 2; public function mount(JobPosting $jobPosting) { @@ -43,6 +41,12 @@ private function formatLetterText(string $text): string return $text; } + // This exposes $this->dailyLimit to your Blade view dynamically + public function getDailyLimitProperty(): int + { + return config('services.groq.daily_limit', 5); + } + public function getCreditsRemainingProperty(): int { $dailyKey = 'cv-gen-daily:' . auth()->id(); diff --git a/app/Livewire/CoverLetterIndex.php b/app/Livewire/CoverLetterIndex.php index a19c707..453dd7e 100644 --- a/app/Livewire/CoverLetterIndex.php +++ b/app/Livewire/CoverLetterIndex.php @@ -3,6 +3,7 @@ namespace App\Livewire; use App\Models\JobPosting; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Component; use Livewire\WithPagination; use Livewire\Attributes\Title; @@ -13,22 +14,17 @@ class CoverLetterIndex extends Component use WithPagination; public string $search = ''; - public string $status = ''; public $coverLetterToDelete = null; + public $selectedJobForNewLetter = ''; public function updatingSearch() { $this->resetPage(); } - public function updatingStatus() - { - $this->resetPage(); - } - public function resetFilters() { - $this->reset(['search', 'status']); + $this->reset('search'); $this->resetPage(); } @@ -56,9 +52,26 @@ public function cancelDelete() $this->coverLetterToDelete = null; } + public function startWorkspace() + { + $this->validate([ + 'selectedJobForNewLetter' => 'required|exists:job_postings,id' + ]); + + return redirect()->route('cover-letters.edit', $this->selectedJobForNewLetter); + } + + public function getCreditsRemainingProperty(): int + { + $dailyLimit = config('services.groq.daily_limit', 5); + $dailyKey = 'cv-gen-daily:' . auth()->id(); + $attempts = RateLimiter::attempts($dailyKey); + return max(0, $dailyLimit - $attempts); + } + public function render() { - $query = auth()->user()->jobPostings(); + $query = auth()->user()->jobPostings()->whereNotNull('cover_letter'); if (!empty($this->search)) { $query->where(function ($q) { @@ -67,20 +80,19 @@ public function render() }); } - if ($this->status === 'drafted') { - $query->whereNotNull('cover_letter'); - } elseif ($this->status === 'pending') { - $query->whereNull('cover_letter'); - } - $jobs = $query->latest()->paginate(12); + + $availableJobsForCreation = auth()->user()->jobPostings() + ->whereNull('cover_letter') + ->latest() + ->get(); return view('livewire.cover-letter-index', [ 'jobs' => $jobs, - 'totalJobs' => auth()->user()->jobPostings()->count(), - 'draftedCount' => auth()->user()->jobPostings()->whereNotNull('cover_letter')->count(), - 'pendingCount' => auth()->user()->jobPostings()->whereNull('cover_letter')->count(), - 'hasAnyJobs' => auth()->user()->jobPostings()->exists(), + 'totalDrafts' => auth()->user()->jobPostings()->whereNotNull('cover_letter')->count(), + 'availableJobs' => $availableJobsForCreation, + 'creditsRemaining' => $this->creditsRemaining, + 'dailyLimit' => config('services.groq.daily_limit', 5), ]); } } \ No newline at end of file diff --git a/app/Livewire/CreateApplication.php b/app/Livewire/CreateApplication.php index 5af0dd0..eee521b 100644 --- a/app/Livewire/CreateApplication.php +++ b/app/Livewire/CreateApplication.php @@ -4,6 +4,9 @@ use App\Enums\ApplicationStatus; use App\Models\JobPosting; +use App\Services\GroqFormatterService; +use Exception; +use Illuminate\Support\Facades\Log; use Livewire\Attributes\Validate; use Livewire\Component; @@ -21,6 +24,32 @@ class CreateApplication extends Component #[Validate('required|string')] public $description = ''; + public bool $isFormatting = false; + + public function autoFormatDescription(GroqFormatterService $formatter) + { + if (empty(trim(strip_tags($this->description)))) { + $this->dispatch('toast', message: 'Please paste a description first before formatting.', type: 'error'); + return; + } + + $this->isFormatting = true; + + try { + $cleanHtml = $formatter->formatJobDescription($this->description); + $this->description = $cleanHtml; + + $this->dispatch('description-formatted', html: $cleanHtml); + $this->dispatch('toast', message: 'Description formatted successfully.', type: 'success'); + + } catch (Exception $e) { + Log::error('Description Formatting Error: ' . $e->getMessage()); + $this->dispatch('toast', message: 'Failed to format description.', type: 'error'); + } finally { + $this->isFormatting = false; + } + } + public function save() { $this->validate(); @@ -43,4 +72,4 @@ public function render() { return view('livewire.create-application'); } -} +} \ No newline at end of file diff --git a/app/Services/GroqFormatterService.php b/app/Services/GroqFormatterService.php new file mode 100644 index 0000000..1baf427 --- /dev/null +++ b/app/Services/GroqFormatterService.php @@ -0,0 +1,43 @@ +,
    ,
  • , , and
    tags. " + . "Ensure headings are bolded. Ensure lists are properly formatted using
      and
    • . " + . "Correct any obvious spacing or line-break issues. " + . "CRITICAL INSTRUCTION: You must return ONLY the raw HTML string. Do not use markdown blocks, do not wrap the output in ```html, and do not include any conversational text like 'Here is the formatted text'."; + + $response = Http::withToken($apiKey) + ->timeout(30) + ->post('[https://api.groq.com/openai/v1/chat/completions](https://api.groq.com/openai/v1/chat/completions)', [ + 'model' => 'llama-3.1-8b-instant', + 'messages' => [ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $rawText], + ], + 'temperature' => 0.1, + ]); + + if ($response->failed()) { + throw new Exception('Failed to format the text via Groq API. ' . $response->body()); + } + + $result = $response->json('choices.0.message.content'); + + return trim(str_replace(['```html', '```'], '', $result)); + } +} \ No newline at end of file diff --git a/app/Support/Csp/CustomPolicy.php b/app/Support/Csp/CustomPolicy.php index d30e9ab..51d0983 100644 --- a/app/Support/Csp/CustomPolicy.php +++ b/app/Support/Csp/CustomPolicy.php @@ -36,7 +36,10 @@ public function configure(Policy $policy): void Keyword::UNSAFE_INLINE, Keyword::UNSAFE_EVAL, 'unpkg.com', // Added for Trix Editor JS - // 'cdn.jsdelivr.net', // Uncomment if your chart uses jsdelivr + ]) + ->add(Directive::CONNECT, [ + Keyword::SELF, + 'unpkg.com', // Added for Trix Editor source maps ]) ->add(Directive::FRAME, [ Keyword::SELF, diff --git a/config/services.php b/config/services.php index 2e40adb..bc2531f 100644 --- a/config/services.php +++ b/config/services.php @@ -43,7 +43,7 @@ 'groq' => [ 'api_key' => env('GROQ_API_KEY'), - 'cover_letter_prompt' => env('GROQ_COVER_LETTER_PROMPT', 'You are an expert career coach. Based on the provided resume and job description, extract the top three overlapping skills and generate a concise, professional cover letter. Return the output strictly as a JSON object with a single key "cover_letter" containing the text.'), + 'daily_limit' => env('GROQ_DAILY_LIMIT', 5), ], -]; +]; \ No newline at end of file diff --git a/resources/css/app.css b/resources/css/app.css index 8a4e70d..70e66ff 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -51,4 +51,26 @@ .custom-pagination nav a:hover svg { color: #e26a35 !important; +} + +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: #cbd5e1 transparent; +} + +.custom-scrollbar::-webkit-scrollbar { + width: 4px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background-color: #cbd5e1; + border-radius: 10px; +} + +.custom-scrollbar:hover::-webkit-scrollbar-thumb { + background-color: #94a3b8; } \ No newline at end of file diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index b4a82f3..e70fd87 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -68,11 +68,11 @@ class="lg:hidden p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded
-
-
+
- +
-

Total Targets

-

{{ $totalJobs }}

+

Active Drafts

+

{{ $totalDrafts }}

+
-
- +
+
-

Drafted Letters

-

{{ $draftedCount }}

+

Available Jobs

+

{{ count($availableJobs) }}

+
-
- +
+
-

Pending Generation

-

{{ $pendingCount }}

+

Daily AI Credits

+
+

{{ $creditsRemaining }}

+

/ {{ $dailyLimit }}

+
-
+
-
- -
- - - -
-
@for ($i = 0; $i < 6; $i++)
-
-
-
-
+
-
-
-
-
-
-
@endfor
-
+
@if($jobs->count() > 0)
@foreach($jobs as $job) @@ -113,19 +89,12 @@ class="absolute inset-0 z-10 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 bg- class="bg-white rounded-2xl border border-gray-200 shadow-sm hover:shadow-md hover:border-[#e26a35]/40 hover:-translate-y-1 transition-all duration-300 flex flex-col p-6 group h-full">
- @if($job->cover_letter) -
- Drafted -
- @else -
- Pending -
- @endif - +
+ Draft Saved +
- {{ $job->created_at->format('M d') }} + {{ $job->updated_at->format('M d') }}
@@ -142,20 +111,15 @@ class="bg-white rounded-2xl border border-gray-200 shadow-sm hover:shadow-md hov
@endforeach @@ -167,41 +131,95 @@ class="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transit
@endif @else - @if(!$hasAnyJobs) -
-
- -
-

No Cover Letters Yet

-

- You haven't tracked any job applications yet. Browse applications and save a job posting first to generate a tailored cover letter. -

- - - Browse Applications - -
- @else -
-
- -
-

No matching cover letters

-

- We couldn't find any applications matching your current search or filters. -

- +
+
+
- @endif +

No Cover Letters Yet

+

+ You have not generated any cover letters yet. Click the button above to select a job application and start drafting. +

+ +
@endif
+ +
+
+
+ +
+

New Cover Letter

+

Select a target application to generate a tailored letter using your primary resume.

+
+ +
+
+ + +
+ + +
+ + + + @foreach($availableJobs as $availableJob) + + @endforeach + + @if(count($availableJobs) === 0) +
No applications available.
+ @endif +
+
+ + @error('selectedJobForNewLetter') {{ $message }} @enderror +
+ +
+ + +
+
+
+
+ diff --git a/resources/views/livewire/create-application.blade.php b/resources/views/livewire/create-application.blade.php index 1031a0f..2d9a9b2 100644 --- a/resources/views/livewire/create-application.blade.php +++ b/resources/views/livewire/create-application.blade.php @@ -28,26 +28,46 @@ class="block w-full rounded-xl border-gray-200 bg-gray-50 py-3.5 px-4 text-sm fo @error('url')

{{ $message }}

@enderror
-
- +
+
+ + + +
+
- + class="trix-content border-none outline-none min-h-[200px]">
@error('description')

{{ $message }}

@enderror
-