Commit 4260f782 authored by HaekalAlif's avatar HaekalAlif
Browse files

feat(chat-checkout): Integrate offer-to-checkout flow and enable image...

feat(chat-checkout): Integrate offer-to-checkout flow and enable image messaging in chat for seamless negotiation and purchasing experience
parent 4431eba6
Showing with 1915 additions and 427 deletions
+1915 -427
......@@ -12,8 +12,10 @@ use App\Models\DetailPembelian;
use App\Models\Tagihan;
use App\Models\Barang;
use App\Models\AlamatUser;
use App\Models\Toko;
use Carbon\Carbon;
use Illuminate\Support\Str;
use Illuminate\Http\JsonResponse;
class PembelianController extends Controller
{
......@@ -61,57 +63,29 @@ class PembelianController extends Controller
}
/**
* Show purchase details by code
* Get purchase by code
*/
public function show($kode)
public function show(string $kode): JsonResponse
{
$user = Auth::user();
// Debug the incoming code parameter
\Log::debug('Fetching purchase with code: ' . $kode);
// First, check if the purchase exists with this code
$pembelian = Pembelian::where('kode_pembelian', $kode)
->where('id_pembeli', $user->id_user)
->where('is_deleted', false)
->first();
if (!$pembelian) {
\Log::error('Purchase not found with code: ' . $kode);
return response()->json([
'status' => 'error',
'message' => 'Pembelian tidak ditemukan'
], 404);
}
// Now that we confirmed it exists, get all related data
\Log::debug('Found purchase with ID: ' . $pembelian->id_pembelian);
// Check if detail pembelian exists for this purchase
$detailCount = DetailPembelian::where('id_pembelian', $pembelian->id_pembelian)->count();
\Log::debug('Detail pembelian count in database: ' . $detailCount);
if ($detailCount === 0) {
\Log::error('No detail pembelian found for purchase ID: ' . $pembelian->id_pembelian);
return response()->json([
'status' => 'error',
'message' => 'Data detail pembelian tidak ditemukan'
], 404);
}
try {
// Load the purchase with all its related data
$completeData = Pembelian::with([
'detailPembelian',
'detailPembelian.barang',
'detailPembelian.barang.gambarBarang',
'detailPembelian.pengiriman_pembelian',
'tagihan',
'pengiriman',
$user = Auth::user();
$purchase = Pembelian::with([
'pembeli',
'alamat.province',
'alamat.regency',
'alamat.regency',
'alamat.district',
'alamat.village',
'detailPembelian.barang.gambar_barang',
'detailPembelian.barang.toko.alamat_toko.province',
'detailPembelian.barang.toko.alamat_toko.regency',
'detailPembelian.barang.toko.alamat_toko.district',
'detailPembelian.toko.alamat_toko.province',
'detailPembelian.toko.alamat_toko.regency',
'detailPembelian.toko.alamat_toko.district',
'detailPembelian.pesanPenawaran',
'detailPembelian.pengiriman_pembelian',
'tagihan',
'review',
'komplain' => function($query) {
$query->with('retur'); // Eager load retur relationship
......@@ -119,23 +93,108 @@ class PembelianController extends Controller
])
->where('kode_pembelian', $kode)
->where('id_pembeli', $user->id_user)
->where('is_deleted', false)
->first();
if (!$completeData) {
if (!$purchase) {
return response()->json([
'status' => 'error',
'message' => 'Pesanan tidak ditemukan'
'message' => 'Purchase not found'
], 404);
}
// Convert to array for processing
$purchaseData = $purchase->toArray();
// Process each detail_pembelian to ensure we have complete store information
if ($purchaseData['detail_pembelian']) {
foreach ($purchaseData['detail_pembelian'] as &$detail) {
// Mark if this item is from an offer
$detail['is_from_offer'] = !is_null($detail['id_pesan']);
// Calculate savings if from offer
if ($detail['is_from_offer'] && isset($detail['barang']['harga'])) {
$detail['offer_price'] = $detail['harga_satuan'];
$detail['original_price'] = $detail['barang']['harga'];
$detail['savings'] = ($detail['barang']['harga'] - $detail['harga_satuan']) * $detail['jumlah'];
}
// Ensure complete store information is available
if (!isset($detail['toko']) || empty($detail['toko'])) {
// Load store details manually if not loaded through relationship
$storeDetails = Toko::with([
'alamat_toko.province',
'alamat_toko.regency',
'alamat_toko.district'
])->find($detail['id_toko']);
if ($storeDetails) {
$detail['toko'] = [
'id_toko' => $storeDetails->id_toko,
'id_user' => $storeDetails->id_user,
'nama_toko' => $storeDetails->nama_toko,
'slug' => $storeDetails->slug,
'deskripsi' => $storeDetails->deskripsi,
'alamat' => $storeDetails->alamat,
'kontak' => $storeDetails->kontak,
'is_active' => $storeDetails->is_active,
'alamat_toko' => $storeDetails->alamat_toko->map(function($alamat) {
return [
'id_alamat_toko' => $alamat->id_alamat_toko,
'nama_pengirim' => $alamat->nama_pengirim,
'no_telepon' => $alamat->no_telepon,
'alamat_lengkap' => $alamat->alamat_lengkap,
'kode_pos' => $alamat->kode_pos,
'is_primary' => $alamat->is_primary,
'province' => $alamat->province ? [
'id' => $alamat->province->id,
'name' => $alamat->province->name
] : null,
'regency' => $alamat->regency ? [
'id' => $alamat->regency->id,
'name' => $alamat->regency->name
] : null,
'district' => $alamat->district ? [
'id' => $alamat->district->id,
'name' => $alamat->district->name
] : null,
];
})->toArray()
];
} else {
// Fallback if store not found
$detail['toko'] = [
'id_toko' => $detail['id_toko'],
'nama_toko' => "Store {$detail['id_toko']}",
'slug' => null,
'deskripsi' => null,
'alamat' => null,
'kontak' => null,
'is_active' => true,
'alamat_toko' => []
];
}
}
// Also ensure barang has toko data for consistency
if (isset($detail['barang']) && (!isset($detail['barang']['toko']) || empty($detail['barang']['toko']))) {
$detail['barang']['toko'] = $detail['toko'];
}
// Ensure barang has id_toko for consistency
if (isset($detail['barang']) && !isset($detail['barang']['id_toko'])) {
$detail['barang']['id_toko'] = $detail['id_toko'];
}
}
}
return response()->json([
'status' => 'success',
'data' => $completeData
'data' => $purchaseData
]);
}
catch (\Exception $e) {
\Log::error('Error fetching purchase details: ' . $e->getMessage());
} catch (\Exception $e) {
\Log::error("Error fetching purchase {$kode}: {$e->getMessage()}");
return response()->json([
'status' => 'error',
'message' => 'Failed to fetch purchase details'
......
......@@ -98,14 +98,29 @@ class PesanController extends Controller
], 403);
}
// Validate the message data
$validated = $request->validate([
// Validate the message data based on message type
$rules = [
'tipe_pesan' => 'required|in:Text,Penawaran,Gambar,System',
'isi_pesan' => 'nullable|string',
'harga_tawar' => 'nullable|numeric|min:1',
'status_penawaran' => 'nullable|in:Menunggu,Diterima,Ditolak',
'id_barang' => 'nullable|exists:barang,id_barang',
]);
];
// Add image validation for Gambar type
if ($request->input('tipe_pesan') === 'Gambar') {
$rules['image'] = 'required|image|mimes:jpeg,png,jpg,gif|max:5120'; // 5MB max
}
$validated = $request->validate($rules);
// Handle image upload for Gambar type messages
$imagePath = null;
if ($validated['tipe_pesan'] === 'Gambar' && $request->hasFile('image')) {
$image = $request->file('image');
$imagePath = $image->store('chat-images', 'public');
$validated['isi_pesan'] = $imagePath; // Store the image path in isi_pesan
}
// Create the message
$message = new Pesan();
......@@ -145,17 +160,19 @@ class PesanController extends Controller
// Also try direct pusher broadcast for debugging
$pusher = app('pusher');
$channelName = "private-chat.{$chatRoomId}";
$chatRoomChannelName = "private-chat-room.{$chatRoomId}";
$chatListChannelName = "private-chat-list.{$chatRoomId}";
$eventName = 'MessageSent';
$eventData = $event->broadcastWith();
Log::info("📡 Direct Pusher broadcast attempt", [
'channel' => $channelName,
'chat_room_channel' => $chatRoomChannelName,
'chat_list_channel' => $chatListChannelName,
'event' => $eventName,
'data_keys' => array_keys($eventData)
]);
$pusher->trigger($channelName, $eventName, $eventData);
$pusher->trigger([$chatRoomChannelName, $chatListChannelName], $eventName, $eventData);
Log::info("✅ MessageSent event broadcasted IMMEDIATELY via both methods");
} catch (\Exception $e) {
......@@ -208,23 +225,55 @@ class PesanController extends Controller
], 400);
}
// Check if offer is still pending
if ($message->status_penawaran !== 'Menunggu') {
return response()->json([
'success' => false,
'message' => 'This offer has already been responded to'
], 400);
}
$validated = $request->validate([
'status_penawaran' => 'required|in:Menunggu,Diterima,Ditolak',
'status_penawaran' => 'required|in:Diterima,Ditolak',
'response_message' => 'nullable|string|max:200'
]);
$message->status_penawaran = $validated['status_penawaran'];
$message->save();
// Create a system response message
$responseText = $validated['status_penawaran'] === 'Diterima'
? "✅ Penawaran diterima"
: "❌ Penawaran ditolak";
if (!empty($validated['response_message'])) {
$responseText .= ": " . $validated['response_message'];
}
$responseMessage = new Pesan();
$responseMessage->id_ruang_chat = $message->id_ruang_chat;
$responseMessage->id_user = $user->id_user;
$responseMessage->tipe_pesan = 'System';
$responseMessage->isi_pesan = $responseText;
$responseMessage->is_read = false;
$responseMessage->save();
// Update the room's updated_at timestamp
$chatRoom->touch();
// Broadcast the update
// Broadcast both messages
$message->load('user');
$responseMessage->load('user');
event(new MessageSent($message));
event(new MessageSent($responseMessage));
return response()->json([
'success' => true,
'data' => $message,
'data' => [
'updated_offer' => $message,
'response_message' => $responseMessage
],
'message' => 'Offer status updated successfully'
]);
} catch (\Exception $e) {
......
......@@ -2,10 +2,13 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AlamatToko extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
......@@ -34,7 +37,7 @@ class AlamatToko extends Model
'kota',
'kecamatan',
'kode_pos',
'is_primary',
'is_primary'
];
/**
......@@ -43,7 +46,7 @@ class AlamatToko extends Model
* @var array
*/
protected $casts = [
'is_primary' => 'boolean',
'is_primary' => 'boolean'
];
/**
......@@ -82,8 +85,8 @@ class AlamatToko extends Model
* Get the village associated with the address based on district.
* This requires the district_id to be properly set.
*/
public function villages()
public function village()
{
return $this->hasMany(Village::class, 'district_id', 'kecamatan');
return $this->belongsTo(Village::class, 'kelurahan', 'id');
}
}
......@@ -8,19 +8,21 @@ use Illuminate\Database\Eloquent\Model;
class DetailPembelian extends Model
{
use HasFactory;
protected $table = 'detail_pembelian';
protected $primaryKey = 'id_detail'; // This is the actual column name in DB
protected $primaryKey = 'id_detail';
protected $fillable = [
'id_pembelian',
'id_barang',
'id_toko',
'jumlah',
'id_keranjang',
'id_pesan', // Add this for offer message reference
'harga_satuan',
'jumlah',
'subtotal'
];
/**
* Relationship with Pembelian
*/
......@@ -45,6 +47,20 @@ class DetailPembelian extends Model
return $this->belongsTo(Toko::class, 'id_toko', 'id_toko');
}
/**
* Relationship with Keranjang
*/
public function keranjang()
{
return $this->belongsTo(Keranjang::class, 'id_keranjang', 'id_keranjang');
}
// Relationship to offer message
public function pesanPenawaran()
{
return $this->belongsTo(Pesan::class, 'id_pesan', 'id_pesan');
}
/**
* Get the shipping information record associated with this purchase detail
*/
......@@ -53,6 +69,41 @@ class DetailPembelian extends Model
return $this->hasOne(PengirimanPembelian::class, 'id_detail_pembelian', 'id_detail');
}
// Check if this detail was created from an offer
public function isFromOffer()
{
return !is_null($this->id_pesan);
}
// Get the offer price if this was from an offer
public function getOfferPrice()
{
if ($this->isFromOffer() && $this->pesanPenawaran) {
return $this->pesanPenawaran->harga_tawar;
}
return null;
}
// Get the original product price
public function getOriginalPrice()
{
return $this->barang ? $this->barang->harga : null;
}
// Calculate savings if this was from an offer
public function getSavings()
{
if ($this->isFromOffer()) {
$originalPrice = $this->getOriginalPrice();
$offerPrice = $this->getOfferPrice();
if ($originalPrice && $offerPrice) {
return ($originalPrice - $offerPrice) * $this->jumlah;
}
}
return 0;
}
// Keep the camelCase relationship for backwards compatibility
public function pengirimanPembelian()
{
......
......@@ -2,10 +2,13 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Toko extends Model
{
use HasFactory;
protected $table = 'toko';
protected $primaryKey = 'id_toko';
......@@ -18,9 +21,12 @@ class Toko extends Model
'alamat',
'kontak',
'is_active',
'is_deleted',
'created_by',
'updated_by'
'is_deleted'
];
protected $casts = [
'is_active' => 'boolean',
'is_deleted' => 'boolean'
];
/**
......@@ -83,16 +89,6 @@ class Toko extends Model
return $this->belongsTo(User::class, 'id_user', 'id_user');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by', 'id_user');
}
public function updater()
{
return $this->belongsTo(User::class, 'updated_by', 'id_user');
}
/**
* Get the addresses for the store.
*/
......@@ -101,4 +97,14 @@ class Toko extends Model
return $this->hasMany(AlamatToko::class, 'id_toko', 'id_toko')
->with(['province', 'regency', 'district']);
}
public function barang()
{
return $this->hasMany(Barang::class, 'id_toko', 'id_toko');
}
public function detailPembelian()
{
return $this->hasMany(DetailPembelian::class, 'id_toko', 'id_toko');
}
}
......@@ -21,6 +21,7 @@ use App\Http\Controllers\User\PesananTokoController;
use App\Http\Controllers\Admin\PesananManagementController;
use App\Http\Controllers\Admin\PaymentManagementController;
use App\Http\Controllers\Admin\KomplainManagementController;
use App\Http\Controllers\User\ChatOfferController;
// Debug endpoint for checking auth status
Route::middleware('auth:sanctum')->get('/auth-check', function (Request $request) {
......@@ -427,21 +428,27 @@ Route::middleware('auth:sanctum')->group(function () {
Route::get('/debug/midtrans-config', [App\Http\Controllers\User\TagihanController::class, 'debugMidtransConfig']);
});
// Chat Routes
Route::prefix('chat')->group(function() {
// Chat and Offers Routes
Route::middleware('auth:sanctum')->group(function () {
// Chat room management
Route::get('/', [App\Http\Controllers\User\RuangChatController::class, 'index']);
Route::post('/', [App\Http\Controllers\User\RuangChatController::class, 'store']);
Route::get('/{id}', [App\Http\Controllers\User\RuangChatController::class, 'show']);
Route::put('/{id}', [App\Http\Controllers\User\RuangChatController::class, 'update']);
Route::delete('/{id}', [App\Http\Controllers\User\RuangChatController::class, 'destroy']);
Route::patch('/{id}/mark-read', [App\Http\Controllers\User\RuangChatController::class, 'markAsRead']);
Route::get('/chat', [App\Http\Controllers\User\RuangChatController::class, 'index']);
Route::post('/chat', [App\Http\Controllers\User\RuangChatController::class, 'store']);
Route::get('/chat/{id}', [App\Http\Controllers\User\RuangChatController::class, 'show']);
Route::put('/chat/{id}', [App\Http\Controllers\User\RuangChatController::class, 'update']);
Route::delete('/chat/{id}', [App\Http\Controllers\User\RuangChatController::class, 'destroy']);
Route::patch('/chat/{id}/mark-read', [App\Http\Controllers\User\RuangChatController::class, 'markAsRead']);
// Messages within chat rooms
Route::get('/{chatRoomId}/messages', [App\Http\Controllers\User\PesanController::class, 'index']);
Route::post('/{chatRoomId}/messages', [App\Http\Controllers\User\PesanController::class, 'store']);
Route::put('/messages/{id}', [App\Http\Controllers\User\PesanController::class, 'update']);
Route::patch('/messages/{id}/read', [App\Http\Controllers\User\PesanController::class, 'markAsRead']);
Route::get('/chat/{chatRoomId}/messages', [App\Http\Controllers\User\PesanController::class, 'index']);
Route::post('/chat/{chatRoomId}/messages', [App\Http\Controllers\User\PesanController::class, 'store']);
Route::put('/chat/messages/{id}', [App\Http\Controllers\User\PesanController::class, 'update']);
Route::patch('/chat/messages/{id}/read', [App\Http\Controllers\User\PesanController::class, 'markAsRead']);
// Offer routes
Route::post('/chat/{roomId}/offers', [ChatOfferController::class, 'store']);
Route::post('/chat/offers/{messageId}/respond', [ChatOfferController::class, 'respond']);
Route::get('/chat/offers/{messageId}/check-purchase', [ChatOfferController::class, 'checkExistingPurchase']);
Route::post('/chat/offers/{messageId}/purchase', [ChatOfferController::class, 'createPurchaseFromOffer']);
});
});
<?php
use App\Models\RuangChat;
use Illuminate\Support\Facades\Broadcast;
use App\Models\RuangChat;
/*
|--------------------------------------------------------------------------
......
import { useState } from "react";
import { Send, Paperclip, Smile } from "lucide-react";
import { useState, useRef } from "react";
import { Send, Paperclip, Smile, DollarSign, Image } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
interface ChatInputProps {
onSendMessage: (message: string) => void;
onSendImage?: (file: File) => void;
onOpenOfferForm?: () => void;
canMakeOffer?: boolean;
disabled?: boolean;
}
function ChatInput({ onSendMessage, disabled = false }: ChatInputProps) {
function ChatInput({
onSendMessage,
onSendImage,
onOpenOfferForm,
canMakeOffer = false,
disabled = false,
}: ChatInputProps) {
const [message, setMessage] = useState("");
const [isSending, setIsSending] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value);
......@@ -39,10 +50,7 @@ function ChatInput({ onSendMessage, disabled = false }: ChatInputProps) {
}, 100);
} catch (error) {
console.error("Failed to send message:", error);
// Show error feedback
const errorMessage =
error instanceof Error ? error.message : "Gagal mengirim pesan";
// You could add a toast notification here
toast.error("Gagal mengirim pesan");
} finally {
setIsSending(false);
}
......@@ -56,21 +64,70 @@ function ChatInput({ onSendMessage, disabled = false }: ChatInputProps) {
}
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && onSendImage) {
// Validate file type
if (!file.type.startsWith("image/")) {
toast.error("Hanya file gambar yang diperbolehkan");
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error("Ukuran file maksimal 5MB");
return;
}
onSendImage(file);
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
return (
<div className="p-4 bg-white">
<div className="flex items-end gap-3">
{/* Attachment Button */}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
<div className="flex items-end gap-2">
{/* Offer Button - Only show for buyers */}
{canMakeOffer && onOpenOfferForm && (
<Button
variant="ghost"
size="icon"
onClick={onOpenOfferForm}
className="h-10 w-10 text-amber-600 hover:text-amber-700 hover:bg-amber-50 rounded-full flex-shrink-0"
disabled={disabled || isSending}
title="Buat Penawaran"
>
<DollarSign className="h-5 w-5" />
</Button>
)}
{/* Image Upload Button */}
<Button
variant="ghost"
size="icon"
onClick={() => fileInputRef.current?.click()}
className="h-10 w-10 text-gray-400 hover:text-[#F79E0E] hover:bg-orange-50 rounded-full flex-shrink-0"
disabled={disabled || isSending}
title="Kirim Gambar"
>
<Paperclip className="h-5 w-5" />
<Image className="h-5 w-5" />
</Button>
{/* Input Container */}
<div className="flex-1 relative">
<div className="flex-1 relative ">
<textarea
id="chat-input"
value={message}
......@@ -97,6 +154,7 @@ function ChatInput({ onSendMessage, disabled = false }: ChatInputProps) {
size="icon"
className="absolute right-2 bottom-1 h-8 w-8 text-gray-400 hover:text-[#F79E0E] hover:bg-orange-50 rounded-full"
disabled={disabled || isSending}
title="Emoji"
>
<Smile className="h-4 w-4" />
</Button>
......@@ -111,6 +169,7 @@ function ChatInput({ onSendMessage, disabled = false }: ChatInputProps) {
disabled:opacity-50 disabled:cursor-not-allowed
shadow-lg hover:shadow-xl transition-all duration-200
disabled:shadow-none flex-shrink-0"
title="Kirim Pesan"
>
{isSending ? (
<div className="relative">
......
......@@ -5,6 +5,7 @@ import MessageBubble from "./MessageBubble";
import axios from "../../../../lib/axios";
import { Message } from "../types";
import OfferForm from "./OfferForm";
import OfferDialog from "./OfferDialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { MessageCircle, Users } from "lucide-react";
import { MessagesSkeleton } from "./MessagesSkeleton";
......@@ -34,8 +35,9 @@ function ChatRoom({
const [loading, setLoading] = useState(true);
const [loadingMessages, setLoadingMessages] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showOfferForm, setShowOfferForm] = useState(offerMode);
const [showOfferDialog, setShowOfferDialog] = useState(false); // Changed from showOfferForm
const [isConnected, setIsConnected] = useState(false);
const [chatRoomData, setChatRoomData] = useState<any>(null); // Add chat room data state
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
......@@ -58,6 +60,15 @@ function ChatRoom({
setLoadingMessages(true);
setError(null);
// Fetch chat room data first
const roomResponse = await axios.get(
`${process.env.NEXT_PUBLIC_API_URL}/chat/${roomId}`
);
if (roomResponse.data.success) {
setChatRoomData(roomResponse.data.data);
}
const response = await axios.get(
`${process.env.NEXT_PUBLIC_API_URL}/chat/${roomId}/messages`
);
......@@ -86,7 +97,9 @@ function ChatRoom({
try {
// Use different channel name for ChatRoom to avoid conflicts
const channelName = `chat-room.${roomId}`;
console.log(`🔗 ChatRoom subscribing to private channel: ${channelName}`);
console.log(
`🔗 ChatRoom subscribing to private channel: ${channelName}`
);
channel = echo.private(channelName);
// Listen for new messages
......@@ -94,19 +107,28 @@ function ChatRoom({
console.log("📨 ChatRoom received MessageSent event:", data);
setMessages((prevMessages) => {
const exists = prevMessages.some(
// Check if message already exists
const existingIndex = prevMessages.findIndex(
(msg) => msg.id_pesan === data.id_pesan
);
if (exists) {
if (existingIndex !== -1) {
// Update existing message (for status changes like offer responses)
console.log(
"⚠️ ChatRoom: Message already exists, skipping:",
"📝 ChatRoom: Updating existing message:",
data.id_pesan
);
return prevMessages;
const updatedMessages = [...prevMessages];
updatedMessages[existingIndex] = {
...updatedMessages[existingIndex],
...data,
};
return updatedMessages;
} else {
// Add new message
console.log("✅ ChatRoom: Adding new message:", data.id_pesan);
return [...prevMessages, data];
}
console.log("✅ ChatRoom: Adding new message:", data.id_pesan);
return [...prevMessages, data];
});
});
......@@ -131,7 +153,10 @@ function ChatRoom({
}
});
} catch (error) {
console.error("❌ ChatRoom error setting up real-time connection:", error);
console.error(
"❌ ChatRoom error setting up real-time connection:",
error
);
setIsConnected(false);
}
};
......@@ -171,9 +196,6 @@ function ChatRoom({
if (!response.data.success && response.data.status !== "success") {
throw new Error(response.data.message || "Failed to send message");
}
// Remove the temporary message addition - let broadcasting handle it
// This ensures consistent behavior for both sender and receiver
} catch (error: any) {
console.error("❌ Error sending message:", error);
throw error;
......@@ -187,13 +209,11 @@ function ChatRoom({
) => {
try {
const response = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/chat/${roomId}/messages`,
`${process.env.NEXT_PUBLIC_API_URL}/chat/${roomId}/offers`,
{
tipe_pesan: "Penawaran",
isi_pesan: message,
harga_tawar: offerPrice,
status_penawaran: "Menunggu",
// You might want to include product ID if available
isi_pesan: message || `Penawaran untuk ${quantity} item`,
quantity: quantity,
}
);
......@@ -201,38 +221,131 @@ function ChatRoom({
throw new Error(response.data.message || "Failed to send offer");
}
setShowOfferForm(false);
// Dialog will close automatically after successful submission
} catch (error: any) {
console.error("Error sending offer:", error);
throw error;
}
};
const handleOfferResponse = async (
messageId: number,
status: string,
responseMessage?: string
) => {
try {
const response = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/chat/offers/${messageId}/respond`,
{
status_penawaran: status,
response_message: responseMessage,
}
);
if (!response.data.success) {
throw new Error(response.data.message || "Failed to respond to offer");
}
// Update the local state immediately for better UX
setMessages((prevMessages) => {
return prevMessages.map((msg) => {
if (msg.id_pesan === messageId) {
return {
...msg,
status_penawaran: status,
};
}
return msg;
});
});
} catch (error: any) {
console.error("Error responding to offer:", error);
throw error;
}
};
const handleSendImage = async (imageFile: File) => {
try {
const formData = new FormData();
formData.append("image", imageFile);
formData.append("tipe_pesan", "Gambar");
const response = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/chat/${roomId}/messages`,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
}
);
if (!response.data.success && response.data.status !== "success") {
throw new Error(response.data.message || "Failed to send image");
}
} catch (error: any) {
console.error("Error sending image:", error);
throw error;
}
};
// Determine if current user can make offers (only buyers can make offers)
const canMakeOffer = () => {
return (
chatRoomData?.id_pembeli === session?.user?.id && chatRoomData?.barang
);
};
if (loading) {
return (
<Card className="border-orange-100 h-full">
<CardContent className="flex justify-center items-center h-96">
<div className="flex flex-col items-center gap-6">
{/* Multi-layer spinner */}
<div className="h-full flex flex-col bg-white rounded-xl border border-orange-100">
<div className="flex-1 flex justify-center items-center p-8">
<div className="flex flex-col items-center gap-6 max-w-sm">
{/* Enhanced multi-layer spinner */}
<div className="relative">
<div className="w-16 h-16 border-4 border-orange-100 rounded-full"></div>
<div className="absolute inset-0 w-16 h-16 border-4 border-transparent border-t-orange-400 rounded-full animate-spin"></div>
{/* Outer ring */}
<div className="w-20 h-20 border-4 border-orange-100/60 rounded-full"></div>
{/* Main spinning ring */}
<div className="absolute inset-0 w-20 h-20 border-4 border-transparent border-t-orange-400 border-r-orange-300 rounded-full animate-spin"></div>
{/* Middle ring - reverse spin */}
<div
className="absolute inset-3 w-14 h-14 border-4 border-transparent border-t-amber-400 border-l-amber-300 rounded-full animate-spin"
style={{
animationDirection: "reverse",
animationDuration: "1.5s",
}}
></div>
{/* Inner ring */}
<div
className="absolute inset-2 w-12 h-12 border-4 border-transparent border-t-amber-400 rounded-full animate-spin animation-delay-150"
style={{ animationDirection: "reverse" }}
className="absolute inset-6 w-8 h-8 border-4 border-transparent border-t-orange-300 border-b-amber-300 rounded-full animate-spin"
style={{ animationDuration: "0.8s" }}
></div>
<div className="absolute inset-4 w-8 h-8 border-4 border-transparent border-t-orange-300 rounded-full animate-spin animation-delay-300"></div>
{/* Center dot with pulse */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-2 h-2 bg-orange-400 rounded-full animate-pulse"></div>
</div>
</div>
{/* Loading text with typewriter effect */}
<div className="text-center space-y-3">
{/* Loading text with enhanced animation */}
<div className="text-center space-y-4">
<div className="relative">
<h3 className="text-lg font-semibold text-gray-800 inline-block">
Menghubungkan ke server
<span className="animate-pulse">...</span>
<span className="inline-block animate-bounce ml-1">.</span>
<span className="inline-block animate-bounce animation-delay-100 ml-0.5">
.
</span>
<span className="inline-block animate-bounce animation-delay-200 ml-0.5">
.
</span>
</h3>
</div>
<div className="flex items-center justify-center gap-2">
<div className="flex items-center justify-center gap-3">
<div className="flex space-x-1">
<div
className="w-2 h-2 bg-orange-400 rounded-full animate-bounce"
......@@ -247,17 +360,38 @@ function ChatRoom({
style={{ animationDelay: "0.2s" }}
></div>
</div>
<p className="text-sm text-gray-500">Menyiapkan chat room</p>
<p className="text-sm text-gray-500 font-medium">
Menyiapkan chat room
</p>
</div>
{/* Progress bar */}
<div className="w-48 bg-orange-100 rounded-full h-1.5 overflow-hidden">
<div className="h-full bg-gradient-to-r from-orange-400 to-amber-400 rounded-full animate-pulse"></div>
{/* Enhanced progress bar */}
<div className="w-56 bg-orange-100/80 rounded-full h-2 overflow-hidden shadow-inner">
<div
className="h-full bg-gradient-to-r from-orange-400 via-amber-400 to-orange-400 rounded-full animate-pulse shadow-sm"
style={{
background:
"linear-gradient(90deg, #fb923c, #fbbf24, #fb923c)",
backgroundSize: "200% 100%",
animation: "shimmer 2s infinite, pulse 1.5s infinite",
}}
></div>
</div>
{/* Status messages */}
<div className="text-xs text-gray-400 space-y-1">
<p className="animate-pulse">🔐 Mengamankan koneksi</p>
<p className="animate-pulse animation-delay-300">
💬 Memuat riwayat chat
</p>
<p className="animate-pulse animation-delay-600">
⚡ Menyiapkan real-time sync
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
......@@ -304,7 +438,9 @@ function ChatRoom({
</div>
<div>
<h3 className="font-semibold text-gray-900">
Chat Room #{roomId}
{chatRoomData?.barang
? chatRoomData.barang.nama_barang
: `Chat Room #${roomId}`}
</h3>
<div className="flex items-center gap-2 mt-1">
<div
......@@ -324,22 +460,26 @@ function ChatRoom({
</div>
</div>
{/* Offer Mode Banner */}
{showOfferForm && (
<div className="mt-3 p-3 bg-gradient-to-r from-amber-50 to-orange-50 border border-amber-200 rounded-lg">
{/* Product Info Banner */}
{chatRoomData?.barang && (
<div className="mt-3 p-3 bg-white/60 border border-amber-200 rounded-lg">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-amber-800">Mode Penawaran</h4>
<p className="text-sm text-amber-600">
Jumlah: {offerQuantity} item
<h4 className="font-medium text-gray-800">
{chatRoomData.barang.nama_barang}
</h4>
<p className="text-sm text-gray-600">
Harga: Rp {chatRoomData.barang.harga?.toLocaleString("id-ID")}
</p>
</div>
<button
onClick={() => setShowOfferForm(false)}
className="text-amber-600 hover:text-amber-800 text-lg font-bold transition-colors"
>
</button>
{canMakeOffer() && (
<button
onClick={() => setShowOfferDialog(true)}
className="px-3 py-1 bg-amber-500 text-white text-sm rounded-lg hover:bg-amber-600 transition-colors"
>
💰 Tawar
</button>
)}
</div>
</div>
)}
......@@ -362,12 +502,10 @@ function ChatRoom({
<MessageCircle className="h-12 w-12 text-[#F79E0E]" />
</div>
<h3 className="font-semibold text-gray-900 mb-3 text-lg">
{offerMode ? "Mulai dengan Penawaran!" : "Belum Ada Pesan"}
Belum Ada Pesan
</h3>
<p className="text-gray-500 text-sm max-w-xs leading-relaxed">
{offerMode
? "Buat penawaran Anda menggunakan tombol di bawah untuk memulai negosiasi"
: "Mulai percakapan dengan mengirim pesan pertama Anda"}
Mulai percakapan dengan mengirim pesan pertama Anda
</p>
</div>
) : (
......@@ -377,6 +515,9 @@ function ChatRoom({
key={message.id_pesan}
message={message}
isOwnMessage={message.id_user === session?.user?.id}
onOfferResponse={handleOfferResponse}
currentUserId={session?.user?.id}
chatRoomData={chatRoomData}
/>
))}
<div ref={messagesEndRef} className="h-1" />
......@@ -387,30 +528,23 @@ function ChatRoom({
{/* Input Area - Fixed at bottom */}
<div className="flex-shrink-0 border-t border-orange-100 bg-white">
{showOfferForm ? (
<div className="p-3">
<OfferForm
quantity={offerQuantity || 1}
onSendOffer={handleSendOffer}
onCancel={() => setShowOfferForm(false)}
/>
</div>
) : (
<>
<ChatInput onSendMessage={handleSendMessage} />
{offerMode && (
<div className="p-3 bg-gradient-to-r from-orange-50 to-amber-50 border-t border-orange-100">
<button
onClick={() => setShowOfferForm(true)}
className="w-full py-2 bg-gradient-to-r from-[#F79E0E] to-[#FFB648] text-white rounded-lg hover:from-[#F79E0E]/90 hover:to-[#FFB648]/90 transition-all font-medium shadow-sm"
>
💰 Buat Penawaran
</button>
</div>
)}
</>
)}
<ChatInput
onSendMessage={handleSendMessage}
onSendImage={handleSendImage}
onOpenOfferForm={() => setShowOfferDialog(true)}
canMakeOffer={canMakeOffer()}
/>
</div>
{/* Offer Dialog */}
<OfferDialog
isOpen={showOfferDialog}
onClose={() => setShowOfferDialog(false)}
quantity={offerQuantity || 1}
onSendOffer={handleSendOffer}
productPrice={chatRoomData?.barang?.harga}
productName={chatRoomData?.barang?.nama_barang}
/>
</div>
);
}
......
......@@ -7,6 +7,7 @@ import axios from "../../../../lib/axios";
import { formatDistanceToNow } from "date-fns";
import { id } from "date-fns/locale";
import echo from "../libs/echo";
import { ChatRoomListSkeleton } from "./ChatRoomListSkeleton";
interface ChatRoom {
id_ruang_chat: number;
......@@ -252,37 +253,7 @@ export function ChatRoomList({
);
if (loading) {
return (
<div className="h-full bg-white border-r border-orange-100 flex flex-col lg:w-80 w-full">
{/* Header Skeleton */}
<div className="flex-shrink-0 p-4 border-b border-orange-100">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 bg-orange-100 rounded-full animate-pulse" />
<div className="h-6 w-32 bg-orange-100 rounded animate-pulse" />
</div>
<div className="h-10 bg-orange-50 rounded-lg animate-pulse" />
</div>
{/* Room List Skeleton */}
<div className="flex-1 overflow-hidden">
{[...Array(5)].map((_, index) => (
<div
key={index}
className="p-4 border-b border-orange-50 animate-pulse"
>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-orange-100 rounded-full" />
<div className="flex-1 space-y-2">
<div className="h-4 w-24 bg-orange-100 rounded" />
<div className="h-3 w-32 bg-orange-50 rounded" />
</div>
<div className="h-3 w-12 bg-orange-50 rounded" />
</div>
</div>
))}
</div>
</div>
);
return <ChatRoomListSkeleton />;
}
return (
......
export function ChatRoomListSkeleton() {
return (
<div className="h-full bg-white border-r border-orange-100 flex flex-col lg:w-80 w-full">
{/* Header Skeleton */}
<div className="flex-shrink-0 p-4 border-b border-orange-100 bg-gradient-to-r from-orange-50 to-amber-50">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-gradient-to-br from-orange-200/60 to-amber-200/60 rounded-lg animate-pulse" />
<div className="space-y-2">
<div className="h-5 w-20 bg-gradient-to-r from-orange-200/70 to-orange-300/70 rounded animate-pulse" />
<div className="h-4 w-32 bg-gradient-to-r from-orange-100/60 to-orange-200/60 rounded animate-pulse" />
</div>
</div>
{/* Search Skeleton */}
<div className="relative">
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 bg-orange-200/50 rounded animate-pulse" />
<div className="w-full h-10 bg-gradient-to-r from-orange-50/80 to-orange-100/50 border border-orange-200/40 rounded-lg animate-pulse" />
</div>
</div>
{/* Chat Room List Skeleton */}
<div className="flex-1 overflow-hidden">
<div className="p-2">
{/* Loading indicator */}
<div className="flex justify-center py-4">
<div className="flex items-center gap-2 px-3 py-2 bg-gradient-to-r from-orange-50 to-amber-50 rounded-full border border-orange-100">
<div className="relative">
<div className="w-3 h-3 bg-orange-300 rounded-full animate-ping absolute"></div>
<div className="w-3 h-3 bg-orange-400 rounded-full"></div>
</div>
<span className="text-xs text-orange-600 font-medium">
Memuat percakapan...
</span>
</div>
</div>
{/* Room Item Skeletons */}
{[...Array(6)].map((_, index) => (
<div
key={index}
className="p-3 border-b border-orange-50 animate-pulse"
style={{
animationDelay: `${index * 0.1}s`,
}}
>
<div className="flex items-start gap-3">
{/* Avatar Skeleton */}
<div className="relative flex-shrink-0">
<div className="w-12 h-12 bg-gradient-to-br from-orange-200/70 to-amber-200/70 rounded-full" />
{/* Random unread badge skeleton for variety */}
{Math.random() > 0.7 && (
<div className="absolute -top-1 -right-1 w-5 h-5 bg-gradient-to-br from-red-300 to-red-400 rounded-full animate-pulse" />
)}
</div>
{/* Content Skeleton */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
{/* Name Skeleton */}
<div
className="h-4 bg-gradient-to-r from-orange-200/80 to-orange-300/80 rounded"
style={{
width: `${70 + Math.random() * 50}px`,
animationDelay: `${index * 0.15}s`,
}}
/>
{/* Time Skeleton */}
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-orange-100/60 rounded animate-pulse" />
<div
className="h-3 bg-gradient-to-r from-orange-100/70 to-orange-200/70 rounded"
style={{
width: `${30 + Math.random() * 20}px`,
animationDelay: `${index * 0.2}s`,
}}
/>
</div>
</div>
{/* Product Info Skeleton - Random appearance */}
{Math.random() > 0.4 && (
<div
className="h-3 bg-gradient-to-r from-amber-100/60 to-amber-200/60 rounded mb-2"
style={{
width: `${80 + Math.random() * 60}px`,
animationDelay: `${index * 0.25}s`,
}}
/>
)}
{/* Message Skeleton */}
<div className="space-y-1">
<div
className="h-4 bg-gradient-to-r from-gray-200/70 to-gray-300/70 rounded"
style={{
width: `${60 + Math.random() * 40}%`,
animationDelay: `${index * 0.3}s`,
}}
/>
{/* Second line for longer messages - Random appearance */}
{Math.random() > 0.6 && (
<div
className="h-4 bg-gradient-to-r from-gray-100/60 to-gray-200/60 rounded"
style={{
width: `${30 + Math.random() * 30}%`,
animationDelay: `${index * 0.35}s`,
}}
/>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Footer Skeleton */}
<div className="flex-shrink-0 p-4 border-t border-orange-100 bg-orange-25">
<div className="flex items-center justify-center gap-2">
<div className="w-3 h-3 bg-orange-200/60 rounded animate-pulse" />
<div className="h-3 w-48 bg-gradient-to-r from-orange-100/60 to-orange-200/60 rounded animate-pulse" />
</div>
</div>
</div>
);
}
import { Message } from "../types";
import { formatDate } from "@/lib/formatter";
import { Check, CheckCheck, Clock } from "lucide-react";
import {
Check,
CheckCheck,
Clock,
DollarSign,
Image as ImageIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import axios from "@/lib/axios";
import { toast } from "sonner";
import Image from "next/image";
import { useRouter } from "next/navigation";
interface MessageBubbleProps {
message: Message;
isOwnMessage: boolean;
onOfferResponse?: (
messageId: number,
status: string,
responseMessage?: string
) => void;
currentUserId?: number;
chatRoomData?: any;
}
function MessageBubble({ message, isOwnMessage }: MessageBubbleProps) {
function MessageBubble({
message,
isOwnMessage,
onOfferResponse,
currentUserId,
chatRoomData,
}: MessageBubbleProps) {
const [responding, setResponding] = useState(false);
const [showResponseForm, setShowResponseForm] = useState(false);
const [responseMessage, setResponseMessage] = useState("");
const [imageError, setImageError] = useState(false);
const [creatingPurchase, setCreatingPurchase] = useState(false);
const router = useRouter();
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString("id-ID", {
......@@ -20,9 +52,12 @@ function MessageBubble({ message, isOwnMessage }: MessageBubbleProps) {
const getMessageTypeStyle = () => {
switch (message.tipe_pesan) {
case "Penawaran":
// Use consistent styling for both sender and receiver of offers
return "border-2 border-amber-200 bg-gradient-to-br from-amber-50 to-orange-50";
case "System":
return "border border-gray-200 bg-gray-50 text-gray-600";
case "Gambar":
return "border border-orange-200 bg-orange-50/30";
default:
return "";
}
......@@ -31,11 +66,333 @@ function MessageBubble({ message, isOwnMessage }: MessageBubbleProps) {
const getOfferStatusColor = () => {
switch (message.status_penawaran) {
case "Diterima":
return "text-green-600 bg-green-50";
return "text-green-600 bg-green-50 border-green-200";
case "Ditolak":
return "text-red-600 bg-red-50";
return "text-red-600 bg-red-50 border-red-200";
default:
return "text-amber-600 bg-amber-50 border-amber-200";
}
};
const handleOfferResponse = async (status: string) => {
if (!onOfferResponse) return;
setResponding(true);
try {
await onOfferResponse(message.id_pesan, status, responseMessage);
setShowResponseForm(false);
setResponseMessage("");
toast.success(`Penawaran ${status.toLowerCase()} berhasil!`);
} catch (error: any) {
console.error("Error responding to offer:", error);
toast.error(error.response?.data?.message || "Gagal merespons penawaran");
} finally {
setResponding(false);
}
};
const canRespondToOffer = () => {
return (
message.tipe_pesan === "Penawaran" &&
message.status_penawaran === "Menunggu" &&
!isOwnMessage &&
chatRoomData?.id_penjual === currentUserId
);
};
const canCheckoutFromOffer = () => {
return (
message.tipe_pesan === "Penawaran" &&
message.status_penawaran === "Diterima" &&
isOwnMessage &&
chatRoomData?.id_pembeli === currentUserId
);
};
const handleCreatePurchaseFromOffer = async () => {
setCreatingPurchase(true);
try {
console.log("🛒 Creating purchase from offer:", message.id_pesan);
// Step 1: Get user's addresses first
toast.info("Preparing purchase from offer...");
const addressResponse = await axios.get(
`${process.env.NEXT_PUBLIC_API_URL}/user/addresses`
);
if (
addressResponse.data.status !== "success" ||
!addressResponse.data.data.length
) {
toast.error("Please add a shipping address first");
router.push("/user/alamat");
return;
}
const addresses = addressResponse.data.data;
const primaryAddress =
addresses.find((addr: any) => addr.is_primary) || addresses[0];
// Step 2: Try to create purchase from offer directly
try {
const response = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/chat/offers/${message.id_pesan}/purchase`,
{
jumlah: 1, // Default quantity for now, could be made configurable
id_alamat: primaryAddress.id_alamat,
}
);
if (response.data.success) {
const { kode_pembelian } = response.data.data;
toast.success(
"Purchase created from offer! Redirecting to checkout..."
);
// Step 3: Redirect to checkout page with offer flag
router.push(`/checkout?code=${kode_pembelian}&from_offer=true`);
} else {
toast.error(
response.data.message || "Failed to create purchase from offer"
);
}
} catch (createError: any) {
console.log(
"Error creating purchase, checking if already exists:",
createError.response?.data
);
// If creation failed, it might be because purchase already exists
// Only then check for existing purchase
if (
createError.response?.status === 400 ||
createError.response?.status === 409
) {
try {
const existingPurchaseResponse = await axios.get(
`${process.env.NEXT_PUBLIC_API_URL}/chat/offers/${message.id_pesan}/check-purchase`
);
if (
existingPurchaseResponse.data.success &&
existingPurchaseResponse.data.data?.kode_pembelian
) {
const { kode_pembelian } = existingPurchaseResponse.data.data;
toast.success("Redirecting to existing purchase...");
router.push(`/checkout?code=${kode_pembelian}&from_offer=true`);
return;
}
} catch (checkError) {
console.log(
"No existing purchase found, original error was:",
createError.response?.data
);
}
}
// If we get here, throw the original creation error
throw createError;
}
} catch (error: any) {
console.error("Error creating purchase from offer:", error);
if (error.response?.status === 401) {
toast.error("Please log in to continue");
router.push("/login");
} else {
toast.error(
error.response?.data?.message ||
"Failed to create purchase from offer"
);
}
} finally {
setCreatingPurchase(false);
}
};
const formatOfferPrice = (price: number) => {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(price);
};
const getImageUrl = (imagePath: string) => {
if (imagePath.startsWith("http")) {
return imagePath;
}
return `${process.env.NEXT_PUBLIC_BACKEND_URL}/storage/${imagePath}`;
};
const renderMessageContent = () => {
switch (message.tipe_pesan) {
case "Penawaran":
return (
<div className="space-y-3">
{/* Offer Header */}
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-amber-600" />
<span className="font-medium text-sm text-amber-800">
Penawaran Harga
</span>
</div>
{/* Offer Amount */}
<div className="text-lg font-bold text-gray-900">
{formatOfferPrice(message.harga_tawar || 0)}
</div>
{/* Offer Message */}
{message.isi_pesan && (
<div className="text-sm p-2 rounded bg-amber-50/50 text-gray-700">
{message.isi_pesan}
</div>
)}
{/* Offer Status */}
<div
className={`
inline-block px-2 py-1 rounded-full text-xs font-medium border
${getOfferStatusColor()}
`}
>
{message.status_penawaran === "Menunggu"
? "⏳ Menunggu Respons"
: message.status_penawaran === "Diterima"
? "✅ Diterima"
: message.status_penawaran === "Ditolak"
? "❌ Ditolak"
: message.status_penawaran || "Menunggu"}
</div>
{/* Response Buttons for Seller */}
{canRespondToOffer() && !showResponseForm && (
<div className="flex gap-2 mt-3">
<Button
size="sm"
onClick={() => setShowResponseForm(true)}
className="bg-green-600 hover:bg-green-700 text-white text-xs px-3 py-1"
>
Respons
</Button>
</div>
)}
{/* Response Form */}
{canRespondToOffer() && showResponseForm && (
<div className="mt-3 p-3 bg-white/80 rounded-lg space-y-2">
<textarea
value={responseMessage}
onChange={(e) => setResponseMessage(e.target.value)}
placeholder="Pesan tambahan (opsional)"
className="w-full p-2 text-sm border rounded resize-none bg-white text-gray-900"
rows={2}
maxLength={200}
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => handleOfferResponse("Diterima")}
disabled={responding}
className="bg-green-600 hover:bg-green-700 text-white text-xs px-3 py-1 flex-1"
>
{responding ? "..." : "✅ Terima"}
</Button>
<Button
size="sm"
onClick={() => handleOfferResponse("Ditolak")}
disabled={responding}
className="bg-red-600 hover:bg-red-700 text-white text-xs px-3 py-1 flex-1"
>
{responding ? "..." : "❌ Tolak"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setShowResponseForm(false);
setResponseMessage("");
}}
className="text-xs px-2 py-1 bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
>
Batal
</Button>
</div>
</div>
)}
{/* Checkout Button for Accepted Offers (Buyer) */}
{canCheckoutFromOffer() && (
<div className="mt-3">
<Button
size="sm"
onClick={handleCreatePurchaseFromOffer}
disabled={creatingPurchase}
className="bg-amber-500 hover:bg-amber-600 text-white text-xs px-4 py-2 w-full font-medium transition-colors"
>
{creatingPurchase ? (
<div className="flex items-center gap-2">
<div className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Memproses...
</div>
) : (
"🛒 Checkout Sekarang"
)}
</Button>
</div>
)}
</div>
);
case "Gambar":
return (
<div className="space-y-2">
{/* Image Header */}
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4" />
<span className="font-medium text-sm">Gambar</span>
</div>
{/* Image Display */}
<div className="relative max-w-64 rounded-lg overflow-hidden">
{!imageError ? (
<Image
src={getImageUrl(message.isi_pesan || "")}
alt="Shared image"
width={256}
height={192}
className="object-cover cursor-pointer hover:opacity-90 transition-opacity"
onError={() => setImageError(true)}
onClick={() => {
// Open image in new tab
window.open(getImageUrl(message.isi_pesan || ""), "_blank");
}}
/>
) : (
<div className="w-64 h-48 bg-gray-200 flex items-center justify-center text-gray-500">
<div className="text-center">
<ImageIcon className="h-8 w-8 mx-auto mb-2" />
<p className="text-sm">Gambar tidak dapat dimuat</p>
</div>
</div>
)}
</div>
</div>
);
case "System":
return (
<div className="text-center text-sm italic">{message.isi_pesan}</div>
);
default:
return "text-amber-600 bg-amber-50";
return (
<div className="whitespace-pre-wrap break-words">
{message.isi_pesan}
</div>
);
}
};
......@@ -50,81 +407,80 @@ function MessageBubble({ message, isOwnMessage }: MessageBubbleProps) {
relative px-4 py-3 rounded-2xl shadow-sm
${getMessageTypeStyle()}
${
isOwnMessage
message.tipe_pesan === "System"
? "mx-auto bg-gray-100 text-gray-600 text-center"
: message.tipe_pesan === "Penawaran"
? "bg-gradient-to-br from-amber-50 to-orange-50 border-2 border-amber-200 text-gray-900" // Consistent styling for offers
: isOwnMessage
? "bg-gradient-to-br from-[#F79E0E] to-[#FFB648] text-white ml-auto"
: "bg-white border border-orange-100 text-gray-900"
}
${isOwnMessage ? "rounded-br-md" : "rounded-bl-md"}
${
isOwnMessage &&
message.tipe_pesan !== "System" &&
message.tipe_pesan !== "Penawaran"
? "rounded-br-md"
: message.tipe_pesan !== "System" &&
message.tipe_pesan !== "Penawaran"
? "rounded-bl-md"
: ""
}
`}
>
{/* Sender Name (only for incoming messages) */}
{!isOwnMessage && (
<div className="text-xs font-medium text-[#F79E0E] mb-1">
{message.user.name}
</div>
)}
{/* Sender Name (only for incoming messages and not system/offer messages) */}
{!isOwnMessage &&
message.tipe_pesan !== "System" &&
message.tipe_pesan !== "Penawaran" && (
<div className="text-xs font-medium text-[#F79E0E] mb-1">
{message.user.name}
</div>
)}
{/* Message Content */}
{message.tipe_pesan === "Penawaran" ? (
<div className="space-y-2">
<div className="font-medium">
💰 Penawaran: Rp {message.harga_tawar?.toLocaleString("id-ID")}
</div>
{message.isi_pesan && (
<div className="text-sm opacity-90">{message.isi_pesan}</div>
)}
{renderMessageContent()}
{/* Message Tail (not for system and offer messages) */}
{message.tipe_pesan !== "System" &&
message.tipe_pesan !== "Penawaran" && (
<div
className={`
inline-block px-2 py-1 rounded-full text-xs font-medium
${getOfferStatusColor()}
`}
>
{message.status_penawaran || "Menunggu"}
</div>
</div>
) : (
<div className="whitespace-pre-wrap break-words">
{message.isi_pesan}
</div>
)}
absolute top-3 w-3 h-3 transform rotate-45
${
isOwnMessage
? "right-[-6px] bg-gradient-to-br from-[#F79E0E] to-[#FFB648]"
: "left-[-6px] bg-white border-l border-b border-orange-100"
}
`}
/>
)}
</div>
{/* Message Tail */}
{/* Message Info (not for system messages) */}
{message.tipe_pesan !== "System" && (
<div
className={`
absolute top-3 w-3 h-3 transform rotate-45
${
isOwnMessage
? "right-[-6px] bg-gradient-to-br from-[#F79E0E] to-[#FFB648]"
: "left-[-6px] bg-white border-l border-b border-orange-100"
}
flex items-center gap-2 mt-1 text-xs text-gray-500
${isOwnMessage ? "justify-end" : "justify-start"}
`}
/>
</div>
>
<span>{formatTime(message.created_at)}</span>
{/* Message Info */}
<div
className={`
flex items-center gap-2 mt-1 text-xs text-gray-500
${isOwnMessage ? "justify-end" : "justify-start"}
`}
>
<span>{formatTime(message.created_at)}</span>
{/* Read Status (only for own messages) */}
{isOwnMessage && (
<div className="flex items-center">
{message.is_read ? (
<div title="Dibaca">
<CheckCheck className="h-3 w-3 text-blue-500" />
</div>
) : (
<div title="Terkirim">
<Check className="h-3 w-3 text-gray-400" />
</div>
)}
</div>
)}
</div>
{/* Read Status (only for own messages) */}
{isOwnMessage && (
<div className="flex items-center">
{message.is_read ? (
<div title="Dibaca">
<CheckCheck className="h-3 w-3 text-blue-500" />
</div>
) : (
<div title="Terkirim">
<Check className="h-3 w-3 text-gray-400" />
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
);
......
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DollarSign,
Package,
MessageSquare,
X,
AlertCircle,
} from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
interface OfferDialogProps {
isOpen: boolean;
onClose: () => void;
quantity: number;
onSendOffer: (
price: number,
quantity: number,
message: string
) => Promise<void>;
productPrice?: number;
productName?: string;
}
export default function OfferDialog({
isOpen,
onClose,
quantity,
onSendOffer,
productPrice,
productName,
}: OfferDialogProps) {
const [offerPrice, setOfferPrice] = useState<string>("");
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const validateForm = () => {
const newErrors: { [key: string]: string } = {};
if (!offerPrice.trim()) {
newErrors.price = "Harga penawaran harus diisi";
} else {
const price = parseInt(offerPrice.replace(/[^0-9]/g, ""));
if (price < 1000) {
newErrors.price = "Harga minimum Rp 1.000";
}
if (productPrice && price > productPrice) {
newErrors.price = "Penawaran tidak boleh melebihi harga asli";
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setLoading(true);
try {
const price = parseInt(offerPrice.replace(/[^0-9]/g, ""));
await onSendOffer(price, quantity, message);
toast.success("Penawaran berhasil dikirim!");
// Reset form
setOfferPrice("");
setMessage("");
setErrors({});
onClose();
} catch (error: any) {
console.error("Error sending offer:", error);
toast.error(error.response?.data?.message || "Gagal mengirim penawaran");
} finally {
setLoading(false);
}
};
const formatCurrency = (value: string) => {
const number = value.replace(/[^0-9]/g, "");
return number.replace(/\B(?=(\d{3})+(?!\d))/g, ".");
};
const handlePriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatCurrency(e.target.value);
setOfferPrice(formatted);
if (errors.price) {
setErrors((prev) => ({ ...prev, price: "" }));
}
};
const calculateSavings = () => {
if (!productPrice || !offerPrice) return null;
const price = parseInt(offerPrice.replace(/[^0-9]/g, ""));
const savings = productPrice - price;
const percentage = Math.round((savings / productPrice) * 100);
return { amount: savings, percentage };
};
const savings = calculateSavings();
// Reset form when dialog closes
const handleClose = () => {
setOfferPrice("");
setMessage("");
setErrors({});
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-amber-800">
<div className="p-1.5 rounded-lg bg-amber-100">
<DollarSign className="h-4 w-4 text-amber-600" />
</div>
Buat Penawaran
</DialogTitle>
{productName && (
<DialogDescription>
Buat penawaran untuk <strong>{productName}</strong>
</DialogDescription>
)}
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Product Info */}
{productPrice && (
<div className="p-3 bg-gradient-to-br from-amber-50 to-orange-50 rounded-lg border border-amber-200">
<div className="text-sm font-medium text-gray-700">
📦 {productName}
</div>
<div className="text-xs text-gray-500 mt-1">
Harga asli: Rp {productPrice.toLocaleString("id-ID")}
</div>
</div>
)}
{/* Quantity Display */}
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg border">
<Package className="h-4 w-4 text-amber-600" />
<span className="text-sm font-medium text-gray-700">
Jumlah: {quantity} item
</span>
</div>
{/* Price Input */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Harga Penawaran <span className="text-red-500">*</span>
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 text-sm">
Rp
</span>
<input
type="text"
value={offerPrice}
onChange={handlePriceChange}
placeholder="0"
required
className={`w-full pl-10 pr-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400/50 bg-white text-sm transition-all ${
errors.price
? "border-red-300 focus:border-red-400"
: "border-gray-300 focus:border-amber-400"
}`}
/>
</div>
{errors.price && (
<div className="flex items-center gap-1 text-xs text-red-600">
<AlertCircle className="h-3 w-3" />
{errors.price}
</div>
)}
{/* Savings Display */}
{savings && savings.amount > 0 && (
<div className="text-xs text-green-600 bg-green-50 p-2 rounded border border-green-200">
💰 Hemat: Rp {savings.amount.toLocaleString("id-ID")} (
{savings.percentage}%)
</div>
)}
</div>
{/* Message Input */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Pesan Tambahan
</label>
<div className="relative">
<MessageSquare className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Tulis pesan untuk penjual..."
rows={3}
maxLength={200}
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400/50 focus:border-amber-400 bg-white resize-none text-sm"
/>
</div>
<div className="text-xs text-gray-400 text-right">
{message.length}/200
</div>
</div>
{/* Tips */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="text-xs text-blue-700">
<strong>💡 Tips Penawaran:</strong>
<ul className="mt-1 space-y-1 list-disc list-inside text-xs">
<li>Berikan alasan yang masuk akal</li>
<li>Sesuaikan dengan kondisi barang</li>
<li>Bersikap sopan dan respektif</li>
</ul>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="flex-1 border-gray-300 text-gray-700 hover:bg-gray-50"
disabled={loading}
>
Batal
</Button>
<Button
type="submit"
disabled={loading || !offerPrice.trim()}
className="flex-1 bg-gradient-to-r from-[#F79E0E] to-[#FFB648] hover:from-[#F79E0E]/90 hover:to-[#FFB648]/90 text-white font-medium disabled:opacity-50"
>
{loading ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Mengirim...
</div>
) : (
"Kirim Penawaran"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DollarSign, Package, MessageSquare, X } from "lucide-react";
import { DollarSign, Package, MessageSquare, X, AlertCircle } from "lucide-react";
import { toast } from "sonner";
interface OfferFormProps {
quantity: number;
......@@ -11,28 +11,56 @@ interface OfferFormProps {
message: string
) => Promise<void>;
onCancel: () => void;
productPrice?: number;
productName?: string;
}
export default function OfferForm({
quantity,
onSendOffer,
onCancel,
productPrice,
productName,
}: OfferFormProps) {
const [offerPrice, setOfferPrice] = useState<string>("");
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const validateForm = () => {
const newErrors: { [key: string]: string } = {};
if (!offerPrice.trim()) {
newErrors.price = "Harga penawaran harus diisi";
} else {
const price = parseInt(offerPrice.replace(/[^0-9]/g, ""));
if (price < 1000) {
newErrors.price = "Harga minimum Rp 1.000";
}
if (productPrice && price > productPrice) {
newErrors.price = "Penawaran tidak boleh melebihi harga asli";
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!offerPrice.trim()) return;
if (!validateForm()) {
return;
}
setLoading(true);
try {
const price = parseInt(offerPrice.replace(/[^0-9]/g, ""));
await onSendOffer(price, quantity, message);
} catch (error) {
toast.success("Penawaran berhasil dikirim!");
} catch (error: any) {
console.error("Error sending offer:", error);
toast.error(error.response?.data?.message || "Gagal mengirim penawaran");
} finally {
setLoading(false);
}
......@@ -46,11 +74,25 @@ export default function OfferForm({
const handlePriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatCurrency(e.target.value);
setOfferPrice(formatted);
if (errors.price) {
setErrors((prev) => ({ ...prev, price: "" }));
}
};
const calculateSavings = () => {
if (!productPrice || !offerPrice) return null;
const price = parseInt(offerPrice.replace(/[^0-9]/g, ""));
const savings = productPrice - price;
const percentage = Math.round((savings / productPrice) * 100);
return { amount: savings, percentage };
};
const savings = calculateSavings();
return (
<div className="border border-amber-200 bg-gradient-to-br from-amber-50 to-orange-50 rounded-xl">
<div className="p-3 border-b border-amber-200">
<div className="border border-amber-200 bg-gradient-to-br from-amber-50 to-orange-50 rounded-xl shadow-sm max-h-96 overflow-y-auto">
{/* Header - Fixed */}
<div className="p-3 border-b border-amber-200 bg-amber-50/80 sticky top-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-amber-100">
......@@ -71,8 +113,24 @@ export default function OfferForm({
</div>
</div>
{/* Content - Scrollable */}
<div className="p-3">
<form onSubmit={handleSubmit} className="space-y-3">
{/* Product Info - Compact */}
{productName && (
<div className="p-2 bg-white rounded-lg border border-amber-200">
<div className="text-sm font-medium text-gray-700 truncate">
📦 {productName}
</div>
{productPrice && (
<div className="text-xs text-gray-500">
Harga: Rp{" "}
{productPrice.toLocaleString("id-ID")}
</div>
)}
</div>
)}
{/* Quantity Display - Compact */}
<div className="flex items-center gap-2 p-2 bg-white rounded-lg border border-amber-200">
<Package className="h-4 w-4 text-amber-600" />
......@@ -81,10 +139,10 @@ export default function OfferForm({
</span>
</div>
{/* Price Input - Compact */}
{/* Price Input */}
<div className="space-y-1">
<label className="text-sm font-medium text-amber-800">
Harga Penawaran
Harga Penawaran <span className="text-red-500">*</span>
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 text-sm">
......@@ -96,30 +154,52 @@ export default function OfferForm({
onChange={handlePriceChange}
placeholder="0"
required
className="w-full pl-10 pr-4 py-2 border border-amber-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400/50 focus:border-amber-400 bg-white text-sm"
className={`w-full pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400/50 bg-white text-sm transition-all ${
errors.price
? "border-red-300 focus:border-red-400"
: "border-amber-200 focus:border-amber-400"
}`}
/>
</div>
{errors.price && (
<div className="flex items-center gap-1 text-xs text-red-600">
<AlertCircle className="h-3 w-3" />
{errors.price}
</div>
)}
{/* Savings Display - Compact */}
{savings && savings.amount > 0 && (
<div className="text-xs text-green-600 bg-green-50 p-1.5 rounded border border-green-200">
💰 Hemat: Rp{" "}
{savings.amount.toLocaleString("id-ID")} ({savings.percentage}%)
</div>
)}
</div>
{/* Message Input - Compact */}
<div className="space-y-1">
<label className="text-sm font-medium text-amber-800">
Pesan (Opsional)
Pesan Tambahan
</label>
<div className="relative">
<MessageSquare className="absolute left-3 top-2 h-4 w-4 text-gray-400" />
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Tambahkan catatan..."
placeholder="Tulis pesan untuk penjual..."
rows={2}
maxLength={200}
className="w-full pl-10 pr-4 py-2 border border-amber-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400/50 focus:border-amber-400 bg-white resize-none text-sm"
/>
</div>
<div className="text-xs text-gray-400 text-right">
{message.length}/200
</div>
</div>
{/* Action Buttons - Compact */}
<div className="flex gap-2 pt-1">
{/* Action Buttons - Sticky at bottom */}
<div className="flex gap-2 pt-2 bg-gradient-to-br from-amber-50 to-orange-50 -mx-3 px-3 pb-3 mt-3 border-t border-amber-200">
<Button
type="button"
variant="outline"
......@@ -132,9 +212,16 @@ export default function OfferForm({
<Button
type="submit"
disabled={loading || !offerPrice.trim()}
className="flex-1 bg-gradient-to-r from-[#F79E0E] to-[#FFB648] hover:from-[#F79E0E]/90 hover:to-[#FFB648]/90 text-white font-medium h-9 text-sm"
className="flex-1 bg-gradient-to-r from-[#F79E0E] to-[#FFB648] hover:from-[#F79E0E]/90 hover:to-[#FFB648]/90 text-white font-medium h-9 text-sm disabled:opacity-50"
>
{loading ? "Mengirim..." : "Kirim"}
{loading ? (
<div className="flex items-center gap-2">
<div className="w-3 h-3 border border-white/30 border-t-white rounded-full animate-spin" />
Mengirim...
</div>
) : (
"Kirim Penawaran"
)}
</Button>
</div>
</form>
......
......@@ -9,6 +9,7 @@ import axios from "../../../lib/axios";
import { MessageCircle, ArrowLeft } from "lucide-react";
import { ChatRoomSkeleton } from "./components/ChatRoomSkeleton";
import { Button } from "@/components/ui/button";
import { ChatRoomListSkeleton } from "./components/ChatRoomListSkeleton";
interface User {
id_user: number;
......@@ -111,7 +112,7 @@ function ChatPage() {
>
{/* Chat List Skeleton */}
<div className="w-80 border-r border-orange-100">
<ChatRoomSkeleton />
<ChatRoomListSkeleton />
</div>
{/* Chat Room Skeleton */}
......@@ -216,7 +217,10 @@ function ChatPage() {
<div
className="bg-white rounded-2xl shadow-xl border border-orange-100 overflow-hidden flex"
style={{
height: isOfferMode && quantity ? "calc(100vh - 200px)" : "calc(100vh - 140px)",
height:
isOfferMode && quantity
? "calc(100vh - 200px)"
: "calc(100vh - 140px)",
minHeight: "500px",
maxHeight: "calc(100vh - 120px)", // Ensure minimum margin
}}
......
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { TrendingDown, Gift, Sparkles } from "lucide-react";
interface OfferSavingsCardProps {
totalSavings: number;
}
export function OfferSavingsCard({ totalSavings }: OfferSavingsCardProps) {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
if (totalSavings <= 0) {
return null;
}
return (
<Card className="border-green-200 bg-gradient-to-r from-green-50 to-emerald-50 overflow-hidden relative shadow-lg">
{/* Background decoration */}
<div className="absolute top-0 right-0 w-32 h-32 opacity-10">
<div className="absolute top-2 right-2 text-green-400">
<Sparkles className="h-8 w-8" />
</div>
<div className="absolute top-8 right-8 text-green-300">
<Gift className="h-6 w-6" />
</div>
<div className="absolute top-14 right-2 text-green-200">
<TrendingDown className="h-4 w-4" />
</div>
</div>
<CardContent className="p-6 relative">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 rounded-full bg-green-100 border border-green-200 shadow-sm">
<TrendingDown className="h-6 w-6 text-green-600" />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-green-800 text-lg">
Penghematan dari Penawaran
</h3>
<Badge
variant="secondary"
className="bg-green-100 text-green-700 text-xs font-medium"
>
💰 Hemat
</Badge>
</div>
<p className="text-green-600 text-sm">
Anda mendapatkan harga khusus yang telah disepakati
</p>
</div>
</div>
<div className="text-right">
<div className="text-xs text-green-600 font-medium mb-1">
Total Penghematan
</div>
<div className="text-2xl font-bold text-green-700">
{formatCurrency(totalSavings)}
</div>
</div>
</div>
{/* Additional info */}
<div className="mt-4 p-3 bg-white/60 rounded-lg border border-green-100">
<div className="flex items-center justify-between text-sm">
<span className="text-green-600 flex items-center gap-1">
<Gift className="h-4 w-4" />
Keuntungan Bernegosiasi
</span>
<span className="text-green-700 font-medium">
Harga lebih murah dari harga normal
</span>
</div>
</div>
</CardContent>
</Card>
);
}
......@@ -15,6 +15,8 @@ interface OrderSummaryProps {
totalShipping: number;
adminFee: number;
total: number;
totalSavings: number;
isFromOffer: boolean;
processingCheckout: boolean;
allStoresReadyForCheckout: () => boolean;
handleCheckout: () => Promise<void>;
......@@ -35,7 +37,7 @@ export const OrderSummary = ({ ...props }: OrderSummaryProps) => {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 p-6">
<CardContent className="space-y-4 px-4">
<div className="space-y-2">
<div className="flex justify-between text-gray-600">
<span>Products Subtotal</span>
......@@ -49,6 +51,12 @@ export const OrderSummary = ({ ...props }: OrderSummaryProps) => {
<span>Admin Fee</span>
<span>{formatRupiah(props.adminFee)}</span>
</div>
{props.isFromOffer && props.totalSavings > 0 && (
<div className="flex justify-between text-green-600">
<span>Total Savings</span>
<span>-{formatRupiah(props.totalSavings)}</span>
</div>
)}
<Separator className="my-2 bg-amber-100" />
<div className="flex justify-between font-semibold">
<span className="text-gray-800">Total</span>
......
......@@ -67,7 +67,7 @@ export const ShippingAddressCard = ({
</span>
)}
</div>
<div className="text-sm text-gray-500">{address.no_telp}</div>
<div className="text-sm text-gray-500">{address.no_telepon}</div>
<div className="text-sm mt-1">
{address.alamat_lengkap}, {address.district?.name},{" "}
{address.regency?.name}, {address.province?.name},{" "}
......
......@@ -11,7 +11,10 @@ interface ShippingMethodCardProps {
onShippingChange: (storeIndex: number, value: string) => void;
}
export const ShippingMethodCard = ({ store, ...props }: ShippingMethodCardProps) => {
export const ShippingMethodCard = ({
store,
...props
}: ShippingMethodCardProps) => {
return (
<Card className="bg-white/95 backdrop-blur-sm border-none shadow-lg">
<CardHeader className="bg-white border-b border-amber-100/30">
......@@ -20,7 +23,9 @@ export const ShippingMethodCard = ({ store, ...props }: ShippingMethodCardProps)
<Truck className="h-5 w-5 text-white" />
</div>
<div className="flex flex-col">
<span className="text-[#F79E0E] font-semibold">Shipping Method</span>
<span className="text-[#F79E0E] font-semibold">
Shipping Method
</span>
<span className="text-sm font-normal text-gray-500">
Choose your preferred delivery option
</span>
......@@ -31,7 +36,9 @@ export const ShippingMethodCard = ({ store, ...props }: ShippingMethodCardProps)
<CardContent className="p-6">
<RadioGroup
value={store.selectedShipping || ""}
onValueChange={(value) => props.onShippingChange(props.storeIndex, value)}
onValueChange={(value) =>
props.onShippingChange(props.storeIndex, value)
}
className="space-y-4"
>
{store.shippingOptions.map((option) => (
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment