Compare commits
2 Commits
fix-tombol
...
fix-wa-otp
| Author | SHA1 | Date | |
|---|---|---|---|
| 9190840c48 | |||
| 781d125d4c |
@@ -23,9 +23,8 @@ const ApbdesFormSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
deskripsi: z.string().optional(),
|
||||
jumlah: z.string().optional(),
|
||||
// Image dan file opsional (bisa kosong)
|
||||
imageId: z.string().optional(),
|
||||
fileId: z.string().optional(),
|
||||
imageId: z.string().min(1, "Gambar wajib diunggah"),
|
||||
fileId: z.string().min(1, "File wajib diunggah"),
|
||||
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
||||
});
|
||||
|
||||
|
||||
@@ -205,6 +205,7 @@ function EditAPBDes() {
|
||||
|
||||
// Upload file baru jika ada perubahan
|
||||
if (imageFile) {
|
||||
// Hapus file lama dari form jika ada file baru
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
file: imageFile,
|
||||
name: imageFile.name,
|
||||
@@ -216,6 +217,7 @@ function EditAPBDes() {
|
||||
}
|
||||
|
||||
if (docFile) {
|
||||
// Hapus file lama dari form jika ada file baru
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
file: docFile,
|
||||
name: docFile.name,
|
||||
@@ -226,7 +228,15 @@ function EditAPBDes() {
|
||||
}
|
||||
}
|
||||
|
||||
// Image dan file sekarang opsional, tidak perlu validasi
|
||||
// Jika tidak ada file baru, gunakan ID lama (sudah ada di form)
|
||||
// Pastikan imageId dan fileId tetap ada
|
||||
if (!apbdesState.edit.form.imageId) {
|
||||
return toast.warn('Gambar wajib diunggah');
|
||||
}
|
||||
if (!apbdesState.edit.form.fileId) {
|
||||
return toast.warn('Dokumen wajib diunggah');
|
||||
}
|
||||
|
||||
const success = await apbdesState.edit.update();
|
||||
if (success) {
|
||||
router.push('/admin/landing-page/apbdes');
|
||||
@@ -333,11 +343,11 @@ function EditAPBDes() {
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Gambar & Dokumen (Opsional) */}
|
||||
{/* Gambar & Dokumen */}
|
||||
<Stack gap="xs">
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar APBDes (Opsional)
|
||||
Gambar APBDes
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={handleDrop('image')}
|
||||
@@ -377,7 +387,6 @@ function EditAPBDes() {
|
||||
onClick={() => {
|
||||
setPreviewImage(null);
|
||||
setImageFile(null);
|
||||
apbdesState.edit.form.imageId = ''; // Clear imageId from form
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
@@ -388,7 +397,7 @@ function EditAPBDes() {
|
||||
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Dokumen APBDes (Opsional)
|
||||
Dokumen APBDes
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={handleDrop('doc')}
|
||||
@@ -437,7 +446,6 @@ function EditAPBDes() {
|
||||
onClick={() => {
|
||||
setPreviewDoc(null);
|
||||
setDocFile(null);
|
||||
apbdesState.edit.form.fileId = ''; // Clear fileId from form
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
|
||||
@@ -46,9 +46,13 @@ function CreateAPBDes() {
|
||||
const [docFile, setDocFile] = useState<File | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Check if form is valid - hanya cek items, gambar dan file opsional
|
||||
// Check if form is valid
|
||||
const isFormValid = () => {
|
||||
return stateAPBDes.create.form.items.length > 0;
|
||||
return (
|
||||
imageFile !== null &&
|
||||
docFile !== null &&
|
||||
stateAPBDes.create.form.items.length > 0
|
||||
);
|
||||
};
|
||||
|
||||
// Form sementara untuk input item baru
|
||||
@@ -80,34 +84,28 @@ function CreateAPBDes() {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!imageFile || !docFile) {
|
||||
return toast.warn("Pilih gambar dan dokumen terlebih dahulu");
|
||||
}
|
||||
if (stateAPBDes.create.form.items.length === 0) {
|
||||
return toast.warn("Minimal tambahkan 1 item APBDes");
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const [uploadImageRes, uploadDocRes] = await Promise.all([
|
||||
ApiFetch.api.fileStorage.create.post({ file: imageFile, name: imageFile.name }),
|
||||
ApiFetch.api.fileStorage.create.post({ file: docFile, name: docFile.name }),
|
||||
]);
|
||||
|
||||
// Upload files hanya jika ada file yang dipilih
|
||||
let imageId = '';
|
||||
let fileId = '';
|
||||
const imageId = uploadImageRes?.data?.data?.id;
|
||||
const fileId = uploadDocRes?.data?.data?.id;
|
||||
|
||||
if (imageFile) {
|
||||
const uploadImageRes = await ApiFetch.api.fileStorage.create.post({
|
||||
file: imageFile,
|
||||
name: imageFile.name,
|
||||
});
|
||||
imageId = uploadImageRes?.data?.data?.id || '';
|
||||
if (!imageId || !fileId) {
|
||||
return toast.error("Gagal mengupload file");
|
||||
}
|
||||
|
||||
if (docFile) {
|
||||
const uploadDocRes = await ApiFetch.api.fileStorage.create.post({
|
||||
file: docFile,
|
||||
name: docFile.name,
|
||||
});
|
||||
fileId = uploadDocRes?.data?.data?.id || '';
|
||||
}
|
||||
|
||||
// Update form dengan ID file (bisa kosong)
|
||||
// Update form dengan ID file
|
||||
stateAPBDes.create.form.imageId = imageId;
|
||||
stateAPBDes.create.form.fileId = fileId;
|
||||
|
||||
@@ -176,16 +174,12 @@ function CreateAPBDes() {
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Info: File opsional */}
|
||||
<Text fz="sm" c="dimmed" mb="xs">
|
||||
* Upload gambar dan dokumen bersifat opsional. Bisa dikosongkan jika belum ada.
|
||||
</Text>
|
||||
|
||||
{/* Gambar & Dokumen (dipendekkan untuk fokus pada items) */}
|
||||
<Stack gap={"xs"}>
|
||||
{/* Gambar APBDes */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar APBDes (Opsional)
|
||||
Gambar APBDes
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
@@ -255,10 +249,10 @@ function CreateAPBDes() {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Dokumen APBDes (Opsional) */}
|
||||
{/* Dokumen APBDes */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Dokumen APBDes (Opsional)
|
||||
Dokumen APBDes
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
|
||||
@@ -17,8 +17,8 @@ type FormCreate = {
|
||||
name?: string;
|
||||
deskripsi?: string;
|
||||
jumlah?: string;
|
||||
imageId?: string | null; // Opsional
|
||||
fileId?: string | null; // Opsional
|
||||
imageId: string;
|
||||
fileId: string;
|
||||
items: APBDesItemInput[];
|
||||
};
|
||||
|
||||
@@ -32,7 +32,12 @@ export default async function apbdesCreate(context: Context) {
|
||||
if (!body.tahun) {
|
||||
throw new Error('Tahun is required');
|
||||
}
|
||||
// Image dan file sekarang opsional
|
||||
if (!body.imageId) {
|
||||
throw new Error('Image ID is required');
|
||||
}
|
||||
if (!body.fileId) {
|
||||
throw new Error('File ID is required');
|
||||
}
|
||||
if (!body.items || body.items.length === 0) {
|
||||
throw new Error('At least one item is required');
|
||||
}
|
||||
@@ -45,8 +50,8 @@ export default async function apbdesCreate(context: Context) {
|
||||
name: body.name || `APBDes Tahun ${body.tahun}`,
|
||||
deskripsi: body.deskripsi,
|
||||
jumlah: body.jumlah,
|
||||
imageId: body.imageId || null, // null jika tidak ada
|
||||
fileId: body.fileId || null, // null jika tidak ada
|
||||
imageId: body.imageId,
|
||||
fileId: body.fileId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ const APBDes = new Elysia({
|
||||
name: t.Optional(t.String()),
|
||||
deskripsi: t.Optional(t.String()),
|
||||
jumlah: t.Optional(t.String()),
|
||||
imageId: t.Optional(t.String()),
|
||||
fileId: t.Optional(t.String()),
|
||||
imageId: t.String(),
|
||||
fileId: t.String(),
|
||||
items: t.Array(ApbdesItemSchema),
|
||||
}),
|
||||
})
|
||||
@@ -50,8 +50,8 @@ const APBDes = new Elysia({
|
||||
name: t.Optional(t.String()),
|
||||
deskripsi: t.Optional(t.String()),
|
||||
jumlah: t.Optional(t.String()),
|
||||
imageId: t.Optional(t.String()),
|
||||
fileId: t.Optional(t.String()),
|
||||
imageId: t.String(),
|
||||
fileId: t.String(),
|
||||
items: t.Array(ApbdesItemSchema),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -15,8 +15,8 @@ type FormUpdateBody = {
|
||||
name?: string;
|
||||
deskripsi?: string;
|
||||
jumlah?: string;
|
||||
imageId?: string | null;
|
||||
fileId?: string | null;
|
||||
imageId: string;
|
||||
fileId: string;
|
||||
items: APBDesItemInput[];
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import prisma from "@/lib/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomOTP } from "../_lib/randomOTP";
|
||||
import { cookies } from "next/headers";
|
||||
import { sendWhatsAppOtp } from "@/lib/wa-service";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (req.method !== "POST") {
|
||||
@@ -34,31 +35,22 @@ export async function POST(req: Request) {
|
||||
const otpNumber = Number(codeOtp);
|
||||
|
||||
const waMessage = `Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.\n\n>> Kode OTP anda: ${codeOtp}.`;
|
||||
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
|
||||
|
||||
console.log("🔍 Debug WA URL:", waUrl);
|
||||
|
||||
try {
|
||||
const res = await fetch(waUrl);
|
||||
const sendWa = await res.json();
|
||||
console.log("📱 WA Response:", sendWa);
|
||||
|
||||
if (sendWa.status !== "success") {
|
||||
console.error("❌ WA Service Error:", sendWa);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "Gagal mengirim OTP via WhatsApp",
|
||||
debug: sendWa
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (waError) {
|
||||
console.error("❌ Fetch WA Error:", waError);
|
||||
// Send OTP via WhatsApp using authenticated API
|
||||
const waResult = await sendWhatsAppOtp({
|
||||
nomor,
|
||||
message: waMessage,
|
||||
});
|
||||
|
||||
if (!waResult.success) {
|
||||
console.error("❌ WA Service Error:", waResult);
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Terjadi kesalahan saat mengirim WA" },
|
||||
{ status: 500 }
|
||||
{
|
||||
success: false,
|
||||
message: waResult.message || "Gagal mengirim OTP via WhatsApp",
|
||||
debug: waResult.data
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { randomOTP } from '../_lib/randomOTP';
|
||||
import { sendWhatsAppOtp } from '@/lib/wa-service';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -24,12 +25,18 @@ export async function POST(req: Request) {
|
||||
const otpNumber = Number(codeOtp);
|
||||
|
||||
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
|
||||
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
|
||||
const waRes = await fetch(waUrl);
|
||||
const waData = await waRes.json();
|
||||
|
||||
if (waData.status !== "success") {
|
||||
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP via WhatsApp' }, { status: 400 });
|
||||
// Send OTP via WhatsApp using authenticated API
|
||||
const waResult = await sendWhatsAppOtp({
|
||||
nomor,
|
||||
message: waMessage,
|
||||
});
|
||||
|
||||
if (!waResult.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: waResult.message || 'Gagal mengirim OTP via WhatsApp', debug: waResult.data },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Simpan OTP ke database
|
||||
|
||||
@@ -3,6 +3,7 @@ import prisma from "@/lib/prisma";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomOTP } from "../_lib/randomOTP";
|
||||
import { sendWhatsAppOtp } from "@/lib/wa-service";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -18,15 +19,17 @@ export async function POST(req: Request) {
|
||||
const codeOtp = randomOTP();
|
||||
const otpNumber = Number(codeOtp);
|
||||
|
||||
// Kirim OTP via WhatsApp
|
||||
// Kirim OTP via WhatsApp menggunakan API terautentikasi
|
||||
const waMessage = `Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.\n\n>> Kode OTP anda: ${codeOtp}.`;
|
||||
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
|
||||
const waRes = await fetch(waUrl);
|
||||
const waData = await waRes.json();
|
||||
|
||||
if (waData.status !== "success") {
|
||||
const waResult = await sendWhatsAppOtp({
|
||||
nomor,
|
||||
message: waMessage,
|
||||
});
|
||||
|
||||
if (!waResult.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Gagal mengirim OTP via WhatsApp" },
|
||||
{ success: false, message: waResult.message || "Gagal mengirim OTP via WhatsApp", debug: waResult.data },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomOTP } from "../_lib/randomOTP";
|
||||
import { sendWhatsAppOtp } from "@/lib/wa-service";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -22,13 +23,18 @@ export async function POST(req: Request) {
|
||||
const codeOtp = randomOTP();
|
||||
const otpNumber = Number(codeOtp);
|
||||
|
||||
// Kirim WA
|
||||
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
|
||||
const res = await fetch(waUrl);
|
||||
const sendWa = await res.json();
|
||||
// Kirim WA menggunakan API terautentikasi
|
||||
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
|
||||
const waResult = await sendWhatsAppOtp({
|
||||
nomor,
|
||||
message: waMessage,
|
||||
});
|
||||
|
||||
if (sendWa.status !== "success") {
|
||||
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP' }, { status: 400 });
|
||||
if (!waResult.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: waResult.message || 'Gagal mengirim OTP', debug: waResult.data },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Simpan OTP
|
||||
|
||||
@@ -82,12 +82,6 @@ export function MusicProvider({ children }: { children: ReactNode }) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const isSeekingRef = useRef(false);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const isRepeatRef = useRef(false); // Ref untuk avoid stale closure
|
||||
|
||||
// Sync ref dengan state
|
||||
useEffect(() => {
|
||||
isRepeatRef.current = isRepeat;
|
||||
}, [isRepeat]);
|
||||
|
||||
// Load musik data
|
||||
const loadMusikData = useCallback(async () => {
|
||||
@@ -117,8 +111,7 @@ export function MusicProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
|
||||
audioRef.current.addEventListener('ended', () => {
|
||||
// Gunakan ref untuk avoid stale closure
|
||||
if (isRepeatRef.current) {
|
||||
if (isRepeat) {
|
||||
audioRef.current!.currentTime = 0;
|
||||
audioRef.current!.play();
|
||||
} else {
|
||||
@@ -139,7 +132,7 @@ export function MusicProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- playNext is intentionally not in deps to avoid circular dependency
|
||||
}, [loadMusikData]); // Remove isRepeat dari deps karena sudah pakai ref
|
||||
}, [loadMusikData, isRepeat]);
|
||||
|
||||
// Update time with requestAnimationFrame for smooth progress
|
||||
const updateTime = useCallback(() => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
Paper,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowsShuffle,
|
||||
IconMusic,
|
||||
IconPlayerPauseFilled,
|
||||
IconPlayerPlayFilled,
|
||||
IconPlayerSkipBackFilled,
|
||||
@@ -47,7 +45,7 @@ export default function FixedPlayerBar() {
|
||||
} = useMusic();
|
||||
|
||||
const [showVolume, setShowVolume] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [isPlayerVisible, setIsPlayerVisible] = useState(true);
|
||||
|
||||
// Format time
|
||||
const formatTime = (seconds: number) => {
|
||||
@@ -71,55 +69,12 @@ export default function FixedPlayerBar() {
|
||||
toggleShuffle();
|
||||
};
|
||||
|
||||
// Handle minimize player (show floating icon)
|
||||
const handleMinimizePlayer = () => {
|
||||
setIsMinimized(true);
|
||||
// Handle close player
|
||||
const handleClosePlayer = () => {
|
||||
setIsPlayerVisible(false);
|
||||
};
|
||||
|
||||
// Handle restore player from floating icon
|
||||
const handleRestorePlayer = () => {
|
||||
setIsMinimized(false);
|
||||
};
|
||||
|
||||
// If minimized, show floating icon instead of player bar
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<>
|
||||
{/* Floating Music Icon - Shows when player is minimized */}
|
||||
<Button
|
||||
color="#0B4F78"
|
||||
variant="filled"
|
||||
size="md"
|
||||
mt="md"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '50%', // Menempatkan titik atas ikon di tengah layar
|
||||
left: '0px',
|
||||
transform: 'translateY(-50%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah
|
||||
borderBottomRightRadius: '20px',
|
||||
borderTopRightRadius: '20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
zIndex: 1
|
||||
}}
|
||||
onClick={handleRestorePlayer}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-50%)';
|
||||
}}
|
||||
>
|
||||
<IconMusic size={28} color="white" />
|
||||
</Button>
|
||||
|
||||
{/* Spacer to prevent content from being hidden behind player */}
|
||||
<Box h={20} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentSong) {
|
||||
if (!currentSong || !isPlayerVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -134,12 +89,12 @@ export default function FixedPlayerBar() {
|
||||
p="sm"
|
||||
shadow="lg"
|
||||
style={{
|
||||
zIndex: 1,
|
||||
zIndex: 1000,
|
||||
borderTop: '1px solid rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<Flex align="center" gap="md" justify="space-between">
|
||||
{/* Song Info - Left */}
|
||||
{/* Song Info */}
|
||||
<Group gap="sm" flex={1} style={{ minWidth: 0 }}>
|
||||
<Avatar
|
||||
src={currentSong.coverImage?.link || ''}
|
||||
@@ -158,81 +113,78 @@ export default function FixedPlayerBar() {
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{/* Controls + Progress - Center */}
|
||||
<Group gap="xs" flex={2} justify="center">
|
||||
{/* Control Buttons */}
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant={isShuffle ? 'filled' : 'subtle'}
|
||||
color={isShuffle ? 'blue' : 'gray'}
|
||||
size="lg"
|
||||
onClick={handleToggleShuffle}
|
||||
title="Shuffle"
|
||||
>
|
||||
<IconArrowsShuffle size={18} />
|
||||
</ActionIcon>
|
||||
{/* Controls */}
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant={isShuffle ? 'filled' : 'subtle'}
|
||||
color={isShuffle ? 'blue' : 'gray'}
|
||||
size="lg"
|
||||
onClick={handleToggleShuffle}
|
||||
title="Shuffle"
|
||||
>
|
||||
<IconArrowsShuffle size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={playPrev}
|
||||
title="Previous"
|
||||
>
|
||||
<IconPlayerSkipBackFilled size={20} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={playPrev}
|
||||
title="Previous"
|
||||
>
|
||||
<IconPlayerSkipBackFilled size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color={isPlaying ? 'blue' : 'gray'}
|
||||
size="xl"
|
||||
radius="xl"
|
||||
onClick={togglePlayPause}
|
||||
title={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<IconPlayerPauseFilled size={24} />
|
||||
) : (
|
||||
<IconPlayerPlayFilled size={24} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color={isPlaying ? 'blue' : 'gray'}
|
||||
size="xl"
|
||||
radius="xl"
|
||||
onClick={togglePlayPause}
|
||||
title={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<IconPlayerPauseFilled size={24} />
|
||||
) : (
|
||||
<IconPlayerPlayFilled size={24} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={playNext}
|
||||
title="Next"
|
||||
>
|
||||
<IconPlayerSkipForwardFilled size={20} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={playNext}
|
||||
title="Next"
|
||||
>
|
||||
<IconPlayerSkipForwardFilled size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={isRepeat ? 'blue' : 'gray'}
|
||||
size="lg"
|
||||
onClick={toggleRepeat}
|
||||
title={isRepeat ? 'Repeat On' : 'Repeat Off'}
|
||||
>
|
||||
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{/* Progress Bar - Desktop */}
|
||||
<Box w={200} display={{ base: 'none', md: 'block' }}>
|
||||
<Slider
|
||||
value={currentTime}
|
||||
max={duration || 100}
|
||||
onChange={handleSeek}
|
||||
size="sm"
|
||||
color="blue"
|
||||
label={(value) => formatTime(value)}
|
||||
/>
|
||||
</Box>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={isRepeat ? 'blue' : 'gray'}
|
||||
size="lg"
|
||||
onClick={toggleRepeat}
|
||||
title={isRepeat ? 'Repeat On' : 'Repeat Off'}
|
||||
>
|
||||
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{/* Right Controls - Volume + Close */}
|
||||
<Group gap="xs" flex={1} justify="flex-end">
|
||||
{/* Progress Bar - Desktop */}
|
||||
<Box w={200} display={{ base: 'none', md: 'block' }}>
|
||||
<Slider
|
||||
value={currentTime}
|
||||
max={duration || 100}
|
||||
onChange={handleSeek}
|
||||
size="sm"
|
||||
color="blue"
|
||||
label={(value) => formatTime(value)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Right Controls */}
|
||||
<Group gap="xs">
|
||||
<Box
|
||||
onMouseEnter={() => setShowVolume(true)}
|
||||
onMouseLeave={() => setShowVolume(false)}
|
||||
@@ -289,8 +241,8 @@ export default function FixedPlayerBar() {
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
onClick={handleMinimizePlayer}
|
||||
title="Minimize player"
|
||||
onClick={handleClosePlayer}
|
||||
title="Close player"
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import { Button } from '@mantine/core';
|
||||
import { IconDisabled, IconDisabledOff } from '@tabler/icons-react';
|
||||
import { IconMusic, IconMusicOff } from '@tabler/icons-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const NewsReaderLanding = () => {
|
||||
@@ -95,17 +95,15 @@ const NewsReaderLanding = () => {
|
||||
mt="md"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '50%', // Menempatkan titik atas ikon di tengah layar
|
||||
bottom: '350px',
|
||||
left: '0px',
|
||||
transform: 'translateY(80%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah
|
||||
borderBottomRightRadius: '20px',
|
||||
borderTopRightRadius: '20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
transition: 'all 0.3s ease',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
{isPointerMode ? <IconDisabledOff /> : <IconDisabled />}
|
||||
{isPointerMode ? <IconMusicOff /> : <IconMusic />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function GrafikRealisasi({ apbdesData }: any) {
|
||||
<Summary title="📊 Pembiayaan" data={pembiayaan} />
|
||||
</Stack>
|
||||
|
||||
{/* Summary Total Keseluruhan
|
||||
{/* Summary Total Keseluruhan */}
|
||||
<Box p="md" bg="gray.0">
|
||||
<>
|
||||
<Group justify="space-between" mb="xs">
|
||||
@@ -132,7 +132,7 @@ export default function GrafikRealisasi({ apbdesData }: any) {
|
||||
color={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}
|
||||
/>
|
||||
</>
|
||||
</Box> */}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
122
src/lib/wa-service.ts
Normal file
122
src/lib/wa-service.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* WhatsApp Service - Send OTP via WhatsApp using otp.wibudev.com API
|
||||
* Uses API Key authentication for secure access
|
||||
*/
|
||||
|
||||
interface SendWaOtpParams {
|
||||
nomor: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface SendWaOtpResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send WhatsApp message using wibudev.com API with authentication
|
||||
* @param params - { nomor: string, message: string }
|
||||
* @returns Promise<SendWaOtpResponse>
|
||||
*/
|
||||
export async function sendWhatsAppOtp({
|
||||
nomor,
|
||||
message,
|
||||
}: SendWaOtpParams): Promise<SendWaOtpResponse> {
|
||||
const apiKey = process.env.WIBU_WA_API_KEY;
|
||||
const waApiUrl = process.env.WIBU_WA_API_URL || 'https://wa.wibudev.com';
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('❌ WIBU_WA_API_KEY is not configured');
|
||||
return {
|
||||
success: false,
|
||||
message: 'WhatsApp API key not configured',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Using the API endpoint with authentication
|
||||
// Format: GET https://wa.wibudev.com/code?nom=...&text=...
|
||||
// With Authorization header for API key
|
||||
const url = `${waApiUrl}/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(message)}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('❌ WA API Error:', result);
|
||||
return {
|
||||
success: false,
|
||||
message: result.message || 'Failed to send WhatsApp message',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if response has success status
|
||||
if (result.status !== 'success') {
|
||||
console.error('❌ WA API Status Error:', result);
|
||||
return {
|
||||
success: false,
|
||||
message: result.message || 'Failed to send WhatsApp message',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
console.log('✅ WA Response:', result);
|
||||
return {
|
||||
success: true,
|
||||
message: 'WhatsApp sent successfully',
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Fetch WA API Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy function for backward compatibility (deprecated)
|
||||
* @deprecated Use sendWhatsAppOtp instead
|
||||
*/
|
||||
export async function sendWhatsAppOtpLegacy({
|
||||
nomor,
|
||||
message,
|
||||
}: SendWaOtpParams): Promise<SendWaOtpResponse> {
|
||||
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(message)}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(waUrl);
|
||||
const result = await res.json();
|
||||
|
||||
if (result.status !== 'success') {
|
||||
console.error('❌ WA Legacy Error:', result);
|
||||
return {
|
||||
success: false,
|
||||
message: result.message || 'Failed to send WhatsApp message',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'WhatsApp sent successfully',
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Fetch WA Legacy Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user