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:
Hafid Nur
2026-02-14 17:15:46 +07:00
committed by GitHub
parent 8728d0abd7
commit 9d37cba65f
47 changed files with 3627 additions and 2 deletions
@@ -0,0 +1,56 @@
import { api } from '../../auth/api/auth.service';
// Types
export interface Campaign {
id: string;
name: string;
url: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateCampaignRequest {
name: string;
url: string;
}
interface CampaignsResponse {
success: boolean;
message: string;
data: Campaign[];
}
interface CampaignResponse {
success: boolean;
message: string;
data: Campaign;
}
interface DeleteResponse {
success: boolean;
message: string;
}
export const campaignService = {
getCampaigns: async (): Promise<Campaign[]> => {
const response = await api.get<CampaignsResponse>('/campaigns');
return response.data.data;
},
createCampaign: async (data: CreateCampaignRequest): Promise<Campaign> => {
const response = await api.post<CampaignResponse>('/campaigns', data);
return response.data.data;
},
activateCampaign: async (campaignId: string): Promise<Campaign> => {
const response = await api.put<CampaignResponse>(
`/campaigns/${campaignId}/activate`
);
return response.data.data;
},
deleteCampaign: async (campaignId: string): Promise<void> => {
await api.delete<DeleteResponse>(`/campaigns/${campaignId}`);
},
};
@@ -0,0 +1,53 @@
import { api } from '../../auth/api/auth.service';
// Types
export interface User {
id: string;
email: string;
name: string;
role: string;
created_at: string;
updated_at: string;
}
export interface UpdateUserRoleRequest {
role: string;
}
interface UsersResponse {
success: boolean;
message: string;
data: User[];
}
interface UserResponse {
success: boolean;
message: string;
data: User;
}
interface DeleteResponse {
success: boolean;
message: string;
}
export const userService = {
getUsers: async (): Promise<User[]> => {
const response = await api.get<UsersResponse>('/users');
return response.data.data;
},
updateUserRole: async (
userId: string,
role: string
): Promise<User> => {
const response = await api.put<UserResponse>(`/users/${userId}/role`, {
role,
});
return response.data.data;
},
deleteUser: async (userId: string): Promise<void> => {
await api.delete<DeleteResponse>(`/users/${userId}`);
},
};
@@ -0,0 +1,27 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../auth/store/auth.store';
interface RequireAdminProps {
children: JSX.Element;
}
export const RequireAdmin = ({ children }: RequireAdminProps) => {
const user = useAuthStore((state) => state.user);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
// Check if user has admin role
// user.role is now an object { id, name, permissions }
const userRole = user?.role?.name;
if (userRole !== 'Admin' && userRole !== 'Super Admin') {
// Redirect non-admins to home
return <Navigate to="/" replace />;
}
return children;
};
@@ -0,0 +1,64 @@
import { Outlet, Link, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../auth/store/auth.store';
export const AdminDashboard = () => {
const logout = useAuthStore((state) => state.logout);
const location = useLocation();
const isActive = (path: string) => location.pathname.startsWith(path);
return (
<div className="min-h-screen bg-slate-100 flex">
{/* Sidebar */}
<aside className="w-64 bg-white border-r border-slate-200 flex flex-col">
<div className="p-6 border-b border-slate-200">
<h1 className="text-xl font-bold text-slate-800">Admin Panel</h1>
<p className="text-xs text-slate-500 mt-1">QR Campaign Manager</p>
</div>
<nav className="flex-1 p-4 space-y-1">
<Link
to="/admin/campaigns"
className={`block px-4 py-2 rounded-md transition-colors ${
isActive('/admin/campaigns')
? 'bg-blue-50 text-blue-700'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
Campaigns
</Link>
<Link
to="/admin/users"
className={`block px-4 py-2 rounded-md transition-colors ${
isActive('/admin/users')
? 'bg-blue-50 text-blue-700'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
Users
</Link>
</nav>
<div className="p-4 border-t border-slate-200">
<Link
to="/"
className="block px-4 py-2 text-sm text-slate-600 hover:text-slate-900 mb-2"
>
&larr; Back to App
</Link>
<button
onClick={logout}
className="w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-md transition-colors text-left"
>
Logout
</button>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 p-8 overflow-auto">
<Outlet />
</main>
</div>
);
};
@@ -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>
);
};
@@ -0,0 +1,121 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { toast } from 'sonner';
interface User {
id: string;
name: string;
email: string;
role: string;
created_at: string;
}
export const UserManagement = () => {
const queryClient = useQueryClient();
// Fetch Users
const {
data: users,
isLoading,
isError,
} = useQuery({
queryKey: ['users'],
queryFn: async () => {
const res = await axios.get('http://localhost:8080/api/v1/users');
return res.data.data as User[];
},
});
// Update Role
const updateRoleMutation = useMutation({
mutationFn: async ({ id, role }: { id: string; role: string }) => {
await axios.put(`http://localhost:8080/api/v1/users/${id}/role`, {
role,
});
},
onSuccess: () => {
toast.success('User role updated');
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: () => toast.error('Failed to update user role'),
});
// Delete User
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await axios.delete(`http://localhost:8080/api/v1/users/${id}`);
},
onSuccess: () => {
toast.success('User deleted');
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: () => toast.error('Failed to delete user'),
});
if (isLoading) return <div>Loading users...</div>;
if (isError) return <div>Error loading users.</div>;
return (
<div>
<h2 className="text-2xl font-bold text-slate-800 mb-6">Users</h2>
<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">Email</th>
<th className="px-6 py-3">Role</th>
<th className="px-6 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{users?.map((user) => (
<tr key={user.id} className="hover:bg-slate-50">
<td className="px-6 py-4 font-medium text-slate-900">
{user.name}
</td>
<td className="px-6 py-4 text-slate-500 text-sm">
{user.email}
</td>
<td className="px-6 py-4">
<select
value={user.role}
onChange={(e) =>
updateRoleMutation.mutate({
id: user.id,
role: e.target.value,
})
}
disabled={user.email === 'admin@demo.com'} // Prevent changing main admin role for safety in demo
className="bg-transparent border border-slate-300 rounded text-sm px-2 py-1 text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</td>
<td className="px-6 py-4">
<button
onClick={() => {
if (
window.confirm(
'Are you sure you want to delete this user?'
)
) {
deleteMutation.mutate(user.id);
}
}}
disabled={user.email === 'admin@demo.com'}
className="text-red-600 hover:text-red-800 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
@@ -0,0 +1,129 @@
import axios from 'axios';
// Define the base URL for the API
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
// Create a configured axios instance
export const api = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add interceptor to add token to requests
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Types
export interface LoginRequest {
email: string;
password: string;
}
export interface RegisterRequest {
name: string;
email: string;
password: string;
}
// Backend user response
interface BackendUser {
id: string;
email: string;
name: string;
role: string;
provider: string;
created_at: string;
updated_at: string;
}
// Frontend user type
export interface User {
id: string;
email: string;
fullname: string;
role?: {
id: string;
name: string;
permissions: string[];
};
}
// Backend auth response
interface BackendAuthResponse {
success: boolean;
message: string;
data: {
tokens: {
access_token: string;
refresh_token: string;
};
user: BackendUser;
};
}
export interface AuthResponse {
success: boolean;
message: string;
data: {
tokens: {
access_token: string;
refresh_token: string;
};
user: User;
};
}
// Helper to transform backend user to frontend user
const transformUser = (backendUser: BackendUser): User => {
return {
id: backendUser.id,
email: backendUser.email,
fullname: backendUser.name,
role: {
id: '',
name: backendUser.role === 'admin' ? 'Admin' : backendUser.role === 'user' ? 'User' : 'User',
permissions: [],
},
};
};
export const authService = {
login: async (data: LoginRequest): Promise<AuthResponse> => {
const response = await api.post<BackendAuthResponse>('/auth/login', data);
return {
success: response.data.success,
message: response.data.message,
data: {
tokens: response.data.data.tokens,
user: transformUser(response.data.data.user),
},
};
},
register: async (data: RegisterRequest): Promise<AuthResponse> => {
const response = await api.post<BackendAuthResponse>('/auth/register', data);
return {
success: response.data.success,
message: response.data.message,
data: {
tokens: response.data.data.tokens,
user: transformUser(response.data.data.user),
},
};
},
getProfile: async (): Promise<User> => {
const response = await api.get<User>('/users/me');
return response.data;
},
};
@@ -0,0 +1,21 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../../features/auth/store/auth.store';
interface RequireAuthProps {
children: JSX.Element;
}
export const RequireAuth = ({ children }: RequireAuthProps) => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const location = useLocation();
if (!isAuthenticated) {
// Redirect them to the /login page, but save the current location they were
// trying to go to when they were redirected. This allows us to send them
// along to that page after they login, which is a nicer user experience.
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
};
@@ -0,0 +1,102 @@
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useAuthStore } from '../store/auth.store';
import { useNavigate, useLocation } from 'react-router-dom';
// Reusing UI components logic or standard HTML for now to keep it simple and dependency-free if UI lib issues arise
// But user mentioned shared UI libs, let's try to use standard Tailwind first to ensure speed.
interface LoginFormInputs {
email: string;
pass: string;
}
export const LoginPage = () => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormInputs>();
const login = useAuthStore((state) => state.login);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || '/';
useEffect(() => {
if (isAuthenticated) {
navigate(from, { replace: true });
}
}, [isAuthenticated, navigate, from]);
const onSubmit = async (data: LoginFormInputs) => {
const success = await login(data.email, data.pass);
if (success) {
// Get user from store to check role
const user = useAuthStore.getState().user;
const userRole = user?.role?.name;
// Redirect admin to admin dashboard
if (userRole === 'Admin' || userRole === 'Super Admin') {
navigate('/admin/campaigns', { replace: true });
} else {
navigate(from, { replace: true });
}
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 p-4">
<div className="w-full max-w-md bg-white rounded-lg shadow-lg p-8">
<h1 className="text-2xl font-bold text-center mb-6 text-slate-800">
Login
</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Email
</label>
<input
type="email"
{...register('email', { required: 'Email is required' })}
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">
{errors.email.message}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Password
</label>
<input
type="password"
{...register('pass', { required: 'Password is required' })}
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.pass && (
<p className="text-red-500 text-sm mt-1">{errors.pass.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{isSubmitting ? 'Logging in...' : 'Login'}
</button>
</form>
<div className="mt-4 text-center text-sm text-slate-500">
<p>Demo credentials available in backend seeder.</p>
</div>
</div>
</div>
);
};
@@ -0,0 +1,84 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { authService, User } from '../api/auth.service';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
login: (email: string, pass: string) => Promise<boolean>;
register: (name: string, email: string, pass: string) => Promise<boolean>;
logout: () => void;
setUser: (user: User) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (email, password) => {
try {
const response = await authService.login({ email, password });
const token = response.data.tokens.access_token;
const refreshToken = response.data.tokens.refresh_token;
localStorage.setItem('token', token);
localStorage.setItem('refreshToken', refreshToken);
set({
user: response.data.user,
token: token,
isAuthenticated: true
});
return true;
} catch (error) {
console.error('Login failed:', error);
return false;
}
},
register: async (name, email, password) => {
try {
const response = await authService.register({ name, email, password });
const token = response.data.tokens.access_token;
const refreshToken = response.data.tokens.refresh_token;
localStorage.setItem('token', token);
localStorage.setItem('refreshToken', refreshToken);
set({
user: response.data.user,
token: token,
isAuthenticated: true
});
return true;
} catch (error) {
console.error('Registration failed:', error);
throw error;
}
},
logout: () => {
localStorage.removeItem('token');
set({ user: null, token: null, isAuthenticated: false });
},
setUser: (user) => set({ user }),
}),
{
name: 'auth-storage', // name of the item in the storage (must be unique)
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated
}),
}
)
);
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
export const useActiveCampaignQR = () => {
return useQuery({
queryKey: ['active-campaign-qr'],
queryFn: async () => {
// Assuming backend is running on localhost:8080
// In production, this should be an env var or relative path if proxied
const response = await axios.get('http://localhost:8080/api/v1/campaigns/active/qr', {
responseType: 'blob',
});
return URL.createObjectURL(response.data);
},
staleTime: 1000 * 60 * 5, // 5 minutes
});
};
@@ -0,0 +1,99 @@
import React, { useCallback, useState } from 'react';
import { toast } from 'sonner';
interface DropzoneProps {
onImageDropped: (file: File) => void;
}
export const Dropzone: React.FC<DropzoneProps> = ({ onImageDropped }) => {
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
const file = files[0];
if (!file.type.startsWith('image/')) {
toast.error('Please upload an image file.');
return;
}
onImageDropped(file);
},
[onImageDropped]
);
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const file = files[0];
if (!file.type.startsWith('image/')) {
toast.error('Please upload an image file.');
return;
}
onImageDropped(file);
}
},
[onImageDropped]
);
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-lg p-12 text-center transition-colors cursor-pointer ${
isDragging
? 'border-blue-500 bg-blue-50'
: 'border-slate-300 hover:border-slate-400'
}`}
onClick={() => document.getElementById('file-upload')?.click()}
>
<input
id="file-upload"
type="file"
className="hidden"
accept="image/png, image/jpeg, image/jpg"
onChange={handleFileInput}
/>
<div className="space-y-2">
<div className="flex justify-center">
{/* Simple upload icon */}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-12 h-12 text-slate-400"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
</div>
<p className="text-lg font-medium text-slate-700">
Drop your image here, or click to upload
</p>
<p className="text-sm text-slate-500">Supports JPG and PNG</p>
</div>
</div>
);
};
@@ -0,0 +1,188 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import html2canvas from 'html2canvas';
import { toast } from 'sonner';
interface WatermarkEditorProps {
imageFile: File;
qrCodeUrl: string;
onReset: () => void;
}
export const WatermarkEditor: React.FC<WatermarkEditorProps> = ({
imageFile,
qrCodeUrl,
onReset,
}) => {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const qrRef = useRef<HTMLDivElement>(null);
// State for QR code
const [position, setPosition] = useState({ x: 20, y: 20 });
const [size, setSize] = useState(100);
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [startResizePos, setStartResizePos] = useState({ x: 0, y: 0 });
const [startResizeSize, setStartResizeSize] = useState(100);
// Load image
useEffect(() => {
const url = URL.createObjectURL(imageFile);
setImageUrl(url);
return () => URL.revokeObjectURL(url);
}, [imageFile]);
// Drag handlers
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
setDragOffset({
x: e.clientX - position.x,
y: e.clientY - position.y,
});
};
const handleResizeMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setStartResizePos({ x: e.clientX, y: e.clientY });
setStartResizeSize(size);
};
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (isDragging) {
const newX = e.clientX - dragOffset.x;
const newY = e.clientY - dragOffset.y;
// Boundaries check (optional, but good UX)
if (containerRef.current) {
// const container = containerRef.current.getBoundingClientRect();
// Simple clamp? Or allow partial off-screen?
// Let's allow it to move freely within container
}
setPosition({ x: newX, y: newY });
}
if (isResizing) {
const deltaX = e.clientX - startResizePos.x;
const newSize = Math.max(50, startResizeSize + deltaX); // Min size 50px
setSize(newSize);
}
},
[isDragging, isResizing, dragOffset, startResizePos, startResizeSize]
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setIsResizing(false);
}, []);
useEffect(() => {
if (isDragging || isResizing) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
} else {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
const handleDownload = async () => {
if (!containerRef.current) return;
try {
const canvas = await html2canvas(containerRef.current, {
useCORS: true, // Important for QR if from external URL
backgroundColor: null,
});
const link = document.createElement('a');
link.download = `qr-campaign-${Date.now()}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
toast.success('Image downloaded successfully!');
} catch (error) {
console.error('Download failed:', error);
toast.error('Failed to download image.');
}
};
if (!imageUrl) return <div>Loading image...</div>;
return (
<div className="flex flex-col items-center gap-4 w-full h-full">
<div className="flex gap-2 mb-4">
<button
onClick={onReset}
className="px-4 py-2 bg-slate-200 text-slate-700 rounded hover:bg-slate-300 transition-colors"
>
Change Image
</button>
<button
onClick={handleDownload}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors shadow-lg"
>
Download Image
</button>
</div>
<div className="border border-slate-200 shadow-xl rounded-lg overflow-hidden bg-slate-50 inline-block relative">
<div
ref={containerRef}
className="relative inline-block"
style={{ lineHeight: 0 }}
>
<img
src={imageUrl}
alt="Uploaded"
className="max-h-[70vh] w-auto h-auto object-contain select-none"
draggable={false}
/>
<div
ref={qrRef}
className="absolute cursor-move select-none group"
style={{
left: position.x,
top: position.y,
width: size,
height: size,
zIndex: 10,
}}
onMouseDown={handleMouseDown}
>
<img
src={qrCodeUrl}
alt="QR Code"
className="w-full h-full select-none pointer-events-none"
crossOrigin="anonymous" // Important for html2canvas
/>
{/* Outline on hover/interaction */}
<div className="absolute inset-0 border-2 border-transparent group-hover:border-blue-400 group-active:border-blue-500 pointer-events-none rounded-sm transition-colors" />
{/* Resize handle */}
<div
className="absolute bottom-0 right-0 w-4 h-4 bg-blue-500 rounded-full cursor-nwse-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={handleResizeMouseDown}
style={{ transform: 'translate(50%, 50%)' }}
/>
</div>
</div>
</div>
<p className="text-sm text-slate-500 mt-2">
Drag to move the QR code. Drag the blue dot to resize.
</p>
</div>
);
};