Compare commits

..

2 Commits

Author SHA1 Message Date
9190840c48 fix(wa-service): use correct API endpoint wa.wibudev.com with GET method
- Change API URL from otp.wibudev.com to wa.wibudev.com
  - otp.wibudev.com is dashboard UI, not API endpoint
  - wa.wibudev.com/code is the correct API endpoint

- Change method from POST to GET
  - API expects GET request with query params
  - Add Authorization header with Bearer token
  - API returns { status: 'success', id: '...' }

- Update .env.local:
  - WIBU_WA_API_URL=https://wa.wibudev.com

Tested with curl:
✓ GET https://wa.wibudev.com/code?nom=...&text=...
✓ Authorization: Bearer <API_KEY>
✓ Response: {"status":"success","id":"..."}

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-05 12:12:59 +08:00
781d125d4c feat(auth): migrate WhatsApp OTP to otp.wibudev.com with API Key authentication
- Create new wa-service.ts helper library
  - sendWhatsAppOtp(): Send OTP via otp.wibudev.com with Bearer token auth
  - sendWhatsAppOtpLegacy(): Deprecated legacy function for backward compat
  - Proper error handling and response validation

- Update all auth routes to use new WA service:
  - login/route.ts: Use sendWhatsAppOtp for login OTP
  - register/route.ts: Use sendWhatsAppOtp for registration OTP
  - resend/route.ts: Use sendWhatsAppOtp for resend OTP
  - send-otp-register/route.ts: Use sendWhatsAppOtp for registration

- Add environment variables to .env.local:
  - WIBU_WA_API_KEY: JWT token for authentication
  - WIBU_WA_API_URL: https://otp.wibudev.com

Benefits:
✓ Secure authentication with JWT API Key
✓ Centralized WA service for all OTP sending
✓ Better error handling and logging
✓ Consistent API response format
✓ Easy to maintain and extend

API Key Info:
- Name: website-desa-darmasaba
- Description: untuk website desa darmasaba
- Expiration: Feb 12, 2116
- Issued: Mar 05, 2026

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-05 12:07:58 +08:00
15 changed files with 307 additions and 228 deletions

View File

@@ -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"),
});

View File

@@ -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} />

View File

@@ -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) => {

View File

@@ -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,
},
});

View File

@@ -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),
}),
})

View File

@@ -15,8 +15,8 @@ type FormUpdateBody = {
name?: string;
deskripsi?: string;
jumlah?: string;
imageId?: string | null;
fileId?: string | null;
imageId: string;
fileId: string;
items: APBDesItemInput[];
};

View File

@@ -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 }
);
}

View File

@@ -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

View File

@@ -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 }
);
}

View File

@@ -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

View File

@@ -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(() => {

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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
View 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',
};
}
}