Ritz token page added

This commit is contained in:
Sidharth Prabhu
2026-04-20 19:48:59 +05:30
parent 2a51622450
commit c8936d8cad
32 changed files with 1948 additions and 52 deletions

BIN
Ritz/.DS_Store vendored

Binary file not shown.

BIN
Ritz/color-vector.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

1052
Ritz/color-vector.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 72 KiB

BIN
Ritz/front.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

View File

@@ -175,7 +175,8 @@ public class OrderController {
// 4. Token Payment Check // 4. Token Payment Check
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) { if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
try { try {
tokenService.spend(order.getUser().getId(), order.getTotalAmount(), "ORD-" + displayId); // Use userId directly for robustness
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + displayId);
} catch (RuntimeException e) { } catch (RuntimeException e) {
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) { if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) {
throw new RuntimeException("INSUFFICIENT_TOKENS"); throw new RuntimeException("INSUFFICIENT_TOKENS");
@@ -236,9 +237,19 @@ public class OrderController {
return orderRepository.findById(id) return orderRepository.findById(id)
.map(order -> { .map(order -> {
order.setStatus(newStatus.toUpperCase()); String oldStatus = order.getStatus();
String nextStatus = newStatus.toUpperCase();
// Check for refund condition: Moving to CANCELLED from a non-cancelled state
if ("CANCELLED".equals(nextStatus) && !"CANCELLED".equals(oldStatus)) {
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
tokenService.refund(order.getUserId(), "ORD-" + order.getDisplayOrderId(), order.getTotalAmount(), "Status changed to CANCELLED");
}
}
order.setStatus(nextStatus);
orderRepository.save(order); orderRepository.save(order);
return ResponseEntity.ok(Map.of("success", true, "message", "Order status updated to " + newStatus)); return ResponseEntity.ok(Map.of("success", true, "message", "Order status updated to " + nextStatus));
}) })
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
} catch (Exception e) { } catch (Exception e) {
@@ -251,8 +262,27 @@ public class OrderController {
try { try {
return orderRepository.findById(id) return orderRepository.findById(id)
.map(existingOrder -> { .map(existingOrder -> {
BigDecimal oldAmount = existingOrder.getTotalAmount();
BigDecimal newAmount = updatedOrder.getTotalAmount();
// Handle Token Adjustments for edited orders
if ("RITZ_TOKEN".equals(existingOrder.getPaymentMethod())) {
int comparison = newAmount.compareTo(oldAmount);
if (comparison > 0) {
// Spend more
tokenService.spend(existingOrder.getUserId(), newAmount.subtract(oldAmount), "ORD-EDIT-" + existingOrder.getDisplayOrderId());
} else if (comparison < 0) {
// This is tricky for individual tokens, but we can refund the difference amount
// For simplicity/robustness, we'll refund the whole order and re-spend the new amount
// to keep unit association clean OR just record it as a topup.
// Let's do a simple balance restoration for the delta.
tokenService.refund(existingOrder.getUserId(), "ORD-" + existingOrder.getDisplayOrderId(), oldAmount, "Order price reduced during edit");
tokenService.spend(existingOrder.getUserId(), newAmount, "ORD-" + existingOrder.getDisplayOrderId());
}
}
// Update basic fields // Update basic fields
existingOrder.setTotalAmount(updatedOrder.getTotalAmount()); existingOrder.setTotalAmount(newAmount);
existingOrder.setPaymentMethod(updatedOrder.getPaymentMethod()); existingOrder.setPaymentMethod(updatedOrder.getPaymentMethod());
// Clear and replace items for a clean update // Clear and replace items for a clean update

View File

@@ -56,4 +56,21 @@ public class WalletController {
return ResponseEntity.status(500).body(Map.of("error", e.getMessage())); return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
} }
} }
@GetMapping("/transactions/all")
public ResponseEntity<List<TokenTransaction>> getAllTransactions() {
return ResponseEntity.ok(tokenService.getAllTransactions());
}
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> getStats() {
return ResponseEntity.ok(tokenService.getGlobalStats());
}
@GetMapping("/circulation")
public ResponseEntity<org.springframework.data.domain.Page<com.rit.canteen.sales.model.TokenUnit>> getCirculation(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(tokenService.getAllCirculation(page, size));
}
} }

View File

@@ -12,8 +12,9 @@ public class TokenTransaction {
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false) @JoinColumn(name = "user_id", nullable = false)
@com.fasterxml.jackson.annotation.JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
private User user; private User user;
@Column(nullable = false) @Column(nullable = false)

View File

@@ -0,0 +1,72 @@
package com.rit.canteen.sales.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "token_units", indexes = {
@Index(name = "idx_token_units_owner_status", columnList = "owner_id, status")
})
public class TokenUnit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String tokenHash;
@Column(name = "owner_id", nullable = true) // Can be null if not yet issued or systemic
private Long ownerId;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private TokenStatus status = TokenStatus.ACTIVE;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column
private LocalDateTime spentAt;
@Column(name = "order_ref")
private String orderRef;
public enum TokenStatus {
ACTIVE,
SPENT,
REVOKED
}
public TokenUnit() {
this.createdAt = LocalDateTime.now();
this.status = TokenStatus.ACTIVE;
}
public TokenUnit(String tokenHash, Long ownerId) {
this();
this.tokenHash = tokenHash;
this.ownerId = ownerId;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTokenHash() { return tokenHash; }
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
public Long getOwnerId() { return ownerId; }
public void setOwnerId(Long ownerId) { this.ownerId = ownerId; }
public TokenStatus getStatus() { return status; }
public void setStatus(TokenStatus status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getSpentAt() { return spentAt; }
public void setSpentAt(LocalDateTime spentAt) { this.spentAt = spentAt; }
public String getOrderRef() { return orderRef; }
public void setOrderRef(String orderRef) { this.orderRef = orderRef; }
}

View File

@@ -8,4 +8,5 @@ import java.util.List;
@Repository @Repository
public interface TokenTransactionRepository extends JpaRepository<TokenTransaction, Long> { public interface TokenTransactionRepository extends JpaRepository<TokenTransaction, Long> {
List<TokenTransaction> findByUserIdOrderByTimestampDesc(Long userId); List<TokenTransaction> findByUserIdOrderByTimestampDesc(Long userId);
List<TokenTransaction> findAllByOrderByTimestampDesc();
} }

View File

@@ -0,0 +1,21 @@
package com.rit.canteen.sales.repository;
import com.rit.canteen.sales.model.TokenUnit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface TokenUnitRepository extends JpaRepository<TokenUnit, Long> {
@Query(value = "SELECT * FROM token_units WHERE owner_id = :ownerId AND status = 'ACTIVE' ORDER BY id ASC LIMIT :amount", nativeQuery = true)
List<TokenUnit> findActiveUnits(@Param("ownerId") Long ownerId, @Param("amount") int amount);
List<TokenUnit> findByOrderRef(String orderRef);
long countByOwnerIdAndStatus(Long ownerId, TokenUnit.TokenStatus status);
org.springframework.data.domain.Page<TokenUnit> findAllByOrderByCreatedAtDesc(org.springframework.data.domain.Pageable pageable);
}

View File

@@ -15,4 +15,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u WHERE LOWER(u.name) LIKE LOWER(CONCAT('%', :search, '%')) " + @Query("SELECT u FROM User u WHERE LOWER(u.name) LIKE LOWER(CONCAT('%', :search, '%')) " +
"OR u.mobileNumber LIKE CONCAT('%', :search, '%')") "OR u.mobileNumber LIKE CONCAT('%', :search, '%')")
org.springframework.data.domain.Page<User> findByNameOrMobileContainingIgnoreCase(String search, org.springframework.data.domain.Pageable pageable); org.springframework.data.domain.Page<User> findByNameOrMobileContainingIgnoreCase(String search, org.springframework.data.domain.Pageable pageable);
@jakarta.persistence.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM User u WHERE u.id = :id")
Optional<User> findByIdWithLock(@org.springframework.data.repository.query.Param("id") Long id);
} }

