feat: integrate landing page with real API and add CMS to backoffice

Landing page:
- Events page now fetches from /v1/landing/cms/events (was hardcoded JSON)
- Testimonials section and page fetch from /v1/landing/cms/testimonials
- Removed dummy data, uses ISR with 60s revalidation
- Replaced thumbnail images with text-based cards (API has no thumbnails)
- Avatar initials for testimonials instead of placeholder images

Backoffice CMS:
- Added Events management page (/cms-events) with full CRUD
- Added Testimonials management page (/cms-testimonials) with full CRUD
- Both follow existing backoffice patterns (DataTable, modals, search, pagination)
- Added CMS section to sidebar navigation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-04-10 14:42:24 +07:00
co-authored by Claude Opus 4.6
parent 414e4d19b8
commit 2cba806cd5
14 changed files with 1697 additions and 112 deletions
@@ -0,0 +1,202 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { useItem, useConfirmItem } from '../_hook/use-item';
interface IModalAddEvent {
isOpen: boolean;
onClose: () => void;
handleAdd?: () => Promise<boolean>;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
onDataCapture?: (data: any) => void;
}
const ModalAddEvent = ({
isOpen,
onClose,
currentStep,
nextStep,
resetStep,
handleAdd,
onDataCapture,
}: IModalAddEvent) => {
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-0 text-center"
isOpen={isOpen}
onClose={() => {
onClose();
resetStep();
}}
disableEscapeKeyDown={true}
>
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} onDataCapture={onDataCapture} />}
{currentStep === 2 && (
<StepTwo
onClose={onClose}
handleAdd={handleAdd}
resetStep={resetStep}
/>
)}
</Modal>
);
};
interface IStepOneProps {
nextStep: () => void;
onClose: () => void;
onDataCapture?: (data: any) => void;
}
const StepOne = ({ nextStep, onDataCapture }: IStepOneProps) => {
const { form, onSubmit } = useItem(nextStep, undefined, onDataCapture);
return (
<>
<Modal.Header>
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Tambah Event
</h2>
</Modal.Header>
<Modal.Content>
<form onSubmit={onSubmit} className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<ControlledInputField
control={form.control}
label="Nama Event"
name="name"
type="text"
placeholder="Masukkan Nama Event"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Deskripsi"
name="description"
type="text"
placeholder="Masukkan Deskripsi Event"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Link Detail"
name="detail_link"
type="text"
placeholder="Masukkan Link Detail"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Lokasi"
name="location"
type="text"
placeholder="Masukkan Lokasi"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Harga"
name="price"
type="number"
placeholder="Masukkan Harga"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Tanggal Mulai"
name="start_date"
type="date"
placeholder="Pilih Tanggal Mulai"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Tanggal Selesai"
name="end_date"
type="date"
placeholder="Pilih Tanggal Selesai"
size="lg"
className="w-full"
/>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_online"
className="rounded"
{...form.register('is_online')}
/>
<label htmlFor="is_online" className="text-p3 font-medium text-neutral-800">
Event Online
</label>
</div>
</div>
<Button variant="primary" size="lg" className="w-full" type="submit">
Tambah Event
</Button>
</form>
</Modal.Content>
</>
);
};
interface IStepTwoProps {
onClose: () => void;
handleAdd?: () => Promise<boolean>;
resetStep: () => void;
}
const StepTwo = ({ onClose, handleAdd, resetStep }: IStepTwoProps) => {
const { onConfirm, onCancel } = useConfirmItem(
onClose,
resetStep,
handleAdd,
{
success: 'Data event berhasil ditambahkan',
error: 'Data event gagal ditambahkan',
}
);
return (
<>
<Modal.Header className="mb-10 text-center items-center">
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Tambah Event
</h2>
<p className="text-p3 text-center text-neutral-400">
Apakah kamu yakin ingin
<br /> menambahkan event ini?
</p>
</Modal.Header>
<Modal.Content className="flex mb-0 gap-4">
<Button
variant="bordered"
size="lg"
className="w-full"
onClick={onCancel}
>
Batal
</Button>
<Button
variant="primary"
size="lg"
className="w-full"
onClick={onConfirm}
>
Tambahkan
</Button>
</Modal.Content>
</>
);
};
export default ModalAddEvent;
@@ -0,0 +1,72 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { useConfirmItem } from '../_hook/use-item';
interface IModalDeleteEvent {
isOpen: boolean;
onClose: () => void;
handleDelete?: () => Promise<boolean>;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
}
const ModalDeleteEvent = ({
isOpen,
onClose,
resetStep,
handleDelete,
}: IModalDeleteEvent) => {
const { onConfirm } = useConfirmItem(onClose, resetStep, handleDelete, {
success: 'Data event berhasil dihapus',
error: 'Data event gagal dihapus',
});
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
isOpen={isOpen}
onClose={onClose}
closeButtonClassName="hidden"
>
<Modal.Header className="gap-8">
<img
src="/chibi-delete.webp"
alt="Delete?"
width={148}
className="self-center"
/>
<div className="text-center">
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
Delete Event
</h2>
<p className="text-p3 text-neutral-400">
Apakah kamu yakin untuk menghapus event ini? Menghapus data ini
mungkin akan mempengaruhi fungsional sistem
</p>
</div>
</Modal.Header>
<Modal.Content className="flex gap-4">
<Button
variant="secondary"
size="lg"
className="w-full"
onClick={onClose}
>
Batal Hapus
</Button>
<Button
variant="primary"
size="lg"
className="w-full bg-danger-500 hover:bg-danger-600"
onClick={onConfirm}
>
Hapus Event
</Button>
</Modal.Content>
</Modal>
);
};
export default ModalDeleteEvent;
@@ -0,0 +1,166 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
interface IModalUpdateEvent {
isOpen: boolean;
onClose: () => void;
handleUpdate?: () => Promise<boolean>;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
initialValues?: {
name?: string;
description?: string;
detail_link?: string;
location?: string;
price?: number;
start_date?: string;
end_date?: string;
is_online?: boolean;
};
onDataCapture?: (data: any) => void;
}
const ModalUpdateEvent = ({
isOpen,
onClose,
resetStep,
handleUpdate,
initialValues,
onDataCapture,
}: IModalUpdateEvent) => {
const form = useForm<any>({
mode: 'all',
defaultValues: initialValues,
});
useEffect(() => {
if (isOpen) {
form.reset(initialValues);
}
}, [isOpen, initialValues]);
const onSubmit = form.handleSubmit(async (data) => {
try {
onDataCapture?.(data);
if (handleUpdate) await handleUpdate();
toast.success('Perubahan event berhasil dilakukan');
onClose();
resetStep();
} catch (error) {
console.log(error);
toast.error('Perubahan event gagal dilakukan');
}
});
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-0 text-center"
isOpen={isOpen}
onClose={onClose}
disableEscapeKeyDown={true}
>
<Modal.Header>
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Update Event
</h2>
</Modal.Header>
<Modal.Content className="flex flex-col gap-8">
<form onSubmit={onSubmit} className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<ControlledInputField
control={form.control}
label="Nama Event"
name="name"
type="text"
placeholder="Masukkan Nama Event"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Deskripsi"
name="description"
type="text"
placeholder="Masukkan Deskripsi Event"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Link Detail"
name="detail_link"
type="text"
placeholder="Masukkan Link Detail"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Lokasi"
name="location"
type="text"
placeholder="Masukkan Lokasi"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Harga"
name="price"
type="number"
placeholder="Masukkan Harga"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Tanggal Mulai"
name="start_date"
type="date"
placeholder="Pilih Tanggal Mulai"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Tanggal Selesai"
name="end_date"
type="date"
placeholder="Pilih Tanggal Selesai"
size="lg"
className="w-full"
/>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_online_update"
className="rounded"
{...form.register('is_online')}
/>
<label htmlFor="is_online_update" className="text-p3 font-medium text-neutral-800">
Event Online
</label>
</div>
</div>
<Button
variant="primary"
size="lg"
className="w-full"
type="submit"
>
Update Event
</Button>
</form>
</Modal.Content>
</Modal>
);
};
export default ModalUpdateEvent;
@@ -0,0 +1,57 @@
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
export const useItem = (
nextStep: () => void,
initialValues?: any,
onDataCapture?: (data: any) => void,
) => {
const form = useForm<any>({
mode: 'all',
defaultValues: initialValues,
});
const onSubmit = form.handleSubmit((data) => {
onDataCapture?.(data);
nextStep();
});
return {
form,
onSubmit,
};
};
export const useConfirmItem = (
onClose: () => void,
resetStep: () => void,
actionFunction?: () => Promise<boolean>,
messages?: {
success?: string;
error?: string;
}
) => {
const onConfirm = async () => {
try {
if (actionFunction) {
await actionFunction();
}
toast.success(messages?.success);
onClose();
resetStep();
} catch (error) {
console.log(error);
toast.error(messages?.error);
}
};
const onCancel = () => {
onClose();
resetStep();
};
return {
onConfirm,
onCancel,
};
};
@@ -0,0 +1,288 @@
import { FC, Fragment, ReactElement, useRef, useState } from 'react';
import {
SearchOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import ModalAddEvent from './_components/modal-add-event';
import ModalUpdateEvent from './_components/modal-update-event';
import ModalDeleteEvent from './_components/modal-delete-event';
import { useQueryState } from '@imphnen-frontend-service/utils';
import {
useEventList,
useCreateEvent,
useUpdateEvent,
useDeleteEvent,
TEventsListItem,
} from '@imphnen-frontend-service/service';
import React from 'react';
export const Components: FC = (): ReactElement => {
const [showModalAddItem, setShowModalAddItem] = useState(false);
const [showModalUpdateItem, setShowModalUpdateItem] = useState(false);
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
const [selectedEvent, setSelectedEvent] = useState<TEventsListItem | null>(null);
const [search, setSearch] = useState('');
const pendingFormData = useRef<any>(null);
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('step', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const { data: eventsData, isLoading } = useEventList({
search,
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
});
const createEvent = useCreateEvent();
const updateEvent = useUpdateEvent();
const deleteEvent = useDeleteEvent();
const events: TEventsListItem[] = eventsData?.data ?? [];
const totalItems = eventsData?.meta?.total ?? events.length;
const handleAdd = async (): Promise<boolean> => {
if (pendingFormData.current) {
await createEvent.mutateAsync(pendingFormData.current);
}
return true;
};
const handleUpdate = async (): Promise<boolean> => {
if (selectedEvent && pendingFormData.current) {
await updateEvent.mutateAsync({ id: selectedEvent.id, data: pendingFormData.current });
}
return true;
};
const handleDelete = async (): Promise<boolean> => {
if (selectedEvent) {
await deleteEvent.mutateAsync(selectedEvent.id);
}
return true;
};
const columns: ColumnDef<TEventsListItem>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'Location',
accessorKey: 'location',
cell: ({ row }) => row.original.location || '-',
},
{
header: 'Price',
accessorKey: 'price',
cell: ({ row }) =>
row.original.price === 0
? 'Free'
: `Rp ${row.original.price.toLocaleString('id-ID')}`,
},
{
header: 'Start Date',
accessorKey: 'start_date',
cell: ({ row }) =>
new Date(row.original.start_date).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
}),
},
{
header: 'Online',
accessorKey: 'is_online',
cell: ({ row }) => (
<span
className={`px-2 py-1 rounded-full text-label2 font-medium ${
row.original.is_online
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{row.original.is_online ? 'Online' : 'Offline'}
</span>
),
},
{
header: 'Action',
cell: ({ row }) => (
<div className="flex gap-[8px]">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setSelectedEvent(row.original);
setShowModalUpdateItem(true);
}}
className="flex items-center gap-2"
>
<EditOutlined /> Update
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setSelectedEvent(row.original);
setShowModalDeleteItem(true);
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
</div>
),
},
];
const table = useReactTable({
data: events,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true,
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">CMS Events</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama event"
className="pl-12 w-full max-h-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => setShowModalAddItem(true)}
>
<PlusOutlined />
Tambah Event
</Button>
</div>
</div>
{isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div>
) : (
<DataTable
data={events}
columns={columns}
pageSize={9}
table={table}
/>
)}
</section>
</main>
<ModalAddEvent
currentStep={currentStep}
isOpen={showModalAddItem}
onClose={() => setShowModalAddItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
handleAdd={handleAdd}
onDataCapture={(data) => { pendingFormData.current = data; }}
/>
<ModalUpdateEvent
isOpen={showModalUpdateItem}
onClose={() => setShowModalUpdateItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
handleUpdate={handleUpdate}
initialValues={selectedEvent ? {
name: selectedEvent.name,
description: selectedEvent.description,
detail_link: selectedEvent.detail_link,
location: selectedEvent.location,
price: selectedEvent.price,
start_date: selectedEvent.start_date,
end_date: selectedEvent.end_date,
is_online: selectedEvent.is_online,
} : undefined}
onDataCapture={(data) => { pendingFormData.current = data; }}
/>
<ModalDeleteEvent
isOpen={showModalDeleteItem}
onClose={() => setShowModalDeleteItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
handleDelete={handleDelete}
/>
</Fragment>
);
};
export default Components;
@@ -0,0 +1,146 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { useItem, useConfirmItem } from '../_hook/use-item';
interface IModalAddTestimonial {
isOpen: boolean;
onClose: () => void;
handleAdd?: () => Promise<boolean>;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
onDataCapture?: (data: any) => void;
}
const ModalAddTestimonial = ({
isOpen,
onClose,
currentStep,
nextStep,
resetStep,
handleAdd,
onDataCapture,
}: IModalAddTestimonial) => {
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-0 text-center"
isOpen={isOpen}
onClose={() => {
onClose();
resetStep();
}}
disableEscapeKeyDown={true}
>
{currentStep === 1 && <StepOne nextStep={nextStep} onClose={onClose} onDataCapture={onDataCapture} />}
{currentStep === 2 && (
<StepTwo
onClose={onClose}
handleAdd={handleAdd}
resetStep={resetStep}
/>
)}
</Modal>
);
};
interface IStepOneProps {
nextStep: () => void;
onClose: () => void;
onDataCapture?: (data: any) => void;
}
const StepOne = ({ nextStep, onDataCapture }: IStepOneProps) => {
const { form, onSubmit } = useItem(nextStep, undefined, onDataCapture);
return (
<>
<Modal.Header>
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Tambah Testimonial
</h2>
</Modal.Header>
<Modal.Content>
<form onSubmit={onSubmit} className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<ControlledInputField
control={form.control}
label="Role"
name="role"
type="text"
placeholder="Masukkan Role (e.g. Software Engineer)"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Konten Testimonial"
name="content"
type="text"
placeholder="Masukkan Konten Testimonial"
size="lg"
className="w-full"
/>
</div>
<Button variant="primary" size="lg" className="w-full" type="submit">
Tambah Testimonial
</Button>
</form>
</Modal.Content>
</>
);
};
interface IStepTwoProps {
onClose: () => void;
handleAdd?: () => Promise<boolean>;
resetStep: () => void;
}
const StepTwo = ({ onClose, handleAdd, resetStep }: IStepTwoProps) => {
const { onConfirm, onCancel } = useConfirmItem(
onClose,
resetStep,
handleAdd,
{
success: 'Data testimonial berhasil ditambahkan',
error: 'Data testimonial gagal ditambahkan',
}
);
return (
<>
<Modal.Header className="mb-10 text-center items-center">
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Tambah Testimonial
</h2>
<p className="text-p3 text-center text-neutral-400">
Apakah kamu yakin ingin
<br /> menambahkan testimonial ini?
</p>
</Modal.Header>
<Modal.Content className="flex mb-0 gap-4">
<Button
variant="bordered"
size="lg"
className="w-full"
onClick={onCancel}
>
Batal
</Button>
<Button
variant="primary"
size="lg"
className="w-full"
onClick={onConfirm}
>
Tambahkan
</Button>
</Modal.Content>
</>
);
};
export default ModalAddTestimonial;
@@ -0,0 +1,72 @@
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { useConfirmItem } from '../_hook/use-item';
interface IModalDeleteTestimonial {
isOpen: boolean;
onClose: () => void;
handleDelete?: () => Promise<boolean>;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
}
const ModalDeleteTestimonial = ({
isOpen,
onClose,
resetStep,
handleDelete,
}: IModalDeleteTestimonial) => {
const { onConfirm } = useConfirmItem(onClose, resetStep, handleDelete, {
success: 'Data testimonial berhasil dihapus',
error: 'Data testimonial gagal dihapus',
});
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
isOpen={isOpen}
onClose={onClose}
closeButtonClassName="hidden"
>
<Modal.Header className="gap-8">
<img
src="/chibi-delete.webp"
alt="Delete?"
width={148}
className="self-center"
/>
<div className="text-center">
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
Delete Testimonial
</h2>
<p className="text-p3 text-neutral-400">
Apakah kamu yakin untuk menghapus testimonial ini? Menghapus data ini
mungkin akan mempengaruhi fungsional sistem
</p>
</div>
</Modal.Header>
<Modal.Content className="flex gap-4">
<Button
variant="secondary"
size="lg"
className="w-full"
onClick={onClose}
>
Batal Hapus
</Button>
<Button
variant="primary"
size="lg"
className="w-full bg-danger-500 hover:bg-danger-600"
onClick={onConfirm}
>
Hapus Testimonial
</Button>
</Modal.Content>
</Modal>
);
};
export default ModalDeleteTestimonial;
@@ -0,0 +1,104 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Modal } from '@imphnen-frontend-service/ui/molecules';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
interface IModalUpdateTestimonial {
isOpen: boolean;
onClose: () => void;
handleUpdate?: () => Promise<boolean>;
currentStep?: number;
nextStep: () => void;
prevStep: () => void;
resetStep: () => void;
initialValues?: {
role?: string;
content?: string;
};
onDataCapture?: (data: any) => void;
}
const ModalUpdateTestimonial = ({
isOpen,
onClose,
resetStep,
handleUpdate,
initialValues,
onDataCapture,
}: IModalUpdateTestimonial) => {
const form = useForm<any>({
mode: 'all',
defaultValues: initialValues,
});
useEffect(() => {
if (isOpen) {
form.reset(initialValues);
}
}, [isOpen, initialValues]);
const onSubmit = form.handleSubmit(async (data) => {
try {
onDataCapture?.(data);
if (handleUpdate) await handleUpdate();
toast.success('Perubahan testimonial berhasil dilakukan');
onClose();
resetStep();
} catch (error) {
console.log(error);
toast.error('Perubahan testimonial gagal dilakukan');
}
});
return (
<Modal
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-0 text-center"
isOpen={isOpen}
onClose={onClose}
disableEscapeKeyDown={true}
>
<Modal.Header>
<h2 className="text-p1 font-semibold text-primary-500 mb-3">
Update Testimonial
</h2>
</Modal.Header>
<Modal.Content className="flex flex-col gap-8">
<form onSubmit={onSubmit} className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<ControlledInputField
control={form.control}
label="Role"
name="role"
type="text"
placeholder="Masukkan Role"
size="lg"
className="w-full"
/>
<ControlledInputField
control={form.control}
label="Konten Testimonial"
name="content"
type="text"
placeholder="Masukkan Konten Testimonial"
size="lg"
className="w-full"
/>
</div>
<Button
variant="primary"
size="lg"
className="w-full"
type="submit"
>
Update Testimonial
</Button>
</form>
</Modal.Content>
</Modal>
);
};
export default ModalUpdateTestimonial;
@@ -0,0 +1,57 @@
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
export const useItem = (
nextStep: () => void,
initialValues?: any,
onDataCapture?: (data: any) => void,
) => {
const form = useForm<any>({
mode: 'all',
defaultValues: initialValues,
});
const onSubmit = form.handleSubmit((data) => {
onDataCapture?.(data);
nextStep();
});
return {
form,
onSubmit,
};
};
export const useConfirmItem = (
onClose: () => void,
resetStep: () => void,
actionFunction?: () => Promise<boolean>,
messages?: {
success?: string;
error?: string;
}
) => {
const onConfirm = async () => {
try {
if (actionFunction) {
await actionFunction();
}
toast.success(messages?.success);
onClose();
resetStep();
} catch (error) {
console.log(error);
toast.error(messages?.error);
}
};
const onCancel = () => {
onClose();
resetStep();
};
return {
onConfirm,
onCancel,
};
};
@@ -0,0 +1,266 @@
import { FC, Fragment, ReactElement, useRef, useState } from 'react';
import {
SearchOutlined,
EditOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
import {
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import ModalAddTestimonial from './_components/modal-add-testimonial';
import ModalUpdateTestimonial from './_components/modal-update-testimonial';
import ModalDeleteTestimonial from './_components/modal-delete-testimonial';
import { useQueryState } from '@imphnen-frontend-service/utils';
import {
useTestimonialList,
useCreateTestimonial,
useUpdateTestimonial,
useDeleteTestimonial,
TTestimonialsListItem,
} from '@imphnen-frontend-service/service';
import React from 'react';
export const Components: FC = (): ReactElement => {
const [showModalAddItem, setShowModalAddItem] = useState(false);
const [showModalUpdateItem, setShowModalUpdateItem] = useState(false);
const [showModalDeleteItem, setShowModalDeleteItem] = useState(false);
const [selectedTestimonial, setSelectedTestimonial] = useState<TTestimonialsListItem | null>(null);
const [search, setSearch] = useState('');
const pendingFormData = useRef<any>(null);
const {
step: currentStep,
nextStep,
prevStep,
resetStep,
} = useQueryState('step', {
defaultValue: 1,
maxValue: 2,
minValue: 1,
});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 9,
});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const { data: testimonialsData, isLoading } = useTestimonialList({
search,
page: pagination.pageIndex + 1,
per_page: pagination.pageSize,
});
const createTestimonial = useCreateTestimonial();
const updateTestimonial = useUpdateTestimonial();
const deleteTestimonial = useDeleteTestimonial();
const testimonials: TTestimonialsListItem[] = testimonialsData?.data ?? [];
const totalItems = testimonialsData?.meta?.total ?? testimonials.length;
const handleAdd = async (): Promise<boolean> => {
if (pendingFormData.current) {
await createTestimonial.mutateAsync(pendingFormData.current);
}
return true;
};
const handleUpdate = async (): Promise<boolean> => {
if (selectedTestimonial && pendingFormData.current) {
await updateTestimonial.mutateAsync({ id: selectedTestimonial.id, data: pendingFormData.current });
}
return true;
};
const handleDelete = async (): Promise<boolean> => {
if (selectedTestimonial) {
await deleteTestimonial.mutateAsync(selectedTestimonial.id);
}
return true;
};
const columns: ColumnDef<TTestimonialsListItem>[] = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="rounded"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{
header: 'User',
accessorKey: 'user_fullname',
},
{
header: 'Role',
accessorKey: 'role',
},
{
header: 'Content',
accessorKey: 'content',
cell: ({ row }) => {
const content = row.original.content;
return content.length > 80 ? `${content.substring(0, 80)}...` : content;
},
},
{
header: 'Created At',
accessorKey: 'created_at',
cell: ({ row }) =>
new Date(row.original.created_at).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
}),
},
{
header: 'Action',
cell: ({ row }) => (
<div className="flex gap-[8px]">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setSelectedTestimonial(row.original);
setShowModalUpdateItem(true);
}}
className="flex items-center gap-2"
>
<EditOutlined /> Update
</Button>
<Button
variant="danger"
size="sm"
onClick={(e) => {
e.stopPropagation();
setSelectedTestimonial(row.original);
setShowModalDeleteItem(true);
}}
className="flex items-center gap-2"
>
<DeleteOutlined /> Delete
</Button>
</div>
),
},
];
const table = useReactTable({
data: testimonials,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(totalItems / pagination.pageSize),
manualPagination: true,
});
return (
<Fragment>
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
<h1 className="text-p2 font-semibold">CMS Testimonials</h1>
</header>
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
<div className="flex justify-between items-center gap-8 mb-2">
<div className="relative w-full">
<Input
placeholder="Cari berdasarkan nama user"
className="pl-12 w-full max-h-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
<SearchOutlined />
</div>
</div>
<div className="relative">
<Button
variant="primary"
size="md"
className="flex gap-3 text-nowrap"
onClick={() => setShowModalAddItem(true)}
>
<PlusOutlined />
Tambah Testimonial
</Button>
</div>
</div>
{isLoading ? (
<div className="text-center py-8 text-neutral-400">Loading...</div>
) : (
<DataTable
data={testimonials}
columns={columns}
pageSize={9}
table={table}
/>
)}
</section>
</main>
<ModalAddTestimonial
currentStep={currentStep}
isOpen={showModalAddItem}
onClose={() => setShowModalAddItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
handleAdd={handleAdd}
onDataCapture={(data) => { pendingFormData.current = data; }}
/>
<ModalUpdateTestimonial
isOpen={showModalUpdateItem}
onClose={() => setShowModalUpdateItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
handleUpdate={handleUpdate}
initialValues={selectedTestimonial ? {
role: selectedTestimonial.role,
content: selectedTestimonial.content,
} : undefined}
onDataCapture={(data) => { pendingFormData.current = data; }}
/>
<ModalDeleteTestimonial
isOpen={showModalDeleteItem}
onClose={() => setShowModalDeleteItem(false)}
nextStep={nextStep}
prevStep={prevStep}
resetStep={resetStep}
handleDelete={handleDelete}
/>
</Fragment>
);
};
export default Components;
@@ -1,16 +1,72 @@
'use client';
import TESTIMONIALS from '@/data/testimonials.json';
import { buttonVariants } from '@components';
import { motion, useInView } from 'framer-motion';
import Image from 'next/image';
import Link from 'next/link';
import { useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { FaQuoteLeft } from 'react-icons/fa';
interface ApiTestimonial {
id: number;
user_id: number;
user_fullname: string;
role: string;
content: string;
created_at: string;
is_deleted: boolean;
}
interface Testimonial {
id: number;
name: string;
role: string;
text: string;
}
const AVATAR_COLORS = [
'bg-primary-500 text-white',
'bg-blue-500 text-white',
'bg-green-500 text-white',
'bg-purple-500 text-white',
'bg-orange-500 text-white',
'bg-pink-500 text-white',
];
function getAvatarColor(index: number) {
return AVATAR_COLORS[index % AVATAR_COLORS.length];
}
function getInitial(name: string) {
return name.charAt(0).toUpperCase();
}
export function TestimonialSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, amount: 0.1 });
const [testimonials, setTestimonials] = useState<Testimonial[]>([]);
useEffect(() => {
fetch('https://api.imphnen.dev/v1/landing/cms/testimonials')
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then((json) => {
const items: Testimonial[] = (json.data as ApiTestimonial[])
.filter((t) => !t.is_deleted)
.slice(0, 6)
.map((t) => ({
id: t.id,
name: t.user_fullname,
role: t.role,
text: t.content,
}));
setTestimonials(items);
})
.catch(() => {
setTestimonials([]);
});
}, []);
const containerVariants = {
hidden: { opacity: 0 },
@@ -59,7 +115,7 @@ export function TestimonialSection() {
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
>
{TESTIMONIALS.map((testimonial) => (
{testimonials.map((testimonial, index) => (
<motion.div
key={testimonial.id}
variants={itemVariants}
@@ -67,14 +123,11 @@ export function TestimonialSection() {
>
<div className="flex flex-col space-y-4">
<div className="flex items-center gap-4">
<Image
src={testimonial.image}
alt={testimonial.name}
width={48}
height={48}
className="w-12 h-12 rounded-full object-cover"
unoptimized
/>
<div
className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${getAvatarColor(index)}`}
>
{getInitial(testimonial.name)}
</div>
<div>
<h4 className="font-semibold text-gray-900">
{testimonial.name}
+126 -86
View File
@@ -1,11 +1,28 @@
'use client';
import events from '@/data/events.json';
import { buttonVariants } from '@components';
import { cn } from '@utils';
import Image from 'next/image';
import { HiCalendar, HiClock, HiLocationMarker } from 'react-icons/hi';
interface ApiEvent {
id: number;
name: string;
description: string;
detail_link: string;
price: number;
is_online: boolean;
is_deleted: boolean;
location: string;
start_date: string;
end_date: string;
created_at: string;
updated_at: string;
}
interface ApiResponse {
data: ApiEvent[];
meta: Record<string, unknown>;
version: string;
}
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('id-ID', {
@@ -30,110 +47,123 @@ const getEventStatus = (endDate: string) => {
return end > now ? 'upcoming' : 'past';
};
export default function EventsPage() {
async function fetchEvents(): Promise<ApiEvent[]> {
const res = await fetch(
'https://api.imphnen.dev/v1/landing/cms/events',
{ next: { revalidate: 60 } }
);
if (!res.ok) {
return [];
}
const json: ApiResponse = await res.json();
return json.data.filter((e) => !e.is_deleted);
}
export default async function EventsPage() {
const events = await fetchEvents();
const sortedEvents = [...events].sort(
(a, b) =>
new Date(b.start_date).getTime() - new Date(a.start_date).getTime()
);
if (sortedEvents.length === 0) {
return (
<section className="min-h-screen bg-background container py-10">
<p className="text-center text-muted-foreground">
Belum ada event tersedia.
</p>
</section>
);
}
return (
<section className="min-h-screen bg-background container py-10">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="col-span-full">
<div className="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden bg-card">
<div className="grid md:grid-cols-2">
<div className="min-h-96 bg-muted relative">
<Image
src={sortedEvents[0].thumbnail}
alt={sortedEvents[0].name}
fill
className="object-cover object-top"
sizes="(max-width: 768px) 100vw, 50vw"
priority
unoptimized
/>
</div>
<div className="p-8 flex flex-col">
<div className="flex items-center gap-2 mb-4">
<span className="bg-primary/20 text-primary text-xs px-2.5 py-1 rounded-full">
Event Terbaru
</span>
<span
className={cn(
'px-2 py-1 rounded-full text-xs',
getEventStatus(sortedEvents[0].end_date) === 'upcoming'
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
)}
>
{getEventStatus(sortedEvents[0].end_date) === 'upcoming'
? 'Upcoming'
: 'Selesai'}
</span>
</div>
<h2 className="text-2xl font-bold mb-4 text-foreground">
{sortedEvents[0].name}
</h2>
<div className="space-y-3 mb-6 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<HiCalendar className="w-4 h-4" />
<span>{formatDate(sortedEvents[0].start_date)}</span>
</div>
<div className="flex items-center gap-2">
<HiClock className="w-4 h-4" />
<span>
{formatTime(sortedEvents[0].start_date)} -{' '}
{formatTime(sortedEvents[0].end_date)} WIB
</span>
</div>
<div className="flex items-center gap-2">
<HiLocationMarker className="w-4 h-4" />
<span>
{sortedEvents[0].type === 'online'
? 'Online'
: sortedEvents[0].location}
</span>
</div>
{sortedEvents[0].price > 0 && (
<div className="mt-1 font-medium">
Rp {sortedEvents[0].price.toLocaleString('id-ID')}
</div>
)}
</div>
<p className="text-muted-foreground mb-6 line-clamp-4">
{sortedEvents[0].description}
</p>
<a
href={sortedEvents[0].detail_link}
target="_blank"
<div className="p-8 flex flex-col">
<div className="flex items-center gap-2 mb-4">
<span className="bg-primary/20 text-primary text-xs px-2.5 py-1 rounded-full">
Event Terbaru
</span>
<span
className={cn(
buttonVariants({ variant: 'bordered' }),
'mt-auto w-full md:w-fit'
'px-2 py-1 rounded-full text-xs',
getEventStatus(sortedEvents[0].end_date) === 'upcoming'
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
)}
>
Lihat Detail
</a>
{getEventStatus(sortedEvents[0].end_date) === 'upcoming'
? 'Upcoming'
: 'Selesai'}
</span>
<span
className={cn(
'px-2 py-1 rounded-full text-xs',
sortedEvents[0].is_online
? 'bg-blue-100 text-blue-700'
: 'bg-green-100 text-green-700'
)}
>
{sortedEvents[0].is_online ? 'Online' : 'Onsite'}
</span>
</div>
<h2 className="text-2xl font-bold mb-4 text-foreground">
{sortedEvents[0].name}
</h2>
<div className="space-y-3 mb-6 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<HiCalendar className="w-4 h-4" />
<span>{formatDate(sortedEvents[0].start_date)}</span>
</div>
<div className="flex items-center gap-2">
<HiClock className="w-4 h-4" />
<span>
{formatTime(sortedEvents[0].start_date)} -{' '}
{formatTime(sortedEvents[0].end_date)} WIB
</span>
</div>
<div className="flex items-center gap-2">
<HiLocationMarker className="w-4 h-4" />
<span>
{sortedEvents[0].is_online
? 'Online'
: sortedEvents[0].location}
</span>
</div>
{sortedEvents[0].price > 0 && (
<div className="mt-1 font-medium">
Rp {sortedEvents[0].price.toLocaleString('id-ID')}
</div>
)}
</div>
<p className="text-muted-foreground mb-6 line-clamp-4">
{sortedEvents[0].description}
</p>
<a
href={sortedEvents[0].detail_link}
target="_blank"
className={cn(
buttonVariants({ variant: 'bordered' }),
'mt-auto w-full md:w-fit'
)}
>
Lihat Detail
</a>
</div>
</div>
</div>
{sortedEvents.slice(1).map((event) => (
<div
key={event.name}
key={event.id}
className="rounded-xl shadow-sm hover:shadow-md transition-shadow duration-200 bg-card"
>
<div className="h-48 bg-muted relative">
<Image
src={event.thumbnail}
alt={event.name}
fill
className="object-cover object-top rounded-t-xl"
sizes="(max-width: 768px) 100vw, 33vw"
unoptimized
/>
</div>
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<span
@@ -148,6 +178,16 @@ export default function EventsPage() {
? 'Upcoming'
: 'Selesai'}
</span>
<span
className={cn(
'px-2 py-1 rounded-full text-xs',
event.is_online
? 'bg-blue-100 text-blue-700'
: 'bg-green-100 text-green-700'
)}
>
{event.is_online ? 'Online' : 'Onsite'}
</span>
</div>
<h3 className="text-lg font-semibold mb-3 text-foreground">
{event.name}
@@ -160,7 +200,7 @@ export default function EventsPage() {
<div className="flex items-center gap-2">
<HiLocationMarker className="w-4 h-4" />
<span>
{event.type === 'online' ? 'Online' : event.location}
{event.is_online ? 'Online' : event.location}
</span>
</div>
{event.price > 0 && (
@@ -1,11 +1,58 @@
import TESTIMONIALS from '@/data/testimonials.json';
import { Button } from '@components';
import Image from 'next/image';
import Link from 'next/link';
import { BsChatLeftQuote } from 'react-icons/bs';
import { FaQuoteLeft } from 'react-icons/fa';
export default function Page() {
interface ApiTestimonial {
id: number;
user_id: number;
user_fullname: string;
role: string;
content: string;
created_at: string;
is_deleted: boolean;
}
interface ApiResponse {
data: ApiTestimonial[];
meta: Record<string, unknown>;
version: string;
}
const AVATAR_COLORS = [
'bg-primary-500 text-white',
'bg-blue-500 text-white',
'bg-green-500 text-white',
'bg-purple-500 text-white',
'bg-orange-500 text-white',
'bg-pink-500 text-white',
];
function getAvatarColor(index: number) {
return AVATAR_COLORS[index % AVATAR_COLORS.length];
}
function getInitial(name: string) {
return name.charAt(0).toUpperCase();
}
async function fetchTestimonials() {
const res = await fetch(
'https://api.imphnen.dev/v1/landing/cms/testimonials',
{ next: { revalidate: 60 } }
);
if (!res.ok) {
return [];
}
const json: ApiResponse = await res.json();
return json.data.filter((t) => !t.is_deleted);
}
export default async function Page() {
const testimonials = await fetchTestimonials();
return (
<div className="min-h-screen">
<div className="py-16 px-4 text-center border-b border-border">
@@ -21,31 +68,28 @@ export default function Page() {
</div>
<div className="grid gap-8 md:gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 my-16 container">
{TESTIMONIALS.map((testimonial) => (
{testimonials.map((testimonial, index) => (
<div
key={testimonial.id}
className="p-6 bg-white rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300"
>
<div className="flex flex-col space-y-4">
<div className="flex items-center gap-4">
<Image
src={testimonial.image}
alt={testimonial.name}
width={48}
height={48}
className="w-12 h-12 rounded-full object-cover"
unoptimized
/>
<div
className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-semibold ${getAvatarColor(index)}`}
>
{getInitial(testimonial.user_fullname)}
</div>
<div>
<h4 className="font-semibold text-gray-900">
{testimonial.name}
{testimonial.user_fullname}
</h4>
<p className="text-sm text-gray-600">{testimonial.role}</p>
</div>
</div>
<div className="text-gray-600 relative">
<FaQuoteLeft className="text-primary-500/30 w-6 h-6 mb-2" />
<p className="text-sm/relaxed">{testimonial.text}</p>
<p className="text-sm/relaxed">{testimonial.content}</p>
</div>
</div>
</div>
@@ -2,10 +2,12 @@ import {
AppstoreOutlined,
AuditOutlined,
BookOutlined,
CalendarOutlined,
CommentOutlined,
DownOutlined,
InboxOutlined,
LogoutOutlined,
MessageOutlined,
ReadOutlined,
ReloadOutlined,
RightOutlined,
@@ -118,6 +120,22 @@ const MENUS: MenuItem[] = [
},
],
},
{
label: 'CMS',
icon: <BookOutlined className="text-[20px]" />,
children: [
{
label: 'Events',
href: '/cms-events',
icon: <CalendarOutlined className="text-[20px]" />,
},
{
label: 'Testimonials',
href: '/cms-testimonials',
icon: <MessageOutlined className="text-[20px]" />,
},
],
},
{
label: 'Permissions',
href: '/permissions',