feat: implementasi fitur pesan dan perbaikan logika cart

This commit is contained in:
sayasilvi 2025-12-08 11:30:41 +07:00
parent db57919bd2
commit 046365a1d6
14 changed files with 478 additions and 17 deletions

View File

@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Pesan;
use App\Models\Petani;
use App\Models\Pembeli;
use Illuminate\Support\Facades\Auth;
class PesanController extends Controller
{
// Menampilkan Daftar Percakapan (Inbox Utama)
public function index()
{
$user = $this->getAuthenticatedUser();
$isPetani = Auth::guard('petani')->check();
$allMessages = Pesan::where(function ($q) use ($user) {
$q->where('pengirim_id', $user->id)->where('pengirim_type', get_class($user));
})->orWhere(function ($q) use ($user) {
$q->where('penerima_id', $user->id)->where('penerima_type', get_class($user));
})->orderBy('created_at', 'desc')->get();
// Kelompokkan berdasarkan ID Lawan Bicara
$conversations = $allMessages->groupBy(function ($pesan) use ($user) {
return $pesan->pengirim_id == $user->id
? $pesan->penerima_type . '_' . $pesan->penerima_id
: $pesan->pengirim_type . '_' . $pesan->pengirim_id;
});
// Format data untuk view
$chatList = $conversations->map(function ($msgs) use ($user) {
$lastMsg = $msgs->first();
// Tentukan siapa lawan bicaranya
if ($lastMsg->pengirim_id == $user->id) {
$lawan = $lastMsg->penerima;
} else {
$lawan = $lastMsg->pengirim;
}
return [
'lawan_id' => $lawan->id ?? 0,
'lawan_type' => get_class($lawan ?? new \stdClass),
'nama' => $lawan->nama_lengkap ?? 'User Terhapus',
'foto' => $lawan->foto ?? null,
'last_message' => $lastMsg->isi_pesan,
'time' => $lastMsg->created_at->diffForHumans(),
'unread' => $msgs->where('penerima_id', $user->id)->where('sudah_dibaca', false)->count()
];
});
$view = $isPetani ? 'petani.pesan.index' : 'landing.pesan.index';
return view($view, compact('chatList'));
}
// Menampilkan Detail Chat
public function show($id)
{
$user = $this->getAuthenticatedUser();
$isPetani = Auth::guard('petani')->check();
// Tentukan model lawan bicara
$lawanType = $isPetani ? Pembeli::class : Petani::class;
$lawan = $lawanType::findOrFail($id);
// Ambil percakapan antara User Login & Lawan Bicara
$chats = Pesan::where(function ($q) use ($user, $lawan, $lawanType) {
$q->where('pengirim_id', $user->id)->where('pengirim_type', get_class($user))
->where('penerima_id', $lawan->id)->where('penerima_type', $lawanType);
})->orWhere(function ($q) use ($user, $lawan, $lawanType) {
$q->where('pengirim_id', $lawan->id)->where('pengirim_type', $lawanType)
->where('penerima_id', $user->id)->where('penerima_type', get_class($user));
})->orderBy('created_at', 'asc')->get();
// Tandai pesan masuk sebagai "Sudah Dibaca"
Pesan::where('pengirim_id', $lawan->id)->where('pengirim_type', $lawanType)
->where('penerima_id', $user->id)->update(['sudah_dibaca' => true]);
$view = $isPetani ? 'petani.pesan.show' : 'landing.pesan.show';
return view($view, compact('chats', 'lawan'));
}
// Proses Kirim Pesan
public function store(Request $request)
{
$request->validate(['isi_pesan' => 'required']);
$user = $this->getAuthenticatedUser();
$isPetani = Auth::guard('petani')->check();
// Menentukan tipe penerima
$penerimaType = $isPetani ? 'App\Models\Pembeli' : 'App\Models\Petani';
Pesan::create([
'pengirim_id' => $user->id,
'pengirim_type' => get_class($user),
'penerima_id' => $request->penerima_id,
'penerima_type' => $penerimaType,
'isi_pesan' => $request->isi_pesan,
'sudah_dibaca' => false,
]);
return back();
}
private function getAuthenticatedUser()
{
if (Auth::guard('petani')->check()) return Auth::guard('petani')->user();
if (Auth::guard('pembeli')->check()) return Auth::guard('pembeli')->user();
abort(403);
}
}