View File

@@ -29,6 +29,7 @@ public class DatabaseSeeder implements CommandLineRunner {
repairStallsSchema(); repairStallsSchema();
repairOrdersSchema(); repairOrdersSchema();
repairUsersSchema(); repairUsersSchema();
repairTokenUnitsSchema();
repairFeedbackSchema(); repairFeedbackSchema();
repairLobColumns(); repairLobColumns();
seedCategories(); seedCategories();
@@ -66,6 +67,25 @@ public class DatabaseSeeder implements CommandLineRunner {
} }
} }
private void repairTokenUnitsSchema() {
System.out.println("Checking schema consistency for 'token_units' table...");
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS token_units (" +
"id BIGSERIAL PRIMARY KEY, " +
"token_hash VARCHAR(255) UNIQUE NOT NULL, " +
"owner_id BIGINT, " +
"status VARCHAR(20) NOT NULL, " +
"created_at TIMESTAMP NOT NULL, " +
"spent_at TIMESTAMP)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_token_units_owner_status ON token_units (owner_id, status)");
System.out.println("Token Units schema consistency confirmed.");
} catch (Exception e) {
System.err.println("Token Units schema repair notice: " + e.getMessage());
}
}
private void repairStallsSchema() { private void repairStallsSchema() {
System.out.println("Checking schema consistency for 'stalls' table..."); System.out.println("Checking schema consistency for 'stalls' table...");
try { try {

View File

@@ -8,8 +8,15 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.rit.canteen.sales.model.TokenUnit;
import org.springframework.jdbc.core.JdbcTemplate;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@Service @Service
public class TokenService { public class TokenService {
@@ -19,25 +26,78 @@ public class TokenService {
@Autowired @Autowired
private TokenTransactionRepository transactionRepository; private TokenTransactionRepository transactionRepository;
@Autowired
private com.rit.canteen.sales.repository.TokenUnitRepository tokenUnitRepository;
@Autowired
private JdbcTemplate jdbcTemplate;
public List<TokenTransaction> getTransactions(Long userId) { public List<TokenTransaction> getTransactions(Long userId) {
return transactionRepository.findByUserIdOrderByTimestampDesc(userId); return transactionRepository.findByUserIdOrderByTimestampDesc(userId);
} }
public List<TokenTransaction> getAllTransactions() {
return transactionRepository.findAllByOrderByTimestampDesc();
}
public java.util.Map<String, Object> getGlobalStats() {
long totalActive = tokenUnitRepository.count(); // Actually all units
long activeUnits = tokenUnitRepository.countByOwnerIdAndStatus(null, null); // Placeholder, will fix below
List<User> users = userRepository.findAll();
BigDecimal totalBalance = users.stream()
.map(u -> u.getRitzTokenBalance() != null ? u.getRitzTokenBalance() : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
java.util.Map<String, Object> stats = new java.util.HashMap<>();
stats.put("totalCirculation", totalBalance);
stats.put("activeWallets", users.stream().filter(u -> u.getRitzTokenBalance().compareTo(BigDecimal.ZERO) > 0).count());
stats.put("totalUsers", users.size());
stats.put("serializedUnitsTotal", tokenUnitRepository.count());
return stats;
}
public org.springframework.data.domain.Page<TokenUnit> getAllCirculation(int page, int size) {
return tokenUnitRepository.findAllByOrderByCreatedAtDesc(org.springframework.data.domain.PageRequest.of(page, size));
}
@Transactional @Transactional
public User topUp(Long userId, BigDecimal amount, String paymentRef) { public User topUp(Long userId, BigDecimal amount, String paymentRef) {
User user = userRepository.findById(userId) User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found")); .orElseThrow(() -> new RuntimeException("User not found"));
int tokenCount = amount.intValue(); // Assuming 1 Ritz = 1 Serialized Unit
System.out.println("MINTING: Serializing " + tokenCount + " Ritz tokens for user " + userId);
// High-Performance Batch Insertion using JDBC
String sql = "INSERT INTO token_units (token_hash, owner_id, status, created_at) VALUES (?, ?, 'ACTIVE', ?)";
List<Object[]> batchArgs = new ArrayList<>();
LocalDateTime now = LocalDateTime.now();
for (int i = 0; i < tokenCount; i++) {
String hash = generateSecureHash();
batchArgs.add(new Object[]{hash, userId, now});
}
jdbcTemplate.batchUpdate(sql, batchArgs);
// Update cached balance
BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO; BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO;
user.setRitzTokenBalance(currentBalance.add(amount)); BigDecimal newBalance = currentBalance.add(amount);
if (newBalance.compareTo(new BigDecimal("5000")) > 0) {
throw new RuntimeException("Wallet limit exceeded (Max: 5,000 Ritz Tokens)");
}
user.setRitzTokenBalance(newBalance);
User savedUser = userRepository.save(user); User savedUser = userRepository.save(user);
TokenTransaction transaction = new TokenTransaction( TokenTransaction transaction = new TokenTransaction(
user, user,
amount, amount,
TokenTransaction.TransactionType.TOPUP, TokenTransaction.TransactionType.TOPUP,
"Wallet Top Up via UPI/Card", "Regulated Wallet Top Up (Serialized ID: " + paymentRef + ")",
paymentRef paymentRef
); );
transactionRepository.save(transaction); transactionRepository.save(transaction);
@@ -47,7 +107,8 @@ public class TokenService {
@Transactional @Transactional
public void spend(Long userId, BigDecimal amount, String orderRef) { public void spend(Long userId, BigDecimal amount, String orderRef) {
User user = userRepository.findById(userId) // High Concurrency Lock: Ensure no other thread modifies this user balance simultaneously
User user = userRepository.findByIdWithLock(userId)
.orElseThrow(() -> new RuntimeException("User not found")); .orElseThrow(() -> new RuntimeException("User not found"));
BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO; BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO;
@@ -56,6 +117,23 @@ public class TokenService {
throw new RuntimeException("INSUFFICIENT_TOKENS"); throw new RuntimeException("INSUFFICIENT_TOKENS");
} }
int amountToSpend = amount.intValue();
// Identify individual token units to consume
List<TokenUnit> unitsToSpend = tokenUnitRepository.findActiveUnits(userId, amountToSpend);
if (unitsToSpend.size() < amountToSpend) {
throw new RuntimeException("SERIALIZED_RECONCILIATION_ERROR: Not enough active units found");
}
// PERMANENT REMOVAL: Physical delete from database
List<Long> unitIds = unitsToSpend.stream().map(TokenUnit::getId).collect(Collectors.toList());
String deleteSql = "DELETE FROM token_units WHERE id IN (" +
unitIds.stream().map(String::valueOf).collect(Collectors.joining(",")) + ")";
jdbcTemplate.update(deleteSql);
// Update cached user balance
user.setRitzTokenBalance(currentBalance.subtract(amount)); user.setRitzTokenBalance(currentBalance.subtract(amount));
userRepository.save(user); userRepository.save(user);
@@ -63,9 +141,63 @@ public class TokenService {
user, user,
amount, amount,
TokenTransaction.TransactionType.SPEND, TokenTransaction.TransactionType.SPEND,
"Food Order Payment", "Regulated Food Payment (Burned " + amountToSpend + " units)",
orderRef orderRef
); );
transactionRepository.save(transaction); transactionRepository.save(transaction);
System.out.println("BURNED: " + amountToSpend + " Ritz tokens for user " + userId + " [Order: " + orderRef + "]");
}
@Transactional
public void refund(Long userId, String orderRef, BigDecimal amount, String reason) {
// Lock user for safety
User user = userRepository.findByIdWithLock(userId)
.orElseThrow(() -> new RuntimeException("User not found for refund"));
// Since original tokens are deleted, we RESTORE balance by MINTING fresh tokens
int tokenCount = amount.intValue();
System.out.println("REFUNDING: Reminting " + tokenCount + " new Ritz tokens for user " + userId + " [" + reason + "]");
String sql = "INSERT INTO token_units (token_hash, owner_id, status, created_at) VALUES (?, ?, 'ACTIVE', ?)";
List<Object[]> batchArgs = new ArrayList<>();
LocalDateTime now = LocalDateTime.now();
for (int i = 0; i < tokenCount; i++) {
batchArgs.add(new Object[]{generateSecureHash(), userId, now});
}
jdbcTemplate.batchUpdate(sql, batchArgs);
// Update user balance
BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO;
user.setRitzTokenBalance(currentBalance.add(amount));
userRepository.save(user);
// Log transaction
TokenTransaction transaction = new TokenTransaction(
user,
amount,
TokenTransaction.TransactionType.TOPUP,
"Order Refund: " + reason + " (Ref: " + orderRef + ")",
"REF-" + orderRef
);
transactionRepository.save(transaction);
}
private String generateSecureHash() {
try {
String base = UUID.randomUUID().toString() + System.nanoTime();
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(base.getBytes("UTF-8"));
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return "RTX-" + hexString.toString().substring(0, 32).toUpperCase();
} catch (Exception e) {
return "RTX-" + UUID.randomUUID().toString().toUpperCase();
}
} }
} }

View File

@@ -23,6 +23,8 @@ import IntentList from './pages/IntentList.tsx';
import NewArrivals from './pages/NewArrivals.tsx'; import NewArrivals from './pages/NewArrivals.tsx';
import Reports from './pages/Reports.tsx'; import Reports from './pages/Reports.tsx';
import Feedback from './pages/Feedback.tsx'; import Feedback from './pages/Feedback.tsx';
import Ritz from './pages/Ritz.tsx';
import RitzCirculation from './pages/RitzCirculation.tsx';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const isLoggedIn = sessionStorage.getItem('isLoggedIn') === 'true'; const isLoggedIn = sessionStorage.getItem('isLoggedIn') === 'true';
@@ -90,6 +92,8 @@ function App() {
{/* Others */} {/* Others */}
<Route path="reports" element={<Reports />} /> <Route path="reports" element={<Reports />} />
<Route path="feedback" element={<Feedback />} /> <Route path="feedback" element={<Feedback />} />
<Route path="ritz/overview" element={<Ritz />} />
<Route path="ritz/circulation" element={<RitzCirculation />} />
{/* Stores */} {/* Stores */}
<Route path="stores/terminals" element={<Terminals />} /> <Route path="stores/terminals" element={<Terminals />} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -15,12 +15,14 @@ import {
ShoppingBag, ShoppingBag,
Receipt, Receipt,
Search, Search,
LogOut LogOut,
CircleDollarSign
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import collegeLogo from '../assets/college-logo.png'; import collegeLogo from '../assets/college-logo.png';
import colorVector from '../assets/color-vector.png';
function cn(...inputs: ClassValue[]) { function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@@ -115,6 +117,14 @@ const menuItems: MenuItem[] = [
] ]
}, },
{ title: 'Feedback', icon: MessageSquare, path: '/feedback' }, { title: 'Feedback', icon: MessageSquare, path: '/feedback' },
{
title: 'Ritz',
icon: CircleDollarSign,
subMenu: [
{ title: 'Overview', path: '/ritz/overview' },
{ title: 'Ritz in Circulation', path: '/ritz/circulation' }
]
},
]; ];
const Sidebar = () => { const Sidebar = () => {
@@ -141,7 +151,8 @@ const Sidebar = () => {
'Expense': 'expense', 'Expense': 'expense',
'Reports': 'reports', 'Reports': 'reports',
'Stores': 'stores', 'Stores': 'stores',
'Feedback': 'feedback' 'Feedback': 'feedback',
'Ritz': 'ritz'
}; };
return userPermissions.includes(permissionMap[item.title]); return userPermissions.includes(permissionMap[item.title]);
@@ -341,10 +352,19 @@ const Sidebar = () => {
> >
{({ isActive }) => ( {({ isActive }) => (
<> <>
<item.icon size={18} strokeWidth={2} className={cn( {item.title === 'Ritz' ? (
"transition-colors", <div className={cn(
isActive ? "text-white" : "text-[#64748b] group-hover:text-[#475569]" "w-5 h-5 rounded-lg overflow-hidden flex items-center justify-center p-0.5",
)} /> isActive ? "bg-white/20" : "bg-slate-100"
)}>
<img src={colorVector} alt="" className="w-full h-full object-cover rounded-sm" />
</div>
) : (
<item.icon size={18} strokeWidth={2} className={cn(
"transition-colors",
isActive ? "text-white" : "text-[#64748b] group-hover:text-[#475569]"
)} />
)}
<span className="tracking-tight">{item.title}</span> <span className="tracking-tight">{item.title}</span>
</> </>
)} )}

View File

@@ -213,7 +213,7 @@ const ArchivedOrders: React.FC = () => {
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-[10px] text-slate-400 font-medium uppercase mb-0.5">Grand Total</div> <div className="text-[10px] text-slate-400 font-medium uppercase mb-0.5">Grand Total</div>
<div className="text-lg font-black text-slate-900 leading-none">{order.totalAmount.toFixed(2)}</div> <div className="text-lg font-black text-slate-900 leading-none">R{order.totalAmount.toFixed(2)}</div>
</div> </div>
</div> </div>
@@ -325,8 +325,8 @@ const ArchivedOrders: React.FC = () => {
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-sm font-black text-slate-900">{(item.price * item.quantity).toLocaleString()}</div> <div className="text-sm font-black text-slate-900">R{(item.price * item.quantity).toLocaleString()}</div>
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">@ {item.price}</div> <div className="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">@ R{item.price}</div>
</div> </div>
</div> </div>
))} ))}
@@ -377,7 +377,7 @@ const ArchivedOrders: React.FC = () => {
</div> </div>
<div className="text-right flex flex-col gap-1 min-w-[250px]"> <div className="text-right flex flex-col gap-1 min-w-[250px]">
<div className="text-[10px] text-slate-500 uppercase font-black tracking-widest mb-1">Final Settlement</div> <div className="text-[10px] text-slate-500 uppercase font-black tracking-widest mb-1">Final Settlement</div>
<div className="text-4xl font-black text-emerald-400 leading-none mb-1">{selectedOrder.totalAmount.toLocaleString()}</div> <div className="text-4xl font-black text-emerald-400 leading-none mb-1">R{selectedOrder.totalAmount.toLocaleString()}</div>
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-[0.2em]">Transaction Fully Reconciled</div> <div className="text-[10px] text-slate-500 font-bold uppercase tracking-[0.2em]">Transaction Fully Reconciled</div>
</div> </div>
</div> </div>

View File

@@ -207,7 +207,7 @@ const Dashboard = () => {
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="time" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 10, fontWeight: 700}} /> <XAxis dataKey="time" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 10, fontWeight: 700}} />
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 10, fontWeight: 700}} tickFormatter={(v) => v >= 1000 ? `${v/1000}k` : `${v}`} /> <YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 10, fontWeight: 700}} tickFormatter={(v) => v >= 1000 ? `R${v/1000}k` : `R${v}`} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }} /> <Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }} />
<Area type="monotone" dataKey="value" stroke="#f43f5e" strokeWidth={3} fillOpacity={1} fill="url(#colorSalesMain)" /> <Area type="monotone" dataKey="value" stroke="#f43f5e" strokeWidth={3} fillOpacity={1} fill="url(#colorSalesMain)" />
</AreaChart> </AreaChart>
@@ -245,9 +245,9 @@ const Dashboard = () => {
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center"> <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center">
<h2 className="text-3xl font-black text-slate-800 tracking-tighter">₹{stats.periodRevenue.toLocaleString()}</h2> <h2 className="text-3xl font-black text-slate-800 tracking-tighter">R{(stats.periodRevenue || 0).toLocaleString()}</h2>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mt-1">{timeRange === 'Today' ? 'Today' : timeRange}</p> <p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mt-1">{timeRange === 'Today' ? 'Today' : timeRange}</p>
<p className="text-[8px] font-bold text-slate-300 uppercase tracking-widest mt-0.5">Total: ₹{stats.totalSales.toLocaleString()}</p> <p className="text-[8px] font-bold text-slate-300 uppercase tracking-widest mt-0.5">Total: R{(stats.totalSales || 0).toLocaleString()}</p>
</div> </div>
<div className="flex gap-6 mt-2"> <div className="flex gap-6 mt-2">
{pieData.map(item => ( {pieData.map(item => (
@@ -412,7 +412,7 @@ const Dashboard = () => {
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-[9px] font-black text-rose-500 uppercase tracking-widest opacity-60">Gross Sale</p> <p className="text-[9px] font-black text-rose-500 uppercase tracking-widest opacity-60">Gross Sale</p>
<p className="text-lg font-black text-slate-800 tracking-tighter">{Number(store.sale).toLocaleString()}</p> <p className="text-lg font-black text-slate-800 tracking-tighter">R{Number(store.sale).toLocaleString()}</p>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-[9px] font-black text-blue-500 uppercase tracking-widest opacity-60">Volume</p> <p className="text-[9px] font-black text-blue-500 uppercase tracking-widest opacity-60">Volume</p>

View File

@@ -277,7 +277,7 @@ const NewArrivals: React.FC = () => {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-[10px] uppercase font-black text-[#64748b] mb-2 tracking-widest ml-1">Live Selling Price ()</label> <label className="block text-[10px] uppercase font-black text-[#64748b] mb-2 tracking-widest ml-1">Live Selling Price (R)</label>
<input required type="number" value={formData.price} onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })} className="w-full px-5 py-4 bg-[#231651]/5 border-2 border-transparent focus:border-[#231651]/20 rounded-2xl text-lg font-black text-[#231651] outline-none transition-all" /> <input required type="number" value={formData.price} onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })} className="w-full px-5 py-4 bg-[#231651]/5 border-2 border-transparent focus:border-[#231651]/20 rounded-2xl text-lg font-black text-[#231651] outline-none transition-all" />
</div> </div>
<div> <div>

View File

@@ -465,7 +465,7 @@ const Orders: React.FC = () => {
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-[10px] text-slate-400 font-medium uppercase mb-0.5">Grand Total</div> <div className="text-[10px] text-slate-400 font-medium uppercase mb-0.5">Grand Total</div>
<div className="text-lg font-black text-slate-900 leading-none">{order.totalAmount.toFixed(2)}</div> <div className="text-lg font-black text-slate-900 leading-none">R{order.totalAmount.toFixed(2)}</div>
</div> </div>
</div> </div>
@@ -628,9 +628,9 @@ const Orders: React.FC = () => {
</div> </div>
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<div className="text-right"> <div className="text-right">
<div className="text-sm font-black text-slate-900">{(item.price * item.quantity).toLocaleString()}</div> <div className="text-sm font-black text-slate-900">R{(item.price * item.quantity).toLocaleString()}</div>
<div className="text-[10px] font-bold text-slate-400"> <div className="text-[10px] font-bold text-slate-400">
{item.quantity} x <span className="text-indigo-400 font-black">{item.price}</span> {item.quantity} x <span className="text-indigo-400 font-black">R{item.price}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -700,7 +700,7 @@ const Orders: React.FC = () => {
<div className="text-right flex flex-col gap-1 min-w-[250px]"> <div className="text-right flex flex-col gap-1 min-w-[250px]">
<div className="text-[10px] text-indigo-400 uppercase font-black tracking-widest mb-1">Active Grand Total</div> <div className="text-[10px] text-indigo-400 uppercase font-black tracking-widest mb-1">Active Grand Total</div>
<div className="text-4xl font-black text-emerald-400 leading-none mb-1">{selectedOrder.totalAmount.toLocaleString()}</div> <div className="text-4xl font-black text-emerald-400 leading-none mb-1">R{selectedOrder.totalAmount.toLocaleString()}</div>
<div className="text-[10px] text-white/40 font-bold uppercase tracking-[0.2em]">Transaction Pending Approval</div> <div className="text-[10px] text-white/40 font-bold uppercase tracking-[0.2em]">Transaction Pending Approval</div>
</div> </div>
</div> </div>
@@ -784,7 +784,7 @@ const Orders: React.FC = () => {
</div> </div>
<div> <div>
<div className="font-bold text-slate-800 text-sm">{item.productName}</div> <div className="font-bold text-slate-800 text-sm">{item.productName}</div>
<div className="text-[10px] font-black text-indigo-400 uppercase">{item.price} each</div> <div className="text-[10px] font-black text-indigo-400 uppercase">R{item.price} each</div>
</div> </div>
</div> </div>
@@ -805,7 +805,7 @@ const Orders: React.FC = () => {
</button> </button>
</div> </div>
<div className="text-right min-w-[80px]"> <div className="text-right min-w-[80px]">
<div className="text-sm font-black text-slate-900 leading-none mb-1">{(item.price * item.quantity).toFixed(2)}</div> <div className="text-sm font-black text-slate-900 leading-none mb-1">R{(item.price * item.quantity).toFixed(2)}</div>
<button <button
onClick={() => removeItem(idx)} onClick={() => removeItem(idx)}
className="text-[10px] font-black text-rose-400 hover:text-rose-600 uppercase tracking-widest transition-colors flex items-center gap-1 active:scale-95" className="text-[10px] font-black text-rose-400 hover:text-rose-600 uppercase tracking-widest transition-colors flex items-center gap-1 active:scale-95"
@@ -848,7 +848,7 @@ const Orders: React.FC = () => {
<div className="font-bold text-slate-800 text-sm group-hover:text-indigo-600 transition-colors">{product.name}</div> <div className="font-bold text-slate-800 text-sm group-hover:text-indigo-600 transition-colors">{product.name}</div>
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{product.category}</div> <div className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{product.category}</div>
</div> </div>
<div className="font-black text-emerald-600 text-xs">{product.price}</div> <div className="font-black text-emerald-600 text-xs">R{product.price}</div>
</button> </button>
)) ))
) : editSearchQuery ? ( ) : editSearchQuery ? (
@@ -866,7 +866,7 @@ const Orders: React.FC = () => {
<div className="mt-8 pt-8 border-t border-slate-200"> <div className="mt-8 pt-8 border-t border-slate-200">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">New Order Total</span> <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">New Order Total</span>
<span className="text-2xl font-black text-emerald-600 tracking-tighter">{editTotal.toFixed(2)}</span> <span className="text-2xl font-black text-emerald-600 tracking-tighter">R{editTotal.toFixed(2)}</span>
</div> </div>
<button <button
onClick={saveOrderEdits} onClick={saveOrderEdits}

View File

@@ -395,7 +395,7 @@ const Products = () => {
})()} })()}
</div> </div>
</td> </td>
<td className="px-6 py-4 text-sm font-bold text-[#1e293b]">{product.price}</td> <td className="px-6 py-4 text-sm font-bold text-[#1e293b]">R{product.price}</td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold w-fit ${product.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}> <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold w-fit ${product.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>
@@ -472,7 +472,7 @@ const Products = () => {
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div><label className="block text-[11px] uppercase font-bold text-[#64748b] mb-1.5">Base Price</label><input type="number" value={formData.basePrice} onChange={(e) => setFormData({ ...formData, basePrice: parseFloat(e.target.value) || 0 })} className="w-full px-4 py-3 border border-[#e2e8f0] rounded-xl text-sm" /></div> <div><label className="block text-[11px] uppercase font-bold text-[#64748b] mb-1.5">Base Price</label><input type="number" value={formData.basePrice} onChange={(e) => setFormData({ ...formData, basePrice: parseFloat(e.target.value) || 0 })} className="w-full px-4 py-3 border border-[#e2e8f0] rounded-xl text-sm" /></div>
<div><label className="block text-[11px] uppercase font-bold text-[#64748b] mb-1.5">Sale Price</label><input type="number" value={formData.price} onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })} className="w-full px-4 py-3 border border-[#e2e8f0] rounded-xl text-sm font-bold" /></div> <div><label className="block text-[11px] uppercase font-bold text-[#64748b] mb-1.5">Sale Price (R)</label><input type="number" value={formData.price} onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })} className="w-full px-4 py-3 border border-[#e2e8f0] rounded-xl text-sm font-bold" /></div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div><label className="block text-[11px] uppercase font-bold text-[#64748b] mb-1.5">Discount %</label><input type="number" value={formData.discountPercent} onChange={(e) => setFormData({ ...formData, discountPercent: parseFloat(e.target.value) || 0 })} className="w-full px-4 py-3 border border-[#e2e8f0] rounded-xl text-sm" /></div> <div><label className="block text-[11px] uppercase font-bold text-[#64748b] mb-1.5">Discount %</label><input type="number" value={formData.discountPercent} onChange={(e) => setFormData({ ...formData, discountPercent: parseFloat(e.target.value) || 0 })} className="w-full px-4 py-3 border border-[#e2e8f0] rounded-xl text-sm" /></div>

