Add a new app: QR code generator for campaign (#75)
* Initialize a new NX project - QR campaign * Develop QR code generator app for campaign * Add deployment script Add Dockerfile, docker compose, and update Nix config for deployment * Revert package.json to fix ci * Revert package-lock.json to ensure ci (Nix build & cache) successful
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Campaign {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
image_url?: string; // QR code image URL if we want to show it
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface CreateCampaignInputs {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const CampaignManagement = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<CreateCampaignInputs>();
|
||||
|
||||
// Fetch Campaigns
|
||||
const {
|
||||
data: campaigns,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ['campaigns'],
|
||||
queryFn: async () => {
|
||||
const res = await axios.get('http://localhost:8080/api/v1/campaigns');
|
||||
return res.data.data as Campaign[];
|
||||
},
|
||||
});
|
||||
|
||||
// Create Campaign
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: CreateCampaignInputs) => {
|
||||
await axios.post('http://localhost:8080/api/v1/campaigns', data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Campaign created successfully');
|
||||
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
setIsCreateModalOpen(false);
|
||||
reset();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to create campaign');
|
||||
},
|
||||
});
|
||||
|
||||
// Activate Campaign
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await axios.put(`http://localhost:8080/api/v1/campaigns/${id}/activate`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Campaign activated');
|
||||
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
// Also invalidate active QR for the main app
|
||||
queryClient.invalidateQueries({ queryKey: ['active-campaign-qr'] });
|
||||
},
|
||||
onError: () => toast.error('Failed to activate campaign'),
|
||||
});
|
||||
|
||||
// Delete Campaign
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await axios.delete(`http://localhost:8080/api/v1/campaigns/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Campaign deleted');
|
||||
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
},
|
||||
onError: () => toast.error('Failed to delete campaign'),
|
||||
});
|
||||
|
||||
const onCreateSubmit = (data: CreateCampaignInputs) => {
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
if (isLoading) return <div>Loading campaigns...</div>;
|
||||
if (isError) return <div>Error loading campaigns.</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-slate-800">Campaigns</h2>
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
+ New Campaign
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-50 text-slate-500 text-xs uppercase font-medium">
|
||||
<tr>
|
||||
<th className="px-6 py-3">Name</th>
|
||||
<th className="px-6 py-3">URL</th>
|
||||
<th className="px-6 py-3">Status</th>
|
||||
<th className="px-6 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{campaigns?.map((campaign) => (
|
||||
<tr key={campaign.id} className="hover:bg-slate-50">
|
||||
<td className="px-6 py-4 font-medium text-slate-900">
|
||||
{campaign.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-500 text-sm max-w-xs truncate">
|
||||
{campaign.url}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{campaign.is_active ? (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-800">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 space-x-2">
|
||||
{!campaign.is_active && (
|
||||
<button
|
||||
onClick={() => activateMutation.mutate(campaign.id)}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
'Are you sure you want to delete this campaign?'
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(campaign.id);
|
||||
}
|
||||
}}
|
||||
className="text-red-600 hover:text-red-800 text-sm font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{campaigns?.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="px-6 py-8 text-center text-slate-500"
|
||||
>
|
||||
No campaigns found. Create one to get started.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Basic Create Modal */}
|
||||
{isCreateModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<h3 className="text-xl font-bold mb-4 text-slate-900">
|
||||
Create New Campaign
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit(onCreateSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Campaign Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register('name', { required: 'Name is required' })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md bg-white"
|
||||
placeholder="e.g. Summer Sale 2026"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Target URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
{...register('url', { required: 'URL is required' })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md bg-white"
|
||||
placeholder="https://example.com/promo"
|
||||
/>
|
||||
{errors.url && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{errors.url.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateModalOpen(false)}
|
||||
className="px-4 py-2 text-slate-700 hover:bg-slate-100 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white hover:bg-blue-700 rounded-md disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Campaign'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user