diff --git a/backend/src/main/java/com/rit/canteen/sales/controller/CouponController.java b/backend/src/main/java/com/rit/canteen/sales/controller/CouponController.java new file mode 100644 index 00000000..6e7667b5 --- /dev/null +++ b/backend/src/main/java/com/rit/canteen/sales/controller/CouponController.java @@ -0,0 +1,109 @@ +package com.rit.canteen.sales.controller; + +import com.rit.canteen.sales.model.CouponCode; +import com.rit.canteen.sales.repository.CouponRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@RestController +@RequestMapping("/api/coupons") +public class CouponController { + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private com.rit.canteen.sales.repository.CouponRedemptionRepository redemptionRepository; + + @Autowired + private com.rit.canteen.sales.service.TokenService tokenService; + + @GetMapping + public List getAllCoupons() { + return couponRepository.findAll(); + } + + @PostMapping("/redeem") + @org.springframework.transaction.annotation.Transactional + public ResponseEntity redeemCoupon(@RequestBody Map request) { + String code = ((String) request.get("code")).toUpperCase().trim(); + Long userId = Long.valueOf(request.get("userId").toString()); + + Optional couponOpt = couponRepository.findByCode(code); + if (couponOpt.isEmpty()) { + return ResponseEntity.status(404).body(Map.of("success", false, "message", "Invalid coupon code")); + } + + CouponCode coupon = couponOpt.get(); + + // 1. Basic Validations + if (!coupon.getIsActive()) { + return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon is currently inactive")); + } + + if (java.time.LocalDateTime.now().isAfter(coupon.getExpiryDate())) { + return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon has expired")); + } + + // 2. Global Usage Limit + if (coupon.getCurrentClaims() >= coupon.getMaxClaims()) { + return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon claim limit reached")); + } + + // 3. Per-User Limit (Single redemption per code) + if (redemptionRepository.existsByUserIdAndCouponId(userId, coupon.getId())) { + return ResponseEntity.status(400).body(Map.of("success", false, "message", "You have already redeemed this code")); + } + + // 4. Execution + try { + // Update coupon stats + coupon.setCurrentClaims(coupon.getCurrentClaims() + 1); + couponRepository.save(coupon); + + // Credit tokens + tokenService.topUp(userId, coupon.getRewardAmount(), "COUPON-" + code); + + // Log redemption + redemptionRepository.save(new com.rit.canteen.sales.model.CouponRedemption(userId, coupon.getId())); + + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Successfully redeemed " + coupon.getRewardAmount() + " Ritz tokens!", + "rewardAmount", coupon.getRewardAmount() + )); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of("success", false, "message", "Redemption failed: " + e.getMessage())); + } + } + + @PostMapping + public ResponseEntity createCoupon(@RequestBody CouponCode coupon) { + if (couponRepository.findByCode(coupon.getCode()).isPresent()) { + return ResponseEntity.badRequest().body("Coupon code already exists"); + } + return ResponseEntity.ok(couponRepository.save(coupon)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteCoupon(@PathVariable Long id) { + couponRepository.deleteById(id); + return ResponseEntity.ok().build(); + } + + @PatchMapping("/{id}/toggle") + public ResponseEntity toggleStatus(@PathVariable Long id) { + Optional couponOpt = couponRepository.findById(id); + if (couponOpt.isPresent()) { + CouponCode coupon = couponOpt.get(); + coupon.setIsActive(!coupon.getIsActive()); + return ResponseEntity.ok(couponRepository.save(coupon)); + } + return ResponseEntity.notFound().build(); + } +} diff --git a/backend/src/main/java/com/rit/canteen/sales/model/CouponCode.java b/backend/src/main/java/com/rit/canteen/sales/model/CouponCode.java new file mode 100644 index 00000000..64fe8cdb --- /dev/null +++ b/backend/src/main/java/com/rit/canteen/sales/model/CouponCode.java @@ -0,0 +1,82 @@ +package com.rit.canteen.sales.model; + +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import com.fasterxml.jackson.annotation.JsonFormat; + + +@Entity +@Table(name = "coupon_codes") +public class CouponCode { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String code; + + @Column(nullable = false) + private BigDecimal rewardAmount; + + @Column(nullable = false) + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + private LocalDateTime expiryDate; + + + @Column(nullable = false) + private Integer maxClaims; + + @Column(nullable = false) + private Integer currentClaims = 0; + + @Column(nullable = false) + private Boolean isActive = true; + + @Column(nullable = true) + private String description; + + @Column(nullable = false, updatable = false) + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + private LocalDateTime createdAt; + + + public CouponCode() {} + + @PrePersist + protected void onCreate() { + if (createdAt == null) createdAt = LocalDateTime.now(); + if (currentClaims == null) currentClaims = 0; + if (isActive == null) isActive = true; + if (code != null) code = code.toUpperCase().trim(); + } + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getCode() { return code; } + public void setCode(String code) { this.code = code; } + + public BigDecimal getRewardAmount() { return rewardAmount; } + public void setRewardAmount(BigDecimal rewardAmount) { this.rewardAmount = rewardAmount; } + + public LocalDateTime getExpiryDate() { return expiryDate; } + public void setExpiryDate(LocalDateTime expiryDate) { this.expiryDate = expiryDate; } + + public Integer getMaxClaims() { return maxClaims; } + public void setMaxClaims(Integer maxClaims) { this.maxClaims = maxClaims; } + + public Integer getCurrentClaims() { return currentClaims; } + public void setCurrentClaims(Integer currentClaims) { this.currentClaims = currentClaims; } + + public Boolean getIsActive() { return isActive; } + public void setIsActive(Boolean active) { isActive = active; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/backend/src/main/java/com/rit/canteen/sales/model/CouponRedemption.java b/backend/src/main/java/com/rit/canteen/sales/model/CouponRedemption.java new file mode 100644 index 00000000..3047d222 --- /dev/null +++ b/backend/src/main/java/com/rit/canteen/sales/model/CouponRedemption.java @@ -0,0 +1,44 @@ +package com.rit.canteen.sales.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "coupon_redemptions", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "coupon_id"}) +}) +public class CouponRedemption { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(nullable = false) + private LocalDateTime redeemedAt; + + public CouponRedemption() {} + + public CouponRedemption(Long userId, Long couponId) { + this.userId = userId; + this.couponId = couponId; + this.redeemedAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public Long getCouponId() { return couponId; } + public void setCouponId(Long couponId) { this.couponId = couponId; } + + public LocalDateTime getRedeemedAt() { return redeemedAt; } + public void setRedeemedAt(LocalDateTime redeemedAt) { this.redeemedAt = redeemedAt; } +} diff --git a/backend/src/main/java/com/rit/canteen/sales/repository/CouponRedemptionRepository.java b/backend/src/main/java/com/rit/canteen/sales/repository/CouponRedemptionRepository.java new file mode 100644 index 00000000..58559b42 --- /dev/null +++ b/backend/src/main/java/com/rit/canteen/sales/repository/CouponRedemptionRepository.java @@ -0,0 +1,12 @@ +package com.rit.canteen.sales.repository; + +import com.rit.canteen.sales.model.CouponRedemption; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.Optional; + +@Repository +public interface CouponRedemptionRepository extends JpaRepository { + Optional findByUserIdAndCouponId(Long userId, Long couponId); + boolean existsByUserIdAndCouponId(Long userId, Long couponId); +} diff --git a/backend/src/main/java/com/rit/canteen/sales/repository/CouponRepository.java b/backend/src/main/java/com/rit/canteen/sales/repository/CouponRepository.java new file mode 100644 index 00000000..8a184696 --- /dev/null +++ b/backend/src/main/java/com/rit/canteen/sales/repository/CouponRepository.java @@ -0,0 +1,11 @@ +package com.rit.canteen.sales.repository; + +import com.rit.canteen.sales.model.CouponCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.Optional; + +@Repository +public interface CouponRepository extends JpaRepository { + Optional findByCode(String code); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9a0f3eb2..523d519c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,8 @@ import Feedback from './pages/Feedback.tsx'; import Ritz from './pages/Ritz.tsx'; import RitzCirculation from './pages/RitzCirculation.tsx'; import ManageWallets from './pages/ManageWallets.tsx'; +import ManageCoupons from './pages/ManageCoupons.tsx'; + const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const isLoggedIn = sessionStorage.getItem('isLoggedIn') === 'true'; @@ -96,6 +98,8 @@ function App() { } /> } /> } /> + } /> + {/* Stores */} } /> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 36a71ff2..89e01c23 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -123,7 +123,8 @@ const menuItems: MenuItem[] = [ subMenu: [ { title: 'Overview', path: '/ritz/overview' }, { title: 'Ritz in Circulation', path: '/ritz/circulation' }, - { title: 'Manage Wallets', path: '/ritz/wallets' } + { title: 'Manage Wallets', path: '/ritz/wallets' }, + { title: 'Coupon Codes', path: '/ritz/coupons' } ] }, ]; diff --git a/frontend/src/pages/ManageCoupons.tsx b/frontend/src/pages/ManageCoupons.tsx new file mode 100644 index 00000000..2c97e3a2 --- /dev/null +++ b/frontend/src/pages/ManageCoupons.tsx @@ -0,0 +1,377 @@ +import { useState, useEffect } from 'react'; +import { + Ticket, + Plus, + Trash2, + Power, + Calendar, + Users, + Clock, + ChevronRight, + TrendingUp, + Tag, + AlertCircle, + X, + Target +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { format } from 'date-fns'; + +interface Coupon { + id: number; + code: string; + rewardAmount: number; + expiryDate: string; + maxClaims: number; + currentClaims: number; + isActive: boolean; + description: string; + createdAt: string; +} + +const ManageCoupons = () => { + const [coupons, setCoupons] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isModalOpen, setIsModalOpen] = useState(false); + const [newCoupon, setNewCoupon] = useState({ + code: '', + rewardAmount: '', + expiryDate: '', + maxClaims: '', + description: '' + }); + + const fetchCoupons = async () => { + try { + setIsLoading(true); + const response = await fetch('/api/coupons'); + if (response.ok) { + const data = await response.json(); + setCoupons(data); + } + } catch (error) { + console.error('Error fetching coupons:', error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchCoupons(); + }, []); + + const handleCreateCoupon = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const response = await fetch('/api/coupons', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...newCoupon, + rewardAmount: Number(newCoupon.rewardAmount), + maxClaims: Number(newCoupon.maxClaims), + expiryDate: new Date(newCoupon.expiryDate).toISOString() + }) + }); + if (response.ok) { + setIsModalOpen(false); + setNewCoupon({ code: '', rewardAmount: '', expiryDate: '', maxClaims: '', description: '' }); + fetchCoupons(); + } + } catch (error) { + console.error('Error creating coupon:', error); + } + }; + + const handleToggleStatus = async (id: number) => { + try { + const response = await fetch(`/api/coupons/${id}/toggle`, { method: 'PATCH' }); + if (response.ok) fetchCoupons(); + } catch (error) { + console.error('Error toggling status:', error); + } + }; + + const handleDelete = async (id: number) => { + if (!window.confirm('Are you sure you want to delete this coupon?')) return; + try { + const response = await fetch(`/api/coupons/${id}`, { method: 'DELETE' }); + if (response.ok) fetchCoupons(); + } catch (error) { + console.error('Error deleting coupon:', error); + } + }; + + if (isLoading) { + return ( +
+ + + Synchronizing Coupon Network... + +
+ ); + } + + return ( +
+ {/* Top Header */} +
+
+
+

Ritz Rewards

+
+

Coupon Codes

+

+ Manage redemption codes and promotional credits for Ritz tokens +

+
+ + +
+ + {/* Analytics Summary */} +
+
+
+
+ +
+ Active Pool +
+

Active Coupons

+

{coupons.filter(c => c.isActive).length}

+
+ +
+
+
+ +
+ Global Reach +
+

Total Redemptions

+

{coupons.reduce((acc, current) => acc + current.currentClaims, 0)}

+
+ +
+
+
+ +
+ Expiring Soon +
+

Limited Availability

+

+ {coupons.filter(c => new Date(c.expiryDate).getTime() < new Date().getTime() + 86400000 * 3 && c.isActive).length} +

+
+
+ + {/* Coupons Grid */} +
+ + {coupons.map((coupon) => ( + + {/* Status Indicator */} +
+ {coupon.isActive ? 'Operational' : 'Deactivated'} +
+ +
+
+ + Redeem +
+ +
+
+

{coupon.code}

+ {!coupon.isActive && } +
+

{coupon.description || 'Global Promotional Credit'}

+ +
+
+
+
+

Reward

+

R{coupon.rewardAmount}

+
+
+
+
+
+

Expires

+

{format(new Date(coupon.expiryDate), 'dd MMM yyyy')}

+
+
+
+
+
+ + {/* Usage Meter */} +
+
+
+ + Usage Dynamics +
+ {coupon.currentClaims} / {coupon.maxClaims} Clm. +
+
+ +
+
+ + {/* Actions */} +
+ + +
+
+ ))} +
+
+ + {/* Creation Modal */} + + {isModalOpen && ( +
+ setIsModalOpen(false)} + className="absolute inset-0 bg-[#0f4475]/40 backdrop-blur-md" + /> + + +
+
+
+ +
+

Generate Code

+
+ +
+ +
+
+
+ + setNewCoupon({...newCoupon, code: e.target.value.toUpperCase()})} + className="w-full bg-slate-50 border border-slate-100 rounded-2xl px-4 py-3 text-sm font-bold text-slate-800 outline-none focus:border-[#0f4475] transition-all" + /> +
+
+ + setNewCoupon({...newCoupon, rewardAmount: e.target.value})} + className="w-full bg-slate-50 border border-slate-100 rounded-2xl px-4 py-3 text-sm font-bold text-slate-800 outline-none focus:border-[#0f4475] transition-all" + /> +
+
+ +
+
+ + setNewCoupon({...newCoupon, expiryDate: e.target.value})} + className="w-full bg-slate-50 border border-slate-100 rounded-2xl px-4 py-3 text-sm font-bold text-slate-800 outline-none focus:border-[#0f4475] transition-all" + /> +
+
+ + setNewCoupon({...newCoupon, maxClaims: e.target.value})} + className="w-full bg-slate-50 border border-slate-100 rounded-2xl px-4 py-3 text-sm font-bold text-slate-800 outline-none focus:border-[#0f4475] transition-all" + /> +
+
+ +
+ +