View File

@ -113,7 +113,7 @@ public function prosesCheckout(Request $request)
$subtotal_transaksi = 0;
$kode_invoice = 'INV/' . date('Ymd') . '/' . rand(1000, 9999);
// Buat Header Transaksi dulu
// Membuat Header Transaksi
$transaksi = Transaksi::create([
'pembeli_id' => $pembeli_id,
'tanggal_transaksi' => now(),
@ -144,7 +144,6 @@ public function prosesCheckout(Request $request)
$transaksi->update(['total_harga' => $subtotal_transaksi]);
}
// Hapus Keranjang setelah sukses
session()->forget('cart');
}
});
@ -223,7 +222,7 @@ public function pesananDetail($id)
{
$petaniId = Auth::guard('petani')->id();
// Ambil transaksi berdasarkan ID, pastikan transaksi tersebut memiliki produk milik petani ini
// Ambil transaksi berdasarkan ID
$pesanan = Transaksi::whereHas('details.produk', function ($q) use ($petaniId) {
$q->where('petani_id', $petaniId);
})

View File

@ -27,4 +27,14 @@ public function transaksis()
{
return $this->hasMany(Transaksi::class, 'pembeli_id');
}
public function pesanMasuk()
{
return $this->morphMany(Pesan::class, 'penerima');
}
public function pesanTerkirim()
{
return $this->morphMany(Pesan::class, 'pengirim');
}
}

View File

@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Pesan extends Model
{
@ -19,5 +20,15 @@ class Pesan extends Model
'isi_pesan',
'sudah_dibaca'
];
public function pengirim(): MorphTo
{
return $this->morphTo();
}
public function penerima(): MorphTo
{
return $this->morphTo();
}
}

View File

@ -2,7 +2,7 @@
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable; // PENTING: Ganti ini
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class Petani extends Authenticatable
@ -29,4 +29,14 @@ public function produks()
{
return $this->hasMany(Produk::class, 'petani_id');
}
public function pesanMasuk()
{
return $this->morphMany(Pesan::class, 'penerima');
}
public function pesanTerkirim()
{
return $this->morphMany(Pesan::class, 'pengirim');
}
}

View File

@ -16,7 +16,7 @@
<div class="auth-logo">
<a href="{{ url('/') }}"><h3 class="fw-bold">TaniDesa</h3></a>
</div>
<h1 class="auth-title">Log in.</h1>
<h3 class="fw-bold text-center">Log in</h3>
{{-- Pesan Sukses Register --}}
@if (session('success'))
@ -37,16 +37,16 @@
<form action="{{ route('login.proses') }}" method="POST">
@csrf
<div class="form-group position-relative has-icon-left mb-4">
<input type="text" name="username" class="form-control form-control-xl" placeholder="Username">
<input type="text" name="username" class="form-control " placeholder="Username">
<div class="form-control-icon"><i class="bi bi-person"></i></div>
</div>
<div class="form-group position-relative has-icon-left mb-4">
<input type="password" name="password" class="form-control form-control-xl" placeholder="Password">
<input type="password" name="password" class="form-control " placeholder="Password">
<div class="form-control-icon"><i class="bi bi-shield-lock"></i></div>
</div>
<button class="btn btn-primary btn-block btn-lg shadow-lg mt-5">Masuk</button>
<button class="btn btn-primary btn-block btn-md shadow-md mt-5">Masuk</button>
</form>
<div class="text-center mt-5 text-lg fs-4">
<div class="text-center mt-5 text-md fs-6">
<p class="text-gray-600">Belum punya akun? <a href="{{ route('register') }}" class="font-bold">Daftar</a>.</p>
</div>
</div>

View File

