Chat funktionalität und Datenbankerweitrerung

This commit is contained in:
serialexperiments 2026-06-29 14:37:31 +02:00
parent 9c72c046ca
commit adc70d4986
7 changed files with 214 additions and 1 deletions

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers;
use App\Models\Message;
use Illuminate\Http\Request;
class MessageController extends Controller
{
public function index(Request $request)
{
$messages = Message::where('product_id', $request->product_id)
->where('buyer_id', $request->buyer_id)
->orderBy('created_at')
->get();
return response()->json($messages);
}
public function store(Request $request)
{
$msg = Message::create($request->validate([
'product_id' => 'required|exists:products,id',
'provider_id' => 'required|exists:providers,id',
'buyer_id' => 'required|integer',
'sender' => 'required|in:buyer,seller',
'body' => 'required|string|max:1000',
]));
return response()->json($msg, 201);
}
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Message extends Model
{
protected $fillable = ['product_id', 'provider_id', 'buyer_id', 'sender', 'body'];
}

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::create('messages', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->foreignId('provider_id')->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('buyer_id');
$table->enum('sender', ['buyer', 'seller']);
$table->text('body');
$table->timestamps();
});
}
public function down(): void {
Schema::dropIfExists('messages');
}
};

View file

@ -2,8 +2,11 @@
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ProductController;
use App\Http\Controllers\ProviderController;
use App\Http\Controllers\MessageController;
Route::get('/products', [ProductController::class, 'index']);
Route::get('/products/{id}', [ProductController::class, 'show']);
Route::get('/providers', [ProviderController::class, 'index']);
Route::get('/providers/{id}', [ProviderController::class, 'show']);
Route::get('/messages', [MessageController::class, 'index']);
Route::post('/messages', [MessageController::class, 'store']);

View file

@ -24,3 +24,19 @@ export async function fetchProvider(id) {
if (!res.ok) throw new Error('Anbieter nicht gefunden')
return res.json()
}
export async function fetchMessages(productId, buyerId) {
const res = await fetch(`${BASE}/messages?product_id=${productId}&buyer_id=${buyerId}`)
if (!res.ok) throw new Error('Fehler beim Laden der Nachrichten')
return res.json()
}
export async function sendMessage(payload) {
const res = await fetch(`${BASE}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
if (!res.ok) throw new Error('Nachricht konnte nicht gesendet werden')
return res.json()
}

View file

@ -0,0 +1,123 @@
<template>
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
<div class="modal-box">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="margin:0">Anfrage: {{ productTitle }}</h3>
<button @click="$emit('close')" style="background:none;border:none;color:#aaa;font-size:20px;cursor:pointer"></button>
</div>
<div class="message-list" ref="listEl">
<div v-if="loading" style="color:#aaa;font-size:13px">Lädt...</div>
<div v-for="m in messages" :key="m.id" :class="['msg', m.sender === 'buyer' ? 'msg-buyer' : 'msg-seller']">
<span>{{ m.body }}</span>
<small>{{ m.created_at }}</small>
</div>
<div v-if="!loading && messages.length === 0" style="color:#666;font-size:13px;text-align:center;padding:20px">
Noch keine Nachrichten. Schreib dem Anbieter!
</div>
</div>
<div v-if="error" style="color:#f44336;font-size:12px;margin-bottom:8px">{{ error }}</div>
<div style="display:flex;gap:8px;margin-top:12px">
<textarea v-model="draft" placeholder="Deine Nachricht..." rows="2"
style="flex:1;background:#2a2a2a;border:1px solid #333;color:#e0e0e0;border-radius:6px;padding:8px;resize:none;font-size:14px"
@keydown.enter.exact.prevent="send" />
<button class="btn-primary" @click="send" :disabled="sending || !draft.trim()"
style="align-self:flex-end;padding:10px 16px">
{{ sending ? '...' : '➤' }}
</button>
</div>
<small style="color:#555;font-size:11px">Enter zum Senden</small>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { fetchMessages, sendMessage } from '@/api.js'
const props = defineProps({
open: Boolean,
productId: [String, Number],
productTitle: String,
providerId: [String, Number],
})
defineEmits(['close'])
// Hardcoded buyer_id=1 bis Auth fertig ist
const BUYER_ID = 1
const messages = ref([])
const draft = ref('')
const loading = ref(false)
const sending = ref(false)
const error = ref('')
const listEl = ref(null)
let pollInterval = null
async function loadMessages() {
loading.value = true
try {
messages.value = await fetchMessages(props.productId, BUYER_ID)
await nextTick()
if (listEl.value) listEl.value.scrollTop = listEl.value.scrollHeight
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function send() {
if (!draft.value.trim() || sending.value) return
sending.value = true
error.value = ''
try {
await sendMessage({
product_id: props.productId,
provider_id: props.providerId,
buyer_id: BUYER_ID,
sender: 'buyer',
body: draft.value.trim(),
})
draft.value = ''
await loadMessages()
} catch (e) {
error.value = e.message
} finally {
sending.value = false
}
}
watch(() => props.open, (val) => {
if (val) {
loadMessages()
pollInterval = setInterval(loadMessages, 5000)
} else {
clearInterval(pollInterval)
messages.value = []
error.value = ''
}
})
</script>
<style scoped>
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center; z-index: 200;
}
.modal-box {
background: #1e1e1e; border: 1px solid #333; border-radius: 10px;
padding: 20px; width: 90%; max-width: 480px; display: flex; flex-direction: column;
}
.message-list {
min-height: 200px; max-height: 300px; overflow-y: auto;
display: flex; flex-direction: column; gap: 8px; padding: 4px 0;
}
.msg { display: flex; flex-direction: column; max-width: 80%; padding: 8px 12px; border-radius: 8px; font-size: 14px; }
.msg small { font-size: 10px; color: #666; margin-top: 4px; }
.msg-buyer { align-self: flex-end; background: #1565c0; }
.msg-seller { align-self: flex-start; background: #2a2a2a; border: 1px solid #333; }
</style>

View file

@ -24,6 +24,13 @@
</div>
</div>
<div class="detail-sidebar">
<ChatModal
:open="chatOpen"
:product-id="product.id"
:product-title="product.title"
:provider-id="product.provider_id"
@close="chatOpen = false"
/>
<div class="seller-card" v-if="product">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<div class="seller-avatar">{{ product.initials }}</div>
@ -34,7 +41,7 @@
</div>
<p style="font-size:13px;color:#aaa">📦 {{ product.address }}, {{ product.city }}</p>
<div v-if="product.verified" style="font-size:12px;color:#4caf50;margin:8px 0"> Verifizierter Anbieter</div>
<button class="btn-action" @click="alert('Anfrage gesendet!')"> Abholung anfragen</button>
<button class="btn-action" @click="chatOpen = true"> Abholung anfragen</button>
<button class="btn-action" style="background:#333;margin-top:8px" @click="router.push(`/anbieter/${product.provider_id}`)">Alle Angebote ansehen</button>
</div>
</div>
@ -54,6 +61,10 @@ const router = useRouter()
const product = ref(null)
const alternatives = ref([])
const loading = ref(true)
import ChatModal from '@/views/ChatModal.vue'
const chatOpen = ref(false)
const error = ref('')
onMounted(async () => {