235
frontend/src/pages/Ritz.tsx Normal file
View File

@@ -0,0 +1,235 @@
import React, { useState, useEffect } from 'react';
import {
Building2,
CircleDollarSign,
TrendingUp,
Users,
ArrowUpRight,
ArrowDownLeft,
Clock,
Search,
Filter,
Download,
Fingerprint
} from 'lucide-react';
import { motion } from 'framer-motion';
import colorVector from '../assets/color-vector.png';
interface TokenTransaction {
id: number;
amount: number;
type: 'TOPUP' | 'SPEND' | 'REFUND';
description: string;
timestamp: string;
referenceId: string;
user: {
id: number;
name: string;
mobileNumber: string;
};
}
interface Stats {
totalCirculation: number;
activeWallets: number;
totalUsers: number;
serializedUnitsTotal: number;
}
const RitzPage: React.FC = () => {
const [transactions, setTransactions] = useState<TokenTransaction[]>([]);
const [stats, setStats] = useState<Stats>({
totalCirculation: 0,
activeWallets: 0,
totalUsers: 0,
serializedUnitsTotal: 0
});
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setIsLoading(true);
const host = window.location.hostname;
const statsRes = await fetch(`http://${host}:8080/api/wallet/stats`);
const statsData = await statsRes.json();
setStats(statsData);
const transRes = await fetch(`http://${host}:8080/api/wallet/transactions/all`);
const transData = await transRes.json();
setTransactions(Array.isArray(transData) ? transData : []);
} catch (error) {
console.error('Error fetching Ritz data:', error);
setTransactions([]);
} finally {
setIsLoading(false);
}
};
const filteredTransactions = Array.isArray(transactions) ? transactions.filter(t =>
t.user?.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.user?.mobileNumber?.includes(searchTerm) ||
t.referenceId?.toLowerCase().includes(searchTerm.toLowerCase())
) : [];
return (
<div className="p-8 bg-[#f8fafc] min-h-screen font-inter animate-slideUp">
{/* Premium Header Banner */}
<div className="relative overflow-hidden bg-[#231651] rounded-3xl p-8 mb-8 text-white shadow-2xl shadow-indigo-200">
<img
src={colorVector}
alt="Vector BG"
className="absolute right-0 top-0 h-full w-auto opacity-30 object-cover pointer-events-none"
/>
<div className="relative z-10">
<div className="flex items-center gap-4 mb-2">
<div className="p-3 bg-white/10 backdrop-blur-md rounded-2xl border border-white/20">
<CircleDollarSign size={32} />
</div>
<h1 className="text-3xl font-black tracking-tight">Ritz Ecosystem</h1>
</div>
<p className="text-indigo-100/80 max-w-lg font-medium">
Monitor the heartbeat of the Ritz digital economy. Track circulation,
active wallets, and system-wide transactions in real-time.
</p>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8">
{[
{ label: 'Tokens in Circulation', value: `R${(stats.totalCirculation || 0).toLocaleString()}`, icon: TrendingUp, color: 'text-emerald-600', bg: 'bg-emerald-50' },
{ label: 'Active Token Wallets', value: (stats.activeWallets || 0).toLocaleString(), icon: Users, color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: 'Serialized Audit Units', value: (stats.serializedUnitsTotal || 0).toLocaleString(), icon: Fingerprint, color: 'text-amber-600', bg: 'bg-amber-50' },
{ label: 'Total Accounts', value: (stats.totalUsers || 0).toLocaleString(), icon: Building2, color: 'text-indigo-600', bg: 'bg-indigo-50' }
].map((item, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm flex items-center justify-between"
>
<div>
<p className="text-sm font-bold text-slate-500 mb-1">{item.label}</p>
<h3 className={`text-3xl font-black ${item.color}`}>{item.value}</h3>
</div>
<div className={`p-4 ${item.bg} ${item.color} rounded-2xl`}>
<item.icon size={28} />
</div>
</motion.div>
))}
</div>
{/* Main Table Section */}
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-6 border-b border-slate-100 flex flex-col md:flex-row justify-between items-center gap-4">
<div>
<h2 className="text-xl font-black text-slate-800">Master Transaction Log</h2>
<p className="text-sm text-slate-500 font-medium">Detailed history of all wallet activities</p>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="Search user or ref..."
className="pl-10 pr-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-indigo-50 focus:border-indigo-500 focus:bg-white outline-none transition-all w-64"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button className="p-2.5 bg-slate-50 border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-100 transition-colors">
<Filter size={18} />
</button>
<button className="flex items-center gap-2 px-4 py-2.5 bg-[#231651] text-white rounded-xl font-bold text-sm shadow-lg shadow-indigo-100 hover:scale-[1.02] active:scale-95 transition-all">
<Download size={18} /> Export
</button>
</div>
</div>
<div className="overflow-x-auto">
{isLoading ? (
<div className="p-24 flex flex-col items-center justify-center text-slate-400">
<div className="w-12 h-12 border-4 border-indigo-100 border-t-indigo-500 rounded-full animate-spin mb-4" />
<span className="font-bold">Syncing Transaction Feed...</span>
</div>
) : (
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50/50">
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Customer</th>
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Type</th>
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Amount</th>
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Description</th>
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Timestamp</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{filteredTransactions.map((t, i) => (
<tr key={i} className="hover:bg-slate-50/50 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-100 rounded-xl flex items-center justify-center text-indigo-700 font-black text-xs">
{t.user.name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()}
</div>
<div>
<p className="text-sm font-bold text-slate-800">{t.user.name}</p>
<p className="text-[11px] text-slate-500 font-medium">{t.user.mobileNumber}</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-tight ${
t.type === 'TOPUP' ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'
}`}>
{t.type === 'TOPUP' ? <ArrowUpRight size={12} /> : <ArrowDownLeft size={12} />}
{t.type}
</span>
</td>
<td className="px-6 py-4">
<span className={`text-base font-black ${t.type === 'TOPUP' ? 'text-emerald-600' : 'text-slate-800'}`}>
{t.type === 'TOPUP' ? '+' : '-'} {t.amount.toLocaleString()}
</span>
</td>
<td className="px-6 py-4">
<p className="text-sm font-bold text-slate-700">{t.description}</p>
<p className="text-[11px] text-slate-400 font-medium font-mono">{t.referenceId}</p>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-[13px] font-bold text-slate-500">
<Clock size={14} className="opacity-60" />
{new Date(t.timestamp).toLocaleString('en-IN', {
day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit'
})}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Animation Styles */}
<style>{`
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-slideUp {
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
`}</style>
</div>
);
};
export default RitzPage;

View File

@@ -0,0 +1,287 @@
import React, { useState, useEffect } from 'react';
import {
CircleDollarSign,
Search,
Filter,
Download,
Fingerprint,
ShieldCheck,
History,
Lock,
Unlock,
Users,
ChevronLeft,
ChevronRight
} from 'lucide-react';
import { motion } from 'framer-motion';
import colorVector from '../assets/color-vector.png';
interface TokenUnit {
id: number;
tokenHash: string;
ownerId: number;
status: 'ACTIVE' | 'SPENT' | 'REVOKED';
createdAt: string;
spentAt: string | null;
}
interface PageResponse {
content: TokenUnit[];
totalPages: number;
totalElements: number;
number: number;
size: number;
}
const RitzCirculation: React.FC = () => {
const [tokens, setTokens] = useState<TokenUnit[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
// Pagination State
const [page, setPage] = useState(0);
const [size] = useState(20);
const [totalPages, setTotalPages] = useState(0);
const [totalElements, setTotalElements] = useState(0);
useEffect(() => {
fetchCirculation();
}, [page, size]);
const fetchCirculation = async () => {
try {
setIsLoading(true);
const host = window.location.hostname;
const res = await fetch(`http://${host}:8080/api/wallet/circulation?page=${page}&size=${size}`);
const data: PageResponse = await res.json();
setTokens(data.content || []);
setTotalPages(data.totalPages || 0);
setTotalElements(data.totalElements || 0);
} catch (error) {
console.error('Error fetching circulation:', error);
} finally {
setIsLoading(false);
}
};
const filteredTokens = tokens.filter(t =>
t.tokenHash.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.ownerId?.toString().includes(searchTerm)
);
return (
<div className="p-8 bg-[#f8fafc] min-h-screen font-inter animate-slideUp pb-24">
{/* Regulated Header */}
<div className="relative overflow-hidden bg-[#0d0d1e] rounded-3xl p-8 mb-8 text-white shadow-2xl border border-white/5">
<img
src={colorVector}
alt="Vector BG"
className="absolute right-[-10%] top-[-20%] h-[150%] w-auto opacity-10 object-cover pointer-events-none"
/>
<div className="relative z-10">
<div className="flex items-center gap-4 mb-2">
<div className="p-3 bg-red-500/10 backdrop-blur-md rounded-2xl border border-red-500/20">
<ShieldCheck size={32} className="text-red-400" />
</div>
<div>
<h1 className="text-3xl font-black tracking-tight">Ritz Forensic Ledger</h1>
<div className="flex items-center gap-2 mt-1 px-3 py-1 bg-red-500/20 rounded-full w-fit border border-red-500/20">
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse" />
<span className="text-[10px] font-black uppercase tracking-widest text-red-400">Strictly Regulated</span>
</div>
</div>
</div>
<p className="text-slate-400 max-w-lg font-medium mt-4">
A comprehensive, non-repudiable audit trail of every individual Ritz unit.
Each token is uniquely serialized and traceable to its point of issuance.
</p>
</div>
</div>
{/* Circulation Stats - Summary level only as we now have pagination */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-indigo-50 text-indigo-600 rounded-lg">
<Fingerprint size={18} />
</div>
<span className="text-[11px] font-black uppercase tracking-wider text-slate-400">Total Audit Population</span>
</div>
<h3 className="text-2xl font-black text-indigo-600">{totalElements.toLocaleString()} Units</h3>
</div>
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-emerald-50 text-emerald-600 rounded-lg">
<Unlock size={18} />
</div>
<span className="text-[11px] font-black uppercase tracking-wider text-slate-400">Ledger Compliance</span>
</div>
<h3 className="text-2xl font-black text-emerald-600">100% Validated</h3>
</div>
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-slate-50 text-slate-600 rounded-lg">
<History size={18} />
</div>
<span className="text-[11px] font-black uppercase tracking-wider text-slate-400">Current View Range</span>
</div>
<h3 className="text-2xl font-black text-slate-600">Page {page + 1} / {totalPages || 1}</h3>
</div>
</div>
{/* Ledger Table */}
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden min-h-[400px]">
<div className="p-6 border-b border-slate-100 flex flex-col md:flex-row justify-between items-center gap-4 bg-slate-50/30">
<div>
<h2 className="text-xl font-black text-slate-800">Circulation Audit</h2>
<p className="text-sm text-slate-500 font-medium">Page {page + 1} of {totalPages} (Showing {tokens.length} records)</p>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="Search Serial Hash..."
className="pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-indigo-50 focus:border-indigo-500 outline-none transition-all w-72 shadow-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center gap-2 border-l border-slate-200 ml-2 pl-4">
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0 || isLoading}
className="p-2 bg-white border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
>
<ChevronLeft size={18} />
</button>
<button
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1 || isLoading}
className="p-2 bg-white border border-slate-200 rounded-xl text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
>
<ChevronRight size={18} />
</button>
</div>
<button className="flex items-center gap-2 px-4 py-2.5 bg-[#0d0d1e] text-white rounded-xl font-bold text-sm shadow-lg hover:scale-[1.02] active:scale-95 transition-all ml-2">
<Download size={18} /> Export
</button>
</div>
</div>
<div className="overflow-x-auto overflow-y-hidden">
{isLoading ? (
<div className="p-24 flex flex-col items-center justify-center text-slate-400 uppercase tracking-widest text-xs font-black">
<div className="w-10 h-10 border-4 border-slate-100 border-t-red-500 rounded-full animate-spin mb-4" />
<span>Verifying Ledger Integrity...</span>
</div>
) : (
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50/80">
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Transaction Unit ID</th>
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Cryptographic Serial</th>
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Owner UID</th>
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Status</th>
<th className="px-6 py-4 text-[11px] font-black uppercase tracking-wider text-slate-400">Issuance Date</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{filteredTokens.map((t, i) => (
<tr key={i} className="hover:bg-slate-50/50 transition-colors">
<td className="px-6 py-4">
<span className="text-[13px] font-black text-slate-400 font-mono">#{t.id.toString().padStart(6, '0')}</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<div className="p-1 px-2 bg-indigo-50 border border-indigo-100 rounded text-[11px] font-black text-indigo-700 font-mono">
{t.tokenHash}
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm font-bold text-slate-600">
<Users size={14} className="opacity-40" />
{t.ownerId || 'SYSTEM'}
</div>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-tight ${
t.status === 'ACTIVE' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500 line-through'
}`}>
{t.status === 'ACTIVE' ? <Unlock size={10} /> : <Lock size={10} />}
{t.status}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-[12px] font-bold text-slate-500">
<History size={14} className="opacity-40" />
{new Date(t.createdAt).toLocaleString('en-IN', {
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'
})}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Pagination Footer - Sticky style */}
<div className="fixed bottom-0 left-64 right-0 p-4 bg-white/80 backdrop-blur-md border-t border-slate-200 flex justify-center items-center gap-4 z-40">
<div className="flex items-center gap-2">
<button
onClick={() => setPage(0)}
disabled={page === 0 || isLoading}
className="px-3 py-1.5 bg-slate-100 border border-slate-200 rounded-lg text-[11px] font-black text-slate-600 hover:bg-slate-200 disabled:opacity-40 transition-all uppercase"
>
First
</button>
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0 || isLoading}
className="flex items-center gap-2 px-4 py-1.5 bg-indigo-600 text-white rounded-xl text-sm font-bold hover:bg-indigo-700 disabled:opacity-40 transition-all shadow-md shadow-indigo-100"
>
<ChevronLeft size={16} /> Prev
</button>
<div className="px-6 py-1.5 bg-slate-50 border border-slate-200 rounded-xl text-sm font-black text-slate-800 flex items-center gap-2 min-w-[140px] justify-center">
{isLoading ? '...' : `Page ${page + 1} of ${totalPages || 1}`}
</div>
<button
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1 || isLoading}
className="flex items-center gap-2 px-4 py-1.5 bg-indigo-600 text-white rounded-xl text-sm font-bold hover:bg-indigo-700 disabled:opacity-40 transition-all shadow-md shadow-indigo-100"
>
Next <ChevronRight size={16} />
</button>
<button
onClick={() => setPage(totalPages - 1)}
disabled={page >= totalPages - 1 || isLoading}
className="px-3 py-1.5 bg-slate-100 border border-slate-200 rounded-lg text-[11px] font-black text-slate-600 hover:bg-slate-200 disabled:opacity-40 transition-all uppercase"
>
Last
</button>
</div>
</div>
{/* Animation Styles */}
<style>{`
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-slideUp {
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
`}</style>
</div>
);
};
export default RitzCirculation;

View File

@@ -639,7 +639,7 @@ const Stalls: React.FC = () => {
</div> </div>
<div> <div>
<p className="font-bold text-[#1e293b] text-sm">{product.name}</p> <p className="font-bold text-[#1e293b] text-sm">{product.name}</p>
<p className="text-[10px] font-bold text-[#94a3b8] uppercase tracking-wider">{product.price} {product.category}</p> <p className="text-[10px] font-bold text-[#94a3b8] uppercase tracking-wider">R{product.price} {product.category}</p>
</div> </div>
</div> </div>
<div className={`w-6 h-6 rounded flex items-center justify-center border-2 transition-all ${isSelected ? 'bg-[#231651] border-[#231651]' : 'border-slate-200 bg-white'}`}> <div className={`w-6 h-6 rounded flex items-center justify-center border-2 transition-all ${isSelected ? 'bg-[#231651] border-[#231651]' : 'border-slate-200 bg-white'}`}>

View File

@@ -185,7 +185,7 @@ const StoreDashboard = () => {
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" /> <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="time" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 10, fontWeight: 700}} /> <XAxis dataKey="time" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 10, fontWeight: 700}} />
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 10, fontWeight: 700}} tickFormatter={(v) => v >= 1000 ? `${v/1000}k` : v} /> <YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 10, fontWeight: 700}} tickFormatter={(v) => v >= 1000 ? `R${v/1000}k` : `R${v}`} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }} /> <Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }} />
<Area type="monotone" dataKey="value" stroke="#f43f5e" strokeWidth={3} fillOpacity={1} fill="url(#colorSalesStore)" /> <Area type="monotone" dataKey="value" stroke="#f43f5e" strokeWidth={3} fillOpacity={1} fill="url(#colorSalesStore)" />
</AreaChart> </AreaChart>
@@ -216,7 +216,7 @@ const StoreDashboard = () => {
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center"> <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center">
<h2 className="text-3xl font-black text-slate-800 tracking-tighter">{formatCurrency(stats.totalSales)}</h2> <h2 className="text-3xl font-black text-slate-800 tracking-tighter">R{formatCurrency(stats.totalSales)}</h2>
</div> </div>
<div className="flex gap-6 mt-4"> <div className="flex gap-6 mt-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -23,7 +23,7 @@ const CartTab: React.FC = () => {
{totalItems} {totalItems === 1 ? 'item' : 'items'} {totalItems} {totalItems === 1 ? 'item' : 'items'}
</span> </span>
</div> </div>
<div className="cart-total">{totalPrice.toFixed(0)}</div> <div className="cart-total">R{totalPrice.toFixed(0)}</div>
</div> </div>
<button className="place-order-btn" onClick={() => navigate('/checkout')}> <button className="place-order-btn" onClick={() => navigate('/checkout')}>

View File

@@ -70,7 +70,7 @@ const Header: React.FC<HeaderProps> = ({ title, onBack, showCart = true }) => {
{totalItems > 0 ? ( {totalItems > 0 ? (
<> <>
<span className="cart-count">{totalItems}</span> <span className="cart-count">{totalItems}</span>
<span className="cart-price">{totalPrice.toFixed(2)}</span> <span className="cart-price">R{totalPrice.toFixed(2)}</span>
</> </>
) : ( ) : (
<span className="cart-text">Cart</span> <span className="cart-text">Cart</span>

View File

@@ -31,7 +31,7 @@ const ItemCard: React.FC<ItemCardProps> = ({ item, isLast }) => {
</div> </div>
<h3 className="item-name">{item.name}</h3> <h3 className="item-name">{item.name}</h3>
<p className="item-price">{item.price.toFixed(2)}</p> <p className="item-price">R{item.price.toFixed(2)}</p>
<p className="item-description">{item.description}</p> <p className="item-description">{item.description}</p>
</div> </div>

View File

@@ -46,7 +46,7 @@ const CartScreen: React.FC = () => {
</div> </div>
<div className="cart-item-footer"> <div className="cart-item-footer">
<span className="cart-item-price">{(item.price * item.quantity).toFixed(2)}</span> <span className="cart-item-price">R{(item.price * item.quantity).toFixed(2)}</span>
<div className="cart-quantity-controls"> <div className="cart-quantity-controls">
<button onClick={() => updateQuantity(item.id, -1)}> <button onClick={() => updateQuantity(item.id, -1)}>
@@ -67,7 +67,7 @@ const CartScreen: React.FC = () => {
<h2 className="section-title">Bill Details</h2> <h2 className="section-title">Bill Details</h2>
<div className="bill-row"> <div className="bill-row">
<span>Item Total</span> <span>Item Total</span>
<span>{totalPrice.toFixed(2)}</span> <span>R{totalPrice.toFixed(2)}</span>
</div> </div>
<div className="bill-row"> <div className="bill-row">
<span>Delivery Fee</span> <span>Delivery Fee</span>
@@ -75,11 +75,11 @@ const CartScreen: React.FC = () => {
</div> </div>
<div className="bill-row"> <div className="bill-row">
<span>Taxes and Charges</span> <span>Taxes and Charges</span>
<span>2.50</span> <span>R2.50</span>
</div> </div>
<div className="bill-row total"> <div className="bill-row total">
<span>To Pay</span> <span>To Pay</span>
<span>{(totalPrice + 2.5).toFixed(2)}</span> <span>R{(totalPrice + 2.5).toFixed(2)}</span>
</div> </div>
</div> </div>
</main> </main>
@@ -87,7 +87,7 @@ const CartScreen: React.FC = () => {
<div className="cart-footer"> <div className="cart-footer">
<div className="footer-total"> <div className="footer-total">
<span className="items-count">{totalItems} {totalItems === 1 ? 'Item' : 'Items'}</span> <span className="items-count">{totalItems} {totalItems === 1 ? 'Item' : 'Items'}</span>
<span className="final-price">{(totalPrice + 2.5).toFixed(2)}</span> <span className="final-price">R{(totalPrice + 2.5).toFixed(2)}</span>
</div> </div>
<button <button
className="checkout-button" className="checkout-button"

View File

@@ -181,7 +181,7 @@ const CheckoutScreen: React.FC = () => {
<div className="payment-security-note"> <div className="payment-security-note">
<ShieldCheck size={14} /> <ShieldCheck size={14} />
<span>Secured by Ritz Token Protocol. 1 Token = 1.00</span> <span>Secured by Ritz Token Protocol. 1 Token = R1.00</span>
</div> </div>
</section> </section>

View File

@@ -49,7 +49,7 @@ const ItemDetailScreen: React.FC = () => {
</div> </div>
<h1 className="item-name-large">{item.name}</h1> <h1 className="item-name-large">{item.name}</h1>
<p className="item-price-large">{item.price.toFixed(2)}</p> <p className="item-price-large">R{item.price.toFixed(2)}</p>
</div> </div>
<div className="item-description-section"> <div className="item-description-section">
@@ -88,7 +88,7 @@ const ItemDetailScreen: React.FC = () => {
<footer className="item-footer"> <footer className="item-footer">
<div className="footer-price-info"> <div className="footer-price-info">
<span className="total-label">Price</span> <span className="total-label">Price</span>
<span className="total-value">{(item.price * Math.max(1, quantity)).toFixed(2)}</span> <span className="total-value">R{(item.price * Math.max(1, quantity)).toFixed(2)}</span>
</div> </div>
<div className="footer-action"> <div className="footer-action">

View File

@@ -227,7 +227,7 @@ const MyOrdersScreen: React.FC = () => {
<div className="order-total-bar"> <div className="order-total-bar">
<span>Total Amount</span> <span>Total Amount</span>
<span className="amount-text">{latestOrder.totalAmount.toFixed(2)}</span> <span className="amount-text">R{latestOrder.totalAmount.toFixed(2)}</span>
</div> </div>
<button <button
@@ -256,7 +256,7 @@ const MyOrdersScreen: React.FC = () => {
<div className="order-list-info"> <div className="order-list-info">
<div className="order-list-top"> <div className="order-list-top">
<span className="order-list-number">#{order.displayOrderId}</span> <span className="order-list-number">#{order.displayOrderId}</span>
<span className="order-list-price">{order.totalAmount.toFixed(0)}</span> <span className="order-list-price">R{order.totalAmount.toFixed(0)}</span>
</div> </div>
<div className="order-list-bottom"> <div className="order-list-bottom">
<span className="order-list-date">{new Date(order.createdAt).toLocaleDateString()}</span> <span className="order-list-date">{new Date(order.createdAt).toLocaleDateString()}</span>
@@ -353,12 +353,12 @@ const MyOrdersScreen: React.FC = () => {
{selectedOrder.items.map((item, idx) => ( {selectedOrder.items.map((item, idx) => (
<div key={idx} className="modal-item-row"> <div key={idx} className="modal-item-row">
<span>{item.quantity} x {item.productName}</span> <span>{item.quantity} x {item.productName}</span>
<span>{(item.price * item.quantity).toFixed(0)}</span> <span>R{(item.price * item.quantity).toFixed(0)}</span>
</div> </div>
))} ))}
<div className="modal-total-row"> <div className="modal-total-row">
<span>Grand Total</span> <span>Grand Total</span>
<span>{selectedOrder.totalAmount.toFixed(2)}</span> <span>R{selectedOrder.totalAmount.toFixed(2)}</span>
</div> </div>
</div> </div>