@ -64,12 +64,46 @@ class="form-control form-control-sm text-center border-0" value="1"
<button type="submit"
class="btn border border-secondary rounded-pill px-4 py-2 mb-4 text-primary"><i
class="fa fa-shopping-bag me-2 text-primary"></i> Masukkan Keranjang</button>
<a href="{{ route('checkout', ['produk_id' => $produk->id]) }}" class="...">
Beli Sekarang
</a>
</form>
</div>
<button type="button" class="btn btn-outline-success rounded-pill px-4" data-bs-toggle="modal"
data-bs-target="#chatModal">
<i class="fa fa-envelope me-2"></i> Chat Petani
</button>
<div class="modal fade" id="chatModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Kirim Pesan ke {{ $produk->petani->nama_lengkap }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form action="{{ route('pesan.kirim') }}" method="POST">
@csrf
<div class="modal-body">
{{-- Hidden Input untuk Target Penerima (Petani) --}}
<input type="hidden" name="penerima_id" value="{{ $produk->petani_id }}">
<input type="hidden" name="penerima_type" value="App\Models\Petani">
<div class="mb-3">
<label class="form-label">Isi Pesan</label>
<textarea name="isi_pesan" class="form-control" rows="4" required
placeholder="Halo, apakah stok produk ini masih ada?"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary"
data-bs-dismiss="modal">Batal</button>
<button type="submit" class="btn btn-success">Kirim Pesan</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-12">
<nav>
@ -83,10 +117,12 @@ class="fa fa-shopping-bag me-2 text-primary"></i> Masukkan Keranjang</button>
</div>
</nav>
<div class="tab-content mb-5">
<div class="tab-pane active" id="nav-about" role="tabpanel" aria-labelledby="nav-about-tab">
<div class="tab-pane active" id="nav-about" role="tabpanel"
aria-labelledby="nav-about-tab">
<p>{{ $produk->deskripsi }}</p>
</div>
<div class="tab-pane" id="nav-mission" role="tabpanel" aria-labelledby="nav-mission-tab">
<div class="tab-pane" id="nav-mission" role="tabpanel"
aria-labelledby="nav-mission-tab">
<div class="d-flex">
<img src="{{ asset('template/frontend/img/avatar.jpg') }}"
class="img-fluid rounded-circle p-3" style="width: 100px; height: 100px;"

View File

@ -0,0 +1,32 @@
@extends('layouts.frontend')
@section('title', 'Pesan Saya')
@section('content')
<div class="container py-5">
<h2 class="mb-4">Pesan Saya</h2>
<div class="card shadow-sm border-0">
<div class="list-group list-group-flush">
@forelse($chatList as $chat)
<a href="{{ route('pembeli.pesan.show', $chat['lawan_id']) }}" class="list-group-item list-group-item-action py-3 d-flex align-items-center">
<img src="{{ asset('template/frontend/img/avatar.jpg') }}" class="rounded-circle me-3" width="50" height="50">
<div class="flex-grow-1">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1 text-primary">{{ $chat['nama'] }}</h5>
<small class="text-muted">{{ $chat['time'] }}</small>
</div>
<p class="mb-1 text-muted">{{ Str::limit($chat['last_message'], 50) }}</p>
</div>
@if($chat['unread'] > 0)
<span class="badge bg-danger rounded-pill ms-2">{{ $chat['unread'] }}</span>
@endif
</a>
@empty
<div class="text-center py-5">
<i class="fa fa-envelope-open fa-3x text-muted mb-3"></i>
<p class="text-muted">Belum ada percakapan. Mulai chat dari halaman detail produk.</p>
<a href="{{ route('shop') }}" class="btn btn-outline-primary rounded-pill">Lihat Produk</a>
</div>
@endforelse
</div>
</div>
</div>
@endsection

View File

