- ✅
- ✅ - ✅ - ✅
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { LIMITS } from "@/lib/limits";
|
||||
|
||||
/**
|
||||
* Item itinerary terstruktur. Format jam: "HH:mm" 24-jam.
|
||||
* Bentuk ini dipakai di form, action, dan render — DB-nya pakai
|
||||
* `TripItineraryItem` (lihat schema.prisma).
|
||||
*/
|
||||
export interface ItineraryItemInput {
|
||||
day: number;
|
||||
startTime: string;
|
||||
endTime?: string | null;
|
||||
activity: string;
|
||||
}
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
export function isValidTimeFormat(value: string): boolean {
|
||||
return TIME_RE.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Konversi "HH:mm" ke total menit sejak 00:00. Pakai untuk perbandingan
|
||||
* jam mulai vs jam selesai. Mengembalikan NaN kalau format invalid.
|
||||
*/
|
||||
export function timeToMinutes(value: string): number {
|
||||
if (!isValidTimeFormat(value)) return Number.NaN;
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format display jam (sudah HH:mm di DB, sekedar pass-through dengan
|
||||
* trimming defensif).
|
||||
*/
|
||||
export function formatItineraryTime(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kelompokkan items per hari, urut ascending. Item dalam satu hari diurut
|
||||
* berdasarkan `order` lalu `startTime`.
|
||||
*/
|
||||
export function groupItineraryByDay<
|
||||
T extends { day: number; order: number; startTime: string },
|
||||
>(items: T[]): Map<number, T[]> {
|
||||
const grouped = new Map<number, T[]>();
|
||||
for (const item of items) {
|
||||
const list = grouped.get(item.day) ?? [];
|
||||
list.push(item);
|
||||
grouped.set(item.day, list);
|
||||
}
|
||||
for (const list of grouped.values()) {
|
||||
list.sort((a, b) => {
|
||||
if (a.order !== b.order) return a.order - b.order;
|
||||
return timeToMinutes(a.startTime) - timeToMinutes(b.startTime);
|
||||
});
|
||||
}
|
||||
return new Map(
|
||||
[...grouped.entries()].sort(([a], [b]) => a - b)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validasi semantik (selain Zod): jam selesai harus >= jam mulai (kalau diisi),
|
||||
* dan jumlah day harus rapat (1..N tanpa lompat) untuk menjaga UX builder
|
||||
* tetap deterministik. Return error pertama yang ditemui, atau null kalau OK.
|
||||
*/
|
||||
export function validateItineraryItems(
|
||||
items: ItineraryItemInput[]
|
||||
): string | null {
|
||||
if (items.length === 0) return null;
|
||||
if (items.length > LIMITS.MAX_ITINERARY_ITEMS) {
|
||||
return `Maksimal ${LIMITS.MAX_ITINERARY_ITEMS} aktivitas total`;
|
||||
}
|
||||
|
||||
const days = new Set<number>();
|
||||
for (const item of items) {
|
||||
if (!Number.isInteger(item.day) || item.day < 1) {
|
||||
return "Nomor hari tidak valid";
|
||||
}
|
||||
if (item.day > LIMITS.MAX_ITINERARY_DAYS) {
|
||||
return `Maksimal ${LIMITS.MAX_ITINERARY_DAYS} hari`;
|
||||
}
|
||||
if (!isValidTimeFormat(item.startTime)) {
|
||||
return "Format jam mulai harus HH:mm (00:00 – 23:59)";
|
||||
}
|
||||
if (item.endTime && !isValidTimeFormat(item.endTime)) {
|
||||
return "Format jam selesai harus HH:mm (00:00 – 23:59)";
|
||||
}
|
||||
if (
|
||||
item.endTime &&
|
||||
timeToMinutes(item.endTime) < timeToMinutes(item.startTime)
|
||||
) {
|
||||
return "Jam selesai tidak boleh sebelum jam mulai";
|
||||
}
|
||||
const trimmedActivity = item.activity.trim();
|
||||
if (trimmedActivity.length === 0) {
|
||||
return "Deskripsi aktivitas harus diisi";
|
||||
}
|
||||
if (trimmedActivity.length > LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH) {
|
||||
return `Aktivitas maksimal ${LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH} karakter`;
|
||||
}
|
||||
days.add(item.day);
|
||||
}
|
||||
|
||||
const sortedDays = [...days].sort((a, b) => a - b);
|
||||
for (let i = 0; i < sortedDays.length; i++) {
|
||||
if (sortedDays[i] !== i + 1) {
|
||||
return "Nomor hari harus berurutan mulai dari Hari 1 (tidak boleh lompat)";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -13,6 +13,12 @@ export const LIMITS = {
|
||||
/** Meeting point & tiap blok include/exclude */
|
||||
MAX_MEETING_POINT_LENGTH: 500,
|
||||
MAX_TRIP_ITINERARY_LENGTH: 8000,
|
||||
/** Itinerary baru terstruktur: maksimal hari dalam satu trip. */
|
||||
MAX_ITINERARY_DAYS: 14,
|
||||
/** Maksimal item itinerary per trip (lintas hari). */
|
||||
MAX_ITINERARY_ITEMS: 60,
|
||||
/** Maksimal panjang deskripsi satu aktivitas. */
|
||||
MAX_ITINERARY_ACTIVITY_LENGTH: 200,
|
||||
MAX_TRIP_BULLET_SECTION_LENGTH: 4000,
|
||||
MAX_REVIEW_COMMENT: 500,
|
||||
MAX_IMAGE_URLS: 5,
|
||||
|
||||
@@ -42,6 +42,11 @@ export const MIDTRANS = {
|
||||
isProduction()
|
||||
? "https://app.midtrans.com/snap/snap.js"
|
||||
: "https://app.sandbox.midtrans.com/snap/snap.js",
|
||||
/** Core API base — dipakai untuk GET /v2/{order_id}/status (rekonsiliasi). */
|
||||
coreApiBase: () =>
|
||||
isProduction()
|
||||
? "https://api.midtrans.com/v2"
|
||||
: "https://api.sandbox.midtrans.com/v2",
|
||||
};
|
||||
|
||||
function requireServerKey(): string {
|
||||
@@ -70,6 +75,9 @@ interface SnapTransactionPayload {
|
||||
itemName: string;
|
||||
/// Berapa detik sampai expire. Default Midtrans 24 jam, kita pakai itu kalau undefined.
|
||||
expirySeconds?: number;
|
||||
/// URL absolut untuk redirect user setelah selesai bayar (success / pending / error).
|
||||
/// Tanpa ini, Midtrans pakai default `example.com`.
|
||||
finishUrl?: string;
|
||||
}
|
||||
|
||||
export interface SnapTransactionResult {
|
||||
@@ -110,6 +118,10 @@ export async function createSnapTransaction(
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.finishUrl) {
|
||||
body.callbacks = { finish: payload.finishUrl };
|
||||
}
|
||||
|
||||
const res = await fetch(`${MIDTRANS.snapApiBase()}/transactions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -137,6 +149,67 @@ export async function createSnapTransaction(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bentuk minimal response dari Midtrans Core API GET /v2/{order_id}/status.
|
||||
* Sub-set field yang kita pakai untuk rekonsiliasi (sama dengan field webhook).
|
||||
* https://docs.midtrans.com/reference/get-transaction-status
|
||||
*/
|
||||
export interface MidtransTransactionStatus {
|
||||
order_id: string;
|
||||
status_code: string;
|
||||
transaction_status: string;
|
||||
gross_amount: string;
|
||||
transaction_id?: string;
|
||||
payment_type?: string;
|
||||
fraud_status?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch status transaksi langsung dari Midtrans untuk rekonsiliasi server-side.
|
||||
* Dipakai saat kita tidak bisa mengandalkan webhook (mis. dev di localhost,
|
||||
* atau webhook tertunda). Auth pakai server key — response sudah terpercaya
|
||||
* karena datang dari Midtrans atas request kita, jadi tidak perlu verifikasi
|
||||
* signature.
|
||||
*
|
||||
* Return null kalau Midtrans tidak menemukan order (404).
|
||||
*/
|
||||
export async function fetchMidtransTransactionStatus(
|
||||
orderId: string
|
||||
): Promise<MidtransTransactionStatus | null> {
|
||||
const res = await fetch(
|
||||
`${MIDTRANS.coreApiBase()}/${encodeURIComponent(orderId)}/status`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: basicAuthHeader(),
|
||||
},
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
if (res.status === 404) return null;
|
||||
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
| (Partial<MidtransTransactionStatus> & { status_message?: string })
|
||||
| null;
|
||||
|
||||
if (!res.ok || !json?.order_id || !json.transaction_status) {
|
||||
const reason = json?.status_message ?? `HTTP ${res.status}`;
|
||||
throw new Error(`Midtrans status fetch gagal: ${reason}`);
|
||||
}
|
||||
|
||||
return {
|
||||
order_id: json.order_id,
|
||||
status_code: json.status_code ?? String(res.status),
|
||||
transaction_status: json.transaction_status,
|
||||
gross_amount: json.gross_amount ?? "0",
|
||||
transaction_id: json.transaction_id,
|
||||
payment_type: json.payment_type,
|
||||
fraud_status: json.fraud_status ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifikasi signature webhook Midtrans.
|
||||
* Formula: SHA512(order_id + status_code + gross_amount + serverKey).
|
||||
|
||||
Reference in New Issue
Block a user