EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
page.tsx
Go to the documentation of this file.
1"use client";
2
3import React, { useState, useEffect } from 'react';
4import { apiClient } from '@/lib/client/apiClient';
5import { Icon } from '@/contexts/IconContext';
6
7interface LoyaltyCampaign {
8 f100: number; // ID
9 f101: string; // Name
10 f102: string; // Start Date
11 f103: string; // Earn End Date
12 f104: number; // Reward Level
13 f105: number; // Money Reward Level
14 f106: number; // Gain
15 f107: string; // Input Pid
16 f108: string; // Output Pid
17 f109: number; // Membership Type flags
18 f113: number; // Active Days
19 f114: string; // Redeem End Date
20}
21
22export default function LoyaltyPage() {
23 const [campaigns, setCampaigns] = useState<LoyaltyCampaign[]>([]);
24 const [filteredCampaigns, setFilteredCampaigns] = useState<LoyaltyCampaign[]>([]);
25 const [loading, setLoading] = useState(false);
26 const [searchTerm, setSearchTerm] = useState('');
27 const [showModal, setShowModal] = useState(false);
28 const [editingCampaign, setEditingCampaign] = useState<LoyaltyCampaign | null>(null);
29 const [sortConfig, setSortConfig] = useState<{ key: keyof LoyaltyCampaign; direction: 'asc' | 'desc' } | null>(null);
30
31 // Form state
32 const [formData, setFormData] = useState({
33 name: '',
34 startDate: '',
35 earnEndDate: '',
36 redeemEndDate: '',
37 rewardLevel: '',
38 moneyRewardLevel: '',
39 gain: '',
40 inputPid: '',
41 outputPid: '',
42 membershipType: '1',
43 activeDays: ''
44 });
45
46 useEffect(() => {
47 loadCampaigns();
48 }, []);
49
50 useEffect(() => {
51 handleSearch();
52 }, [searchTerm, campaigns]);
53
54 const loadCampaigns = async () => {
55 setLoading(true);
56 try {
57 const result = await apiClient.getBuckData({
58 table: 'retailmax.elink.loyalty.campaign',
59 want: 'all'
60 });
61
62 if (result.success && result.data) {
63 const appData = result.data.APPD || [];
64 const mapped: LoyaltyCampaign[] = appData.map((item: any) => ({
65 f100: Number(item.f100) || 0,
66 f101: item.f101 || "",
67 f102: item.f102 || "",
68 f103: item.f103 || "",
69 f114: item.f114 || "",
70 f104: Number(item.f104) || 0,
71 f105: Number(item.f105) || 0,
72 f106: Number(item.f106) || 1,
73 f107: item.f107 || "",
74 f108: item.f108 || "",
75 f109: Number(item.f109) || 0,
76 f113: Number(item.f113) || 0
77 }));
78 setCampaigns(mapped);
79 } else {
80 console.error('Failed to load campaigns:', result.error);
81 setCampaigns([]);
82 }
83 } catch (error) {
84 console.error('Error loading loyalty campaigns:', error);
85 setCampaigns([]);
86 } finally {
87 setLoading(false);
88 }
89 };
90
91 const handleSearch = () => {
92 if (!searchTerm.trim()) {
93 setFilteredCampaigns(campaigns);
94 return;
95 }
96
97 const term = searchTerm.toLowerCase();
98 const filtered = campaigns.filter(campaign =>
99 String(campaign.f100 || '').toLowerCase().includes(term) ||
100 String(campaign.f101 || '').toLowerCase().includes(term) ||
101 String(campaign.f104 || '').toLowerCase().includes(term) ||
102 String(campaign.f105 || '').toLowerCase().includes(term)
103 );
104 setFilteredCampaigns(filtered);
105 };
106
107 const formatDate = (dateStr: string) => {
108 if (!dateStr) return '';
109 try {
110 const date = new Date(dateStr);
111 return date.toISOString().split('T')[0];
112 } catch {
113 return dateStr;
114 }
115 };
116
117 const formatCurrency = (value: number) => {
118 return new Intl.NumberFormat('en-NZ', {
119 style: 'currency',
120 currency: 'NZD',
121 minimumFractionDigits: 2,
122 maximumFractionDigits: 2
123 }).format(value);
124 };
125
126 const openNewCampaignModal = () => {
127 setEditingCampaign(null);
128 setFormData({
129 name: '',
130 startDate: '',
131 earnEndDate: '',
132 redeemEndDate: '',
133 rewardLevel: '',
134 moneyRewardLevel: '',
135 gain: '',
136 inputPid: '',
137 outputPid: '',
138 membershipType: '1',
139 activeDays: ''
140 });
141 setShowModal(true);
142 };
143
144 const openEditModal = (campaign: LoyaltyCampaign) => {
145 setEditingCampaign(campaign);
146 const membershipType = ((campaign.f109 || 1) & 1) === 1 ? '1' : '0';
147 setFormData({
148 name: campaign.f101 || '',
149 startDate: formatDate(campaign.f102 || ''),
150 earnEndDate: formatDate(campaign.f103 || ''),
151 redeemEndDate: formatDate(campaign.f114 || ''),
152 rewardLevel: String(campaign.f104 || ''),
153 moneyRewardLevel: String(campaign.f105 || ''),
154 gain: String(campaign.f106 || ''),
155 inputPid: String(campaign.f107 || ''),
156 outputPid: String(campaign.f108 || ''),
157 membershipType: membershipType,
158 activeDays: String(campaign.f113 || '')
159 });
160 setShowModal(true);
161 };
162
163 const saveCampaign = async () => {
164 const apiKey = sessionStorage.getItem("fieldpine_apikey");
165 if (!apiKey) {
166 alert("Please log in first");
167 return;
168 }
169
170 // Build XML for DATI update
171 let xml = '<DATI><f8_s>retailmax.elink.loyalty.program.edit</f8_s>';
172
173 if (editingCampaign) {
174 xml += '<f11_B>E</f11_B>';
175 xml += `<f100_E>${editingCampaign.f100}</f100_E>`;
176 } else {
177 xml += '<f11_B>I</f11_B>';
178 }
179
180 // Add changed fields
181 if (!editingCampaign || formData.name !== editingCampaign.f101) {
182 xml += `<f101_s>${escapeXml(formData.name)}</f101_s>`;
183 }
184 if (!editingCampaign || formData.startDate !== formatDate(editingCampaign.f102)) {
185 xml += `<f102_s>${escapeXml(formData.startDate)}</f102_s>`;
186 }
187 if (!editingCampaign || formData.earnEndDate !== formatDate(editingCampaign.f103)) {
188 xml += `<f103_s>${escapeXml(formData.earnEndDate)}</f103_s>`;
189 }
190 if (!editingCampaign || formData.rewardLevel !== String(editingCampaign.f104)) {
191 xml += `<f104_E>${escapeXml(formData.rewardLevel)}</f104_E>`;
192 }
193 if (!editingCampaign || formData.moneyRewardLevel !== String(editingCampaign.f105)) {
194 xml += `<f105_s>${escapeXml(formData.moneyRewardLevel)}</f105_s>`;
195 }
196 if (!editingCampaign || formData.gain !== String(editingCampaign.f106)) {
197 xml += `<f106_s>${escapeXml(formData.gain)}</f106_s>`;
198 }
199 if (!editingCampaign || formData.inputPid !== String(editingCampaign.f107)) {
200 xml += `<f107_s>${escapeXml(formData.inputPid)}</f107_s>`;
201 }
202 if (!editingCampaign || formData.outputPid !== String(editingCampaign.f108)) {
203 xml += `<f108_s>${escapeXml(formData.outputPid)}</f108_s>`;
204 }
205 if (!editingCampaign || formData.membershipType !== String((editingCampaign.f109 || 1) & 1)) {
206 xml += `<f109_E>${formData.membershipType}</f109_E>`;
207 }
208 if (!editingCampaign || formData.activeDays !== String(editingCampaign.f113)) {
209 xml += `<f113_E>${escapeXml(formData.activeDays)}</f113_E>`;
210 }
211 if (!editingCampaign || formData.redeemEndDate !== formatDate(editingCampaign.f114)) {
212 xml += `<f114_s>${escapeXml(formData.redeemEndDate)}</f114_s>`;
213 }
214
215 xml += '</DATI>';
216
217 try {
218 const response = await fetch('/dati', {
219 method: 'POST',
220 headers: {
221 'Content-Type': 'application/xml',
222 'Authorization': `Bearer ${apiKey}`
223 },
224 body: xml
225 });
226
227 if (response.ok) {
228 setShowModal(false);
229 loadCampaigns();
230 } else {
231 alert('Error saving campaign. Please try again.');
232 }
233 } catch (error) {
234 console.error('Error saving campaign:', error);
235 alert('Error saving campaign. Please try again.');
236 }
237 };
238
239 const escapeXml = (str: string) => {
240 return String(str)
241 .replace(/&/g, '&amp;')
242 .replace(/</g, '&lt;')
243 .replace(/>/g, '&gt;')
244 .replace(/"/g, '&quot;')
245 .replace(/'/g, '&apos;');
246 };
247
248 const handleSort = (key: keyof LoyaltyCampaign) => {
249 let direction: 'asc' | 'desc' = 'asc';
250 if (sortConfig && sortConfig.key === key && sortConfig.direction === 'asc') {
251 direction = 'desc';
252 }
253 setSortConfig({ key, direction });
254 };
255
256 const getSortedCampaigns = () => {
257 const dataToSort = searchTerm ? filteredCampaigns : campaigns;
258 if (!sortConfig) return dataToSort;
259
260 return [...dataToSort].sort((a, b) => {
261 const aVal = a[sortConfig.key];
262 const bVal = b[sortConfig.key];
263
264 if (aVal === undefined || aVal === null) return 1;
265 if (bVal === undefined || bVal === null) return -1;
266
267 if (typeof aVal === 'number' && typeof bVal === 'number') {
268 return sortConfig.direction === 'asc' ? aVal - bVal : bVal - aVal;
269 }
270
271 const aStr = String(aVal).toLowerCase();
272 const bStr = String(bVal).toLowerCase();
273
274 if (aStr < bStr) return sortConfig.direction === 'asc' ? -1 : 1;
275 if (aStr > bStr) return sortConfig.direction === 'asc' ? 1 : -1;
276 return 0;
277 });
278 };
279
280 const sortedCampaigns = getSortedCampaigns();
281
282 return (
283 <div className="p-6">
284 <h1 className="text-3xl font-bold mb-6 flex items-center gap-2">
285 <Icon name="card_giftcard" size={32} className="text-brand" />
286 Loyalty Campaign Management
287 </h1>
288
289 {/* Action Buttons */}
290 <div className="mb-6 flex flex-wrap gap-3">
291 <button
292 onClick={openNewCampaignModal}
293 className="bg-brand hover:bg-brand2 text-surface px-4 py-2 rounded flex items-center gap-2"
294 >
295 <Icon name="card_giftcard" size={18} />
296 New Campaign
297 </button>
298 <button
299 onClick={() => {/* Excel export logic */}}
300 className="bg-surface-2 hover:bg-surface text-text px-4 py-2 rounded border border-border flex items-center gap-2"
301 >
302 <Icon name="file_download" size={18} />
303 Export to Excel
304 </button>
305 </div>
306
307 {/* Search */}
308 <div className="mb-6 flex gap-2 items-center">
309 <input
310 type="text"
311 placeholder="Search campaigns..."
312 value={searchTerm}
313 onChange={(e) => setSearchTerm(e.target.value)}
314 className="border border-border rounded px-3 py-2 flex-1 max-w-md"
315 />
316 <button
317 onClick={handleSearch}
318 className="bg-info text-surface px-4 py-2 rounded hover:bg-info/80 flex items-center gap-2"
319 >
320 <Icon name="search" size={18} /> Search
321 </button>
322 <span className="text-sm text-muted">
323 Displaying {sortedCampaigns.length} campaign{sortedCampaigns.length !== 1 ? 's' : ''}
324 </span>
325 </div>
326
327 {/* Campaigns Table */}
328 {loading ? (
329 <div className="text-center py-8">
330 <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand mx-auto"></div>
331 <p className="mt-2 text-muted">Loading campaigns...</p>
332 </div>
333 ) : sortedCampaigns.length > 0 ? (
334 <div className="bg-surface rounded-lg shadow overflow-hidden">
335 <div className="overflow-x-auto">
336 <table className="min-w-full divide-y divide-border">
337 <thead className="bg-surface-2">
338 <tr>
339 <th
340 onClick={() => handleSort('f100')}
341 className="px-6 py-3 text-left text-xs font-medium text-muted uppercase tracking-wider cursor-pointer hover:bg-surface"
342 >
343 ID {sortConfig?.key === 'f100' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
344 </th>
345 <th
346 onClick={() => handleSort('f101')}
347 className="px-6 py-3 text-left text-xs font-medium text-muted uppercase tracking-wider cursor-pointer hover:bg-surface"
348 >
349 Name {sortConfig?.key === 'f101' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
350 </th>
351 <th
352 onClick={() => handleSort('f104')}
353 className="px-6 py-3 text-right text-xs font-medium text-muted uppercase tracking-wider cursor-pointer hover:bg-surface"
354 >
355 Reward Level {sortConfig?.key === 'f104' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
356 </th>
357 <th
358 onClick={() => handleSort('f105')}
359 className="px-6 py-3 text-right text-xs font-medium text-muted uppercase tracking-wider cursor-pointer hover:bg-surface"
360 >
361 Reward Level $ {sortConfig?.key === 'f105' && (sortConfig.direction === 'asc' ? '↑' : '↓')}
362 </th>
363 <th className="px-6 py-3 text-left text-xs font-medium text-muted uppercase tracking-wider">
364 Options
365 </th>
366 </tr>
367 </thead>
368 <tbody className="bg-surface divide-y divide-border">
369 {sortedCampaigns.map((campaign, idx) => (
370 <tr key={idx} className="hover:bg-surface-2">
371 <td className="px-6 py-4 whitespace-nowrap text-sm text-muted">
372 {campaign.f100}
373 </td>
374 <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-text">
375 {campaign.f101}
376 </td>
377 <td className="px-6 py-4 whitespace-nowrap text-sm text-text text-right">
378 {campaign.f104}
379 </td>
380 <td className="px-6 py-4 whitespace-nowrap text-sm text-text text-right font-mono">
381 {formatCurrency(campaign.f105 || 0)}
382 </td>
383 <td className="px-6 py-4 whitespace-nowrap text-sm">
384 <div className="flex gap-2">
385 <button
386 onClick={() => openEditModal(campaign)}
387 className="text-brand hover:text-brand2 font-medium flex items-center gap-1"
388 >
389 <Icon name="edit" size={16} /> Edit Config
390 </button>
391 <a
392 href={`/report/pos/customer/fieldpine/loyalty_single.htm?rpid=${campaign.f100}`}
393 target="_blank"
394 className="text-info hover:text-info/80 font-medium flex items-center gap-1"
395 >
396 <Icon name="visibility" size={16} /> View Full Detail
397 </a>
398 </div>
399 </td>
400 </tr>
401 ))}
402 </tbody>
403 </table>
404 </div>
405 </div>
406 ) : (
407 <div className="bg-surface rounded-lg shadow p-8 text-center text-muted">
408 No loyalty campaigns found.
409 </div>
410 )}
411
412 {/* Edit/Create Modal */}
413 {showModal && (
414 <div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50 p-4">
415 <div className="bg-surface rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
416 <div className="sticky top-0 bg-surface border-b border-border px-6 py-4 flex justify-between items-center">
417 <h2 className="text-2xl font-bold">
418 {editingCampaign ? 'Edit Loyalty Campaign' : 'Create New Loyalty Campaign'}
419 </h2>
420 <button
421 onClick={() => setShowModal(false)}
422 className="text-muted hover:text-text"
423 >
424 <Icon name="close" size={24} />
425 </button>
426 </div>
427
428 <div className="p-6">
429 <table className="w-full">
430 <tbody>
431 <tr className="bg-surface">
432 <td className="py-2 pr-4 font-medium">Name of Campaign</td>
433 <td className="py-2">
434 <input
435 type="text"
436 value={formData.name}
437 onChange={(e) => setFormData({...formData, name: e.target.value})}
438 className="border border-border rounded px-3 py-2 w-full"
439 />
440 </td>
441 <td className="py-2 pl-4 text-xs text-muted">
442 Used to name the campaign on your reports
443 </td>
444 </tr>
445 <tr className="bg-surface-2">
446 <td className="py-2 pr-4 font-medium">Start Date</td>
447 <td className="py-2">
448 <input
449 type="date"
450 value={formData.startDate}
451 onChange={(e) => setFormData({...formData, startDate: e.target.value})}
452 className="border border-border rounded px-3 py-2 w-full"
453 />
454 </td>
455 <td className="py-2 pl-4 text-xs text-muted"></td>
456 </tr>
457 <tr className="bg-surface">
458 <td className="py-2 pr-4 font-medium">Earn End Date</td>
459 <td className="py-2">
460 <input
461 type="date"
462 value={formData.earnEndDate}
463 onChange={(e) => setFormData({...formData, earnEndDate: e.target.value})}
464 className="border border-border rounded px-3 py-2 w-full"
465 />
466 </td>
467 <td className="py-2 pl-4 text-xs text-muted"></td>
468 </tr>
469 <tr className="bg-surface-2">
470 <td className="py-2 pr-4 font-medium">Redeem End Date</td>
471 <td className="py-2">
472 <input
473 type="date"
474 value={formData.redeemEndDate}
475 onChange={(e) => setFormData({...formData, redeemEndDate: e.target.value})}
476 className="border border-border rounded px-3 py-2 w-full"
477 />
478 </td>
479 <td className="py-2 pl-4 text-xs text-muted"></td>
480 </tr>
481 <tr className="bg-surface">
482 <td className="py-2 pr-4 font-medium">Reward Level</td>
483 <td className="py-2">
484 <input
485 type="number"
486 value={formData.rewardLevel}
487 onChange={(e) => setFormData({...formData, rewardLevel: e.target.value})}
488 className="border border-border rounded px-3 py-2 w-full"
489 />
490 </td>
491 <td className="py-2 pl-4 text-xs text-muted"></td>
492 </tr>
493 <tr className="bg-surface-2">
494 <td className="py-2 pr-4 font-medium">Money Reward Level</td>
495 <td className="py-2">
496 <input
497 type="number"
498 step="0.01"
499 value={formData.moneyRewardLevel}
500 onChange={(e) => setFormData({...formData, moneyRewardLevel: e.target.value})}
501 className="border border-border rounded px-3 py-2 w-full"
502 />
503 </td>
504 <td className="py-2 pl-4 text-xs text-muted"></td>
505 </tr>
506 <tr className="bg-surface">
507 <td className="py-2 pr-4 font-medium">Gain</td>
508 <td className="py-2">
509 <input
510 type="number"
511 value={formData.gain}
512 onChange={(e) => setFormData({...formData, gain: e.target.value})}
513 className="border border-border rounded px-3 py-2 w-full"
514 />
515 </td>
516 <td className="py-2 pl-4 text-xs text-muted">
517 Points multiplier. If set to 2, customer will get double rewards
518 </td>
519 </tr>
520 <tr className="bg-surface-2">
521 <td className="py-2 pr-4 font-medium">Input Pid</td>
522 <td className="py-2">
523 <input
524 type="text"
525 value={formData.inputPid}
526 onChange={(e) => setFormData({...formData, inputPid: e.target.value})}
527 className="border border-border rounded px-3 py-2 w-full"
528 />
529 </td>
530 <td className="py-2 pl-4 text-xs text-muted"></td>
531 </tr>
532 <tr className="bg-surface">
533 <td className="py-2 pr-4 font-medium">Output Pid</td>
534 <td className="py-2">
535 <input
536 type="text"
537 value={formData.outputPid}
538 onChange={(e) => setFormData({...formData, outputPid: e.target.value})}
539 className="border border-border rounded px-3 py-2 w-full"
540 />
541 </td>
542 <td className="py-2 pl-4 text-xs text-muted"></td>
543 </tr>
544 <tr className="bg-surface-2">
545 <td className="py-2 pr-4 font-medium">Membership Type</td>
546 <td className="py-2">
547 <select
548 value={formData.membershipType}
549 onChange={(e) => setFormData({...formData, membershipType: e.target.value})}
550 className="border border-border rounded px-3 py-2 w-full"
551 >
552 <option value="0">Individually add Customers</option>
553 <option value="1">All Customers Included</option>
554 </select>
555 </td>
556 <td className="py-2 pl-4 text-xs text-muted"></td>
557 </tr>
558 <tr className="bg-surface">
559 <td className="py-2 pr-4 font-medium">Active Days</td>
560 <td className="py-2">
561 <input
562 type="number"
563 value={formData.activeDays}
564 onChange={(e) => setFormData({...formData, activeDays: e.target.value})}
565 className="border border-border rounded px-3 py-2 w-full"
566 />
567 </td>
568 <td className="py-2 pl-4 text-xs text-muted">
569 A customer that does not perform a transaction in the last N days is considered InActive
570 </td>
571 </tr>
572 </tbody>
573 </table>
574
575 <div className="mt-6 flex justify-end gap-3">
576 <button
577 onClick={() => setShowModal(false)}
578 className="px-4 py-2 border border-border rounded hover:bg-surface-2"
579 >
580 Cancel
581 </button>
582 <button
583 onClick={saveCampaign}
584 className="px-4 py-2 bg-brand text-surface rounded hover:bg-brand2 flex items-center gap-2"
585 >
586 <Icon name="save" size={18} />
587 Save
588 </button>
589 </div>
590 </div>
591 </div>
592 </div>
593 )}
594 </div>
595 );
596}