@ -0,0 +1,88 @@
@extends('layouts.frontend')
@section('title', 'Chat dengan ' . $lawan->nama_lengkap)
@section('content')
<div class="container-fluid page-header py-5 mb-5">
<h1 class="text-center text-white display-6">Percakapan</h1>
<ol class="breadcrumb justify-content-center mb-0">
<li class="breadcrumb-item"><a href="{{ route('pembeli.pesan.index') }}" class="text-white">Pesan Saya</a></li>
<li class="breadcrumb-item active text-white">Detail</li>
</ol>
</div>
<div class="container py-3">
<div class="row justify-content-center">
<div class="col-lg-8 col-md-10">
<div class="card border shadow-sm">
<div class="card-header bg-white py-3 border-bottom d-flex align-items-center">
<a href="{{ route('pembeli.pesan.index') }}"
class="btn btn-sm btn-outline-secondary rounded-circle me-3">
<i class="fas fa-arrow-left"></i>
</a>
<div>
<h6 class="mb-0 fw-bold">{{ $lawan->nama_lengkap }}</h6>
<small class="text-muted">Petani</small>
</div>
</div>
<div class="card-body bg-light overflow-auto" id="chatBox" style="height: 500px;">
@foreach ($chats as $chat)
@php
$isMe =
$chat->pengirim_id == Auth::guard('pembeli')->id() &&
$chat->pengirim_type == 'App\Models\Pembeli';
@endphp
<div class="d-flex mb-3 {{ $isMe ? 'justify-content-end' : 'justify-content-start' }}">
<div class="p-3 rounded-3"
style="max-width: 75%;
{{ $isMe ? 'background-color: #0d6efd; color: white;' : 'background-color: white; border: 1px solid #dee2e6;' }}">
<p class="mb-1">{{ $chat->isi_pesan }}</p>
<div class="text-end">
<small style="font-size: 11px; {{ $isMe ? 'color: #e0e0e0;' : 'color: #6c757d;' }}">
{{ $chat->created_at->format('H:i') }}
@if ($isMe)
<i
class="fas fa-check {{ $chat->sudah_dibaca ? 'text-warning' : '' }} ms-1"></i>
@endif
</small>
</div>
</div>
</div>
@endforeach
</div>
<div class="card-footer bg-white p-3">
<form action="{{ route('pesan.kirim') }}" method="POST">
@csrf
<input type="hidden" name="penerima_id" value="{{ $lawan->id }}">
<div class="input-group">
<input type="text" name="isi_pesan" class="form-control" placeholder="Tulis pesan..."
required autocomplete="off">
<button type="submit" class="btn btn-primary px-4">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection
@section('js')
<script>
$(document).ready(function() {
var chatBox = document.getElementById("chatBox");
chatBox.scrollTop = chatBox.scrollHeight;
});
</script>
@endsection

View File

@ -113,6 +113,14 @@ class="bi bi-x bi-middle"></i></a>
<span>Pesanan Masuk</span>
</a>
</li>
{{-- Manajemen Kotak Masuk(Pesan) --}}
<li class="sidebar-item {{ request()->is('petani/pesan*') ? 'active' : '' }}">
<a href="{{ route('petani.pesan.index') }}" class='sidebar-link'>
<i class="bi bi-chat-dots-fill"></i>
<span>Kotak Masuk</span>
</a>
</li>
@endif
<li class="sidebar-title">Akun</li>

View File

@ -97,13 +97,12 @@ class="position-absolute bg-secondary rounded-circle d-flex align-items-center j
class="d-none d-xl-inline ms-1">{{ Auth::guard('pembeli')->user()->nama_lengkap }}</span>
</a>
<div class="dropdown-menu m-0 bg-secondary rounded-0">
<!-- Menu Profil -->
<a href="#" class="dropdown-item">Profil Saya</a>
<!-- Menu Pesanan Saya -->
<a href="{{ route('pembeli.pesan.index') }}" class="dropdown-item">Pesan Saya</a>
<a href="{{ route('pesanan.saya') }}" class="dropdown-item">Pesanan Saya</a>
<!-- Form Logout -->
<form action="{{ route('logout') }}" method="POST">
@csrf
<button type="submit" class="dropdown-item">Logout</button>
@ -123,7 +122,7 @@ class="d-none d-xl-inline ms-1">{{ Auth::guard('pembeli')->user()->nama_lengkap
<!-- Main Content -->
<div style="margin-top: 150px;">
@if(session('success'))
@if (session('success'))
<div class="container mt-3">
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fa fa-check-circle me-2"></i> {{ session('success') }}
@ -131,7 +130,7 @@ class="d-none d-xl-inline ms-1">{{ Auth::guard('pembeli')->user()->nama_lengkap
</div>
</div>
@endif
@yield('content')
</div>

View File

@ -0,0 +1,37 @@
@extends('layouts.admin')
@section('title', 'Chat')
@section('content')
<div class="card" style="height: 75vh;">
<div class="row g-0 h-100">
<div class="col-md-4 border-end h-100 overflow-auto">
<div class="p-3 bg-light border-bottom">
<h6 class="mb-0">Daftar Percakapan</h6>
</div>
<div class="list-group list-group-flush">
@forelse($chatList as $chat)
<a href="{{ route('petani.pesan.show', $chat['lawan_id']) }}"
class="list-group-item list-group-item-action py-3">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1 text-primary">{{ $chat['nama'] }}</h6>
<small class="text-muted" style="font-size: 11px">{{ $chat['time'] }}</small>
</div>
<p class="mb-1 text-truncate small text-secondary">{{ $chat['last_message'] }}</p>
@if ($chat['unread'] > 0)
<span class="badge bg-danger rounded-pill">{{ $chat['unread'] }}</span>
@endif
</a>
@empty
<div class="p-4 text-center text-muted">Belum ada pesan.</div>
@endforelse
</div>
</div>
<div class="col-md-8 h-100 d-flex align-items-center justify-content-center bg-white">
<div class="text-center text-muted">
<i class="bi bi-chat-dots display-1"></i>
<p class="mt-3">Pilih kontak di sebelah kiri untuk mulai chatting.</p>
</div>
</div>
</div>
</div>
@endsection

View File

@ -0,0 +1,104 @@
@extends('layouts.admin')
@section('title', 'Chat dengan ' . $lawan->nama_lengkap)
@section('content')
<section class="section">
<div class="card" style="height: 85vh; display: flex; flex-direction: column;">
{{-- HEADER CHAT --}}
<div class="card-header bg-primary py-3 d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center text-white">
<a href="{{ route('petani.pesan.index') }}" class="btn btn-light btn-sm me-3 rounded-circle"
style="width: 32px; height: 32px; padding: 0; display: flex; align-items: center; justify-content: center;">
<i class="bi bi-arrow-left text-primary"></i>
</a>
<div>
<h6 class="mb-0 text-white font-bold" style="font-size: 1.1rem;">{{ $lawan->nama_lengkap }}</h6>
<small style="opacity: 0.8; font-size: 0.8rem;">
{{ $lawan->role ?? 'Pembeli' }}
</small>
</div>
</div>
</div>
{{-- INBOX SECTION (SCROLLABLE) --}}
<div class="card-body p-4" id="chatContainer"
style="flex-grow: 1; overflow-y: auto; background-color: #f2f7ff;">
<div class="d-flex flex-column gap-3">
@forelse($chats as $chat)
@php
$isMe =
$chat->pengirim_id == Auth::guard('petani')->id() &&
$chat->pengirim_type == 'App\Models\Petani';
@endphp
{{-- Logic Posisi: Kalau SAYA di Kanan (end), Kalau DIA di Kiri (start) --}}
<div class="d-flex w-100 {{ $isMe ? 'justify-content-end' : 'justify-content-start' }}">
<div style="max-width: 70%; min-width: 120px;">
{{-- Bubble Chat --}}
<div class="p-3 shadow-sm position-relative"
style="border-radius: 15px;
border-{{ $isMe ? 'bottom-right' : 'bottom-left' }}-radius: 0;
background-color: {{ $isMe ? '#435ebe' : '#ffffff' }};
color: {{ $isMe ? '#ffffff' : '#212529' }};">
<p class="mb-1" style="font-size: 0.95rem; line-height: 1.4;">
{{ $chat->isi_pesan }}
</p>
<div class="d-flex justify-content-end align-items-center mt-1">
<small style="font-size: 0.7rem; opacity: 0.8; margin-right: 4px;">
{{ $chat->created_at->format('H:i') }}
</small>
@if ($isMe)
<i class="bi {{ $chat->sudah_dibaca ? 'bi-check-all text-info' : 'bi-check' }}"
style="font-size: 0.9rem;"></i>
@endif
</div>
</div>
</div>
</div>
@empty
<div class="text-center my-5">
<span class="badge bg-light-secondary text-secondary p-3 rounded-pill">
Belum ada percakapan. Mulailah menyapa! 👋
</span>
</div>
@endforelse
</div>
</div>
{{-- FOOTER INPUT PESAN --}}
<div class="card-footer bg-white py-3 border-top">
<form action="{{ route('pesan.kirim') }}" method="POST" class="d-flex gap-2 align-items-center">
@csrf
{{-- Input Hidden Data Penerima --}}
<input type="hidden" name="penerima_id" value="{{ $lawan->id }}">
{{-- Input Text --}}
<div class="input-group">
<input type="text" name="isi_pesan" class="form-control form-control-lg border-0 bg-light"
placeholder="Ketik pesan..." required autocomplete="off" style="border-radius: 20px;">
<button type="submit" class="btn btn-primary btn-lg ms-2 rounded-circle"
style="width: 50px; height: 50px; display: flex; align-items: center; justify-content: center;">
<i class="bi bi-send-fill fs-5"></i>
</button>
</div>
</form>
</div>
</div>
</section>
<script>
document.addEventListener("DOMContentLoaded", function() {
var chatBox = document.getElementById("chatContainer");
if (chatBox) {
chatBox.scrollTop = chatBox.scrollHeight;
}
});
</script>
@endsection

View File

@ -5,6 +5,7 @@
use App\Http\Controllers\AdminController;
use App\Http\Controllers\CartController;
use App\Http\Controllers\LandingController;
use App\Http\Controllers\PesanController;
use App\Http\Controllers\Petani\DashboardController;
use App\Http\Controllers\Petani\ProdukController;
use App\Http\Controllers\TransaksiController;
@ -25,6 +26,11 @@
Route::post('/cart/add', [CartController::class, 'addToCart'])->name('cart.add');
Route::delete('/cart/remove', [CartController::class, 'remove'])->name('cart.remove');
// --- ROUTE GLOBAL (BISA DIAKSES PETANI & PEMBELI) ---
Route::post('/pesan/kirim', [PesanController::class, 'store'])
->middleware('auth:pembeli,petani')
->name('pesan.kirim');
// --- AUTH ROUTES (Guest Only) ---
Route::middleware('guest')->group(function () {
@ -55,6 +61,10 @@
Route::patch('/cart/update', [CartController::class, 'updateCart'])->name('cart.update');
Route::delete('/cart/remove', [CartController::class, 'remove'])->name('cart.remove');
// Route Pesan untuk Pembeli
Route::get('/pesan', [PesanController::class, 'index'])->name('pembeli.pesan.index');
Route::get('/pesan/{id}', [PesanController::class, 'show'])->name('pembeli.pesan.show');
});
@ -84,4 +94,8 @@
Route::get('/petani/pesanan', [TransaksiController::class, 'pesananMasuk'])->name('petani.pesanan.index');
Route::patch('/petani/pesanan/{id}', [TransaksiController::class, 'updateStatus'])->name('petani.pesanan.update');
Route::get('/petani/pesanan/{id}', [TransaksiController::class, 'pesananDetail'])->name('petani.pesanan.detail');
// Route Pesan untuk Petani
Route::get('/petani/pesan', [PesanController::class, 'index'])->name('petani.pesan.index');
Route::get('/petani/pesan/{id}', [PesanController::class, 'show'])->name('petani.pesan.show');
});