sbcwallet 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -0
- package/.github/workflows/build.yml +66 -0
- package/.github/workflows/release.yml +57 -0
- package/APPLE_WALLET_SETUP.md +318 -0
- package/GOOGLE_WALLET_SETUP.md +473 -0
- package/LICENSE +201 -0
- package/README.md +187 -0
- package/dist/adapters/apple.d.ts +10 -0
- package/dist/adapters/apple.js +153 -0
- package/dist/adapters/google.d.ts +26 -0
- package/dist/adapters/google.js +431 -0
- package/dist/api/unified.d.ts +67 -0
- package/dist/api/unified.js +375 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +11 -0
- package/dist/profiles/healthcare/index.d.ts +91 -0
- package/dist/profiles/healthcare/index.js +151 -0
- package/dist/profiles/logistics/index.d.ts +91 -0
- package/dist/profiles/logistics/index.js +152 -0
- package/dist/profiles/loyalty/index.d.ts +91 -0
- package/dist/profiles/loyalty/index.js +81 -0
- package/dist/templates/apple/child.json +59 -0
- package/dist/templates/apple/parent.json +54 -0
- package/dist/templates/google/child_object.json +38 -0
- package/dist/templates/google/loyalty_class.json +7 -0
- package/dist/templates/google/loyalty_object.json +29 -0
- package/dist/templates/google/parent_class.json +10 -0
- package/dist/templates/google/parent_object.json +33 -0
- package/dist/types.d.ts +422 -0
- package/dist/types.js +80 -0
- package/dist/utils/progress-image.d.ts +23 -0
- package/dist/utils/progress-image.js +94 -0
- package/examples/.loyalty-fixed-state.json +10 -0
- package/examples/claim-flow.ts +163 -0
- package/examples/loyalty-admin-server.js +207 -0
- package/examples/loyalty-admin.html +260 -0
- package/examples/loyalty-fixed-card-server.js +288 -0
- package/examples/loyalty-flow.ts +78 -0
- package/examples/loyalty-google-issue.js +115 -0
- package/package.json +51 -0
- package/scripts/copy-assets.js +35 -0
- package/scripts/smoke-dist-import.js +39 -0
- package/setup-google-class.js +97 -0
- package/setup-google-class.ts +105 -0
- package/src/adapters/apple.ts +193 -0
- package/src/adapters/google.ts +521 -0
- package/src/api/unified.ts +487 -0
- package/src/index.ts +74 -0
- package/src/profiles/healthcare/index.ts +157 -0
- package/src/profiles/logistics/index.ts +158 -0
- package/src/profiles/loyalty/index.ts +87 -0
- package/src/templates/apple/child.json +59 -0
- package/src/templates/apple/parent.json +54 -0
- package/src/templates/google/child_object.json +38 -0
- package/src/templates/google/loyalty_class.json +7 -0
- package/src/templates/google/loyalty_object.json +29 -0
- package/src/templates/google/parent_class.json +10 -0
- package/src/templates/google/parent_object.json +33 -0
- package/src/types.ts +324 -0
- package/src/utils/progress-image.ts +130 -0
- package/test-google-wallet.js +78 -0
- package/test-google-wallet.ts +94 -0
- package/tests/adapters.test.ts +244 -0
- package/tests/loyalty.test.ts +39 -0
- package/tests/unified.test.ts +388 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createParentSchedule, createChildTicket, getPkpassBuffer, updatePassStatus } from '../src/index.js'
|
|
2
|
+
import { writeFile } from 'fs/promises'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Example claim flow demonstrating the complete lifecycle of a
|
|
6
|
+
* Program Entry Schedule (PES) and Transport Order (TO) in the logistics profile.
|
|
7
|
+
*/
|
|
8
|
+
async function runClaimFlow() {
|
|
9
|
+
console.log('🚀 Starting sbcwallet Pass Claim Flow Example\n')
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
// Step 1: Create Parent PES (Program Entry Schedule)
|
|
13
|
+
console.log('1️⃣ Creating Parent PES...')
|
|
14
|
+
const pes = await createParentSchedule({
|
|
15
|
+
profile: 'logistics',
|
|
16
|
+
programName: 'Morning Yard Veracruz',
|
|
17
|
+
site: 'Patio Gate 3',
|
|
18
|
+
window: {
|
|
19
|
+
from: '2025-10-18T08:00:00-06:00',
|
|
20
|
+
to: '2025-10-18T12:00:00-06:00',
|
|
21
|
+
tz: 'America/Mexico_City'
|
|
22
|
+
},
|
|
23
|
+
capacity: 50
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
console.log(`✅ Parent PES created: ${pes.id}`)
|
|
27
|
+
console.log(` Program: ${pes.programName}`)
|
|
28
|
+
console.log(` Site: ${pes.site}`)
|
|
29
|
+
console.log(` Status: ${pes.status}`)
|
|
30
|
+
console.log(` Hash: ${pes.hash}`)
|
|
31
|
+
console.log(` Signature: ${pes.signature}\n`)
|
|
32
|
+
|
|
33
|
+
// Step 2: Create Child TO (Transport Order)
|
|
34
|
+
console.log('2️⃣ Creating Child TO...')
|
|
35
|
+
const to = await createChildTicket({
|
|
36
|
+
profile: 'logistics',
|
|
37
|
+
parentId: pes.id,
|
|
38
|
+
plate: 'ABC123A',
|
|
39
|
+
carrier: 'Transportes Golfo',
|
|
40
|
+
client: 'Cliente Y'
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
console.log(`✅ Child TO created: ${to.id}`)
|
|
44
|
+
console.log(` Plate: ${to.plate}`)
|
|
45
|
+
console.log(` Carrier: ${to.carrier}`)
|
|
46
|
+
console.log(` Client: ${to.client}`)
|
|
47
|
+
console.log(` Parent ID: ${to.parentId}`)
|
|
48
|
+
console.log(` Status: ${to.status}`)
|
|
49
|
+
console.log(` Hash: ${to.hash}`)
|
|
50
|
+
console.log(` Signature: ${to.signature}\n`)
|
|
51
|
+
|
|
52
|
+
// Step 3: Simulate status transitions
|
|
53
|
+
console.log('3️⃣ Simulating status transitions...')
|
|
54
|
+
|
|
55
|
+
console.log(' → PRESENCE (vehicle arrives at gate)')
|
|
56
|
+
const toPresence = await updatePassStatus(to.id, 'PRESENCE')
|
|
57
|
+
console.log(` ✓ Status: ${toPresence.status}`)
|
|
58
|
+
|
|
59
|
+
console.log(' → SCALE (vehicle on scale)')
|
|
60
|
+
const toScale = await updatePassStatus(to.id, 'SCALE')
|
|
61
|
+
console.log(` ✓ Status: ${toScale.status}`)
|
|
62
|
+
|
|
63
|
+
console.log(' → OPS (operations in progress)')
|
|
64
|
+
const toOps = await updatePassStatus(to.id, 'OPS')
|
|
65
|
+
console.log(` ✓ Status: ${toOps.status}`)
|
|
66
|
+
|
|
67
|
+
console.log(' → EXITED (vehicle has exited)')
|
|
68
|
+
const toExited = await updatePassStatus(to.id, 'EXITED')
|
|
69
|
+
console.log(` ✓ Status: ${toExited.status}`)
|
|
70
|
+
console.log(` ✓ New hash: ${toExited.hash}\n`)
|
|
71
|
+
|
|
72
|
+
// Step 4: Generate Apple Wallet .pkpass (will fail without valid certs, but demonstrates API)
|
|
73
|
+
console.log('4️⃣ Attempting to generate Apple Wallet .pkpass...')
|
|
74
|
+
try {
|
|
75
|
+
const pkpass = await getPkpassBuffer('child', toExited)
|
|
76
|
+
await writeFile('ticket.pkpass', pkpass)
|
|
77
|
+
console.log('✅ Saved pkpass: ticket.pkpass\n')
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.log('⚠️ Apple Wallet pass generation skipped (requires valid certificates)')
|
|
80
|
+
console.log(` Error: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Summary
|
|
84
|
+
console.log('📊 Summary:')
|
|
85
|
+
console.log(` Parent PES: ${pes.id}`)
|
|
86
|
+
console.log(` Child TO: ${to.id}`)
|
|
87
|
+
console.log(` Final Status: ${toExited.status}`)
|
|
88
|
+
console.log(` Status Flow: ISSUED → PRESENCE → SCALE → OPS → EXITED`)
|
|
89
|
+
console.log('\n✨ Claim flow completed successfully!')
|
|
90
|
+
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error('❌ Error during claim flow:')
|
|
93
|
+
console.error(error)
|
|
94
|
+
process.exit(1)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Healthcare example demonstrating appointment batch and patient visits
|
|
100
|
+
*/
|
|
101
|
+
async function runHealthcareFlow() {
|
|
102
|
+
console.log('\n🏥 Starting Healthcare Flow Example\n')
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
// Create appointment batch
|
|
106
|
+
console.log('1️⃣ Creating Appointment Batch...')
|
|
107
|
+
const batch = await createParentSchedule({
|
|
108
|
+
profile: 'healthcare',
|
|
109
|
+
programName: 'Cardiology Appointments - Dr. Smith',
|
|
110
|
+
site: 'Main Hospital - Floor 3',
|
|
111
|
+
window: {
|
|
112
|
+
from: '2025-10-20T09:00:00Z',
|
|
113
|
+
to: '2025-10-20T17:00:00Z'
|
|
114
|
+
},
|
|
115
|
+
capacity: 20
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
console.log(`✅ Appointment Batch created: ${batch.id}`)
|
|
119
|
+
console.log(` Program: ${batch.programName}`)
|
|
120
|
+
console.log(` Status: ${batch.status}\n`)
|
|
121
|
+
|
|
122
|
+
// Create patient visit
|
|
123
|
+
console.log('2️⃣ Creating Patient Visit...')
|
|
124
|
+
const visit = await createChildTicket({
|
|
125
|
+
profile: 'healthcare',
|
|
126
|
+
parentId: batch.id,
|
|
127
|
+
patientName: 'John Doe',
|
|
128
|
+
doctor: 'Dr. Smith',
|
|
129
|
+
procedure: 'Cardiac Consultation'
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
console.log(`✅ Patient Visit created: ${visit.id}`)
|
|
133
|
+
console.log(` Patient: ${visit.patientName}`)
|
|
134
|
+
console.log(` Doctor: ${visit.doctor}`)
|
|
135
|
+
console.log(` Procedure: ${visit.procedure}`)
|
|
136
|
+
console.log(` Status: ${visit.status}\n`)
|
|
137
|
+
|
|
138
|
+
// Status transitions
|
|
139
|
+
console.log('3️⃣ Processing patient visit...')
|
|
140
|
+
await updatePassStatus(visit.id, 'CHECKIN')
|
|
141
|
+
console.log(' ✓ CHECKIN')
|
|
142
|
+
|
|
143
|
+
await updatePassStatus(visit.id, 'PROCEDURE')
|
|
144
|
+
console.log(' ✓ PROCEDURE')
|
|
145
|
+
|
|
146
|
+
const discharged = await updatePassStatus(visit.id, 'DISCHARGED')
|
|
147
|
+
console.log(' ✓ DISCHARGED\n')
|
|
148
|
+
|
|
149
|
+
console.log('✨ Healthcare flow completed!')
|
|
150
|
+
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.error('❌ Error during healthcare flow:')
|
|
153
|
+
console.error(error)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Run both examples
|
|
158
|
+
runClaimFlow()
|
|
159
|
+
.then(() => runHealthcareFlow())
|
|
160
|
+
.catch(error => {
|
|
161
|
+
console.error('Fatal error:', error)
|
|
162
|
+
process.exit(1)
|
|
163
|
+
})
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import 'dotenv/config'
|
|
2
|
+
|
|
3
|
+
import http from 'node:http'
|
|
4
|
+
import { readFile } from 'node:fs/promises'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createBusiness,
|
|
10
|
+
createCustomerAccount,
|
|
11
|
+
createLoyaltyProgram,
|
|
12
|
+
issueLoyaltyCard,
|
|
13
|
+
updateLoyaltyPoints,
|
|
14
|
+
getGoogleObject,
|
|
15
|
+
getPass
|
|
16
|
+
} from '../dist/index.js'
|
|
17
|
+
|
|
18
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
19
|
+
const __dirname = dirname(__filename)
|
|
20
|
+
|
|
21
|
+
const PORT = Number(process.env.LOYALTY_ADMIN_PORT || 5179)
|
|
22
|
+
|
|
23
|
+
function optionalEnv(name) {
|
|
24
|
+
const value = process.env[name]
|
|
25
|
+
return value && String(value).trim() ? String(value).trim() : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function json(res, statusCode, data) {
|
|
29
|
+
res.writeHead(statusCode, {
|
|
30
|
+
'content-type': 'application/json; charset=utf-8',
|
|
31
|
+
'cache-control': 'no-store'
|
|
32
|
+
})
|
|
33
|
+
res.end(JSON.stringify(data, null, 2))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readJson(req) {
|
|
37
|
+
const chunks = []
|
|
38
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
39
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
40
|
+
if (!raw) return {}
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(raw)
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error('Invalid JSON body')
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseLocationsFromEnv() {
|
|
49
|
+
// LOYALTY_LOCATIONS="35.6892,51.389;35.7000,51.4000"
|
|
50
|
+
const raw = process.env.LOYALTY_LOCATIONS
|
|
51
|
+
if (!raw) return undefined
|
|
52
|
+
|
|
53
|
+
const pairs = raw
|
|
54
|
+
.split(';')
|
|
55
|
+
.map(s => s.trim())
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
|
|
58
|
+
const locations = pairs
|
|
59
|
+
.map(pair => {
|
|
60
|
+
const [latStr, lngStr] = pair.split(',').map(s => s.trim())
|
|
61
|
+
const latitude = Number(latStr)
|
|
62
|
+
const longitude = Number(lngStr)
|
|
63
|
+
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null
|
|
64
|
+
return { latitude, longitude }
|
|
65
|
+
})
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
|
|
68
|
+
return locations.length > 0 ? locations : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let state = {
|
|
72
|
+
business: null,
|
|
73
|
+
program: null,
|
|
74
|
+
customer: null,
|
|
75
|
+
card: null,
|
|
76
|
+
saveUrl: null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function initState() {
|
|
80
|
+
const logoUrl = optionalEnv('LOYALTY_LOGO_URL') || 'https://placehold.co/256x256/png?text=SBC'
|
|
81
|
+
if (!optionalEnv('LOYALTY_LOGO_URL')) {
|
|
82
|
+
console.warn('⚠️ LOYALTY_LOGO_URL not set; using placeholder logo (set it to your public HTTPS logo URL for production)')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const business = createBusiness({
|
|
86
|
+
name: process.env.LOYALTY_BUSINESS_NAME || 'SBC Coffee',
|
|
87
|
+
programName: process.env.LOYALTY_PROGRAM_NAME || 'SBC Coffee Rewards',
|
|
88
|
+
pointsLabel: process.env.LOYALTY_POINTS_LABEL || 'Points'
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const locations = parseLocationsFromEnv() || [
|
|
92
|
+
{ latitude: 35.6892, longitude: 51.389 },
|
|
93
|
+
{ latitude: 35.7000, longitude: 51.4 }
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
const program = await createLoyaltyProgram({
|
|
97
|
+
businessId: business.id,
|
|
98
|
+
site: process.env.LOYALTY_SITE || 'Downtown Branch',
|
|
99
|
+
countryCode: process.env.LOYALTY_COUNTRY_CODE || 'IR',
|
|
100
|
+
homepageUrl: process.env.LOYALTY_HOMEPAGE_URL || 'https://example.com',
|
|
101
|
+
locations,
|
|
102
|
+
metadata: {
|
|
103
|
+
googleWallet: {
|
|
104
|
+
issuerName: process.env.LOYALTY_ISSUER_NAME || business.name,
|
|
105
|
+
backgroundColor: process.env.LOYALTY_BG || '#111827',
|
|
106
|
+
logoUrl,
|
|
107
|
+
heroImageUrl: process.env.LOYALTY_HERO_URL,
|
|
108
|
+
wordMarkUrl: process.env.LOYALTY_WORDMARK_URL,
|
|
109
|
+
updateRequestUrl: process.env.LOYALTY_UPDATE_REQUEST_URL
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// Ensure loyalty class exists in API (when creds exist)
|
|
115
|
+
await getGoogleObject('parent', program)
|
|
116
|
+
|
|
117
|
+
const customer = createCustomerAccount({
|
|
118
|
+
businessId: business.id,
|
|
119
|
+
fullName: process.env.LOYALTY_CUSTOMER_NAME || 'Milad Test'
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const card = await issueLoyaltyCard({
|
|
123
|
+
businessId: business.id,
|
|
124
|
+
customerId: customer.id,
|
|
125
|
+
initialPoints: Number(process.env.LOYALTY_INITIAL_POINTS || 10)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const { saveUrl } = await getGoogleObject('child', card)
|
|
129
|
+
|
|
130
|
+
state = { business, program, customer, card, saveUrl }
|
|
131
|
+
return state
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function ensureState() {
|
|
135
|
+
if (!state.card || !state.program || !state.customer || !state.business) {
|
|
136
|
+
await initState()
|
|
137
|
+
}
|
|
138
|
+
return state
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function refreshSaveUrlForCard() {
|
|
142
|
+
const card = state.card
|
|
143
|
+
if (!card) return
|
|
144
|
+
const latest = getPass(card.id) || card
|
|
145
|
+
const { saveUrl } = await getGoogleObject('child', latest)
|
|
146
|
+
state.saveUrl = saveUrl
|
|
147
|
+
state.card = latest
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const server = http.createServer(async (req, res) => {
|
|
151
|
+
try {
|
|
152
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
|
|
153
|
+
|
|
154
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
155
|
+
const html = await readFile(join(__dirname, 'loyalty-admin.html'), 'utf8')
|
|
156
|
+
res.writeHead(200, {
|
|
157
|
+
'content-type': 'text/html; charset=utf-8',
|
|
158
|
+
'cache-control': 'no-store'
|
|
159
|
+
})
|
|
160
|
+
res.end(html)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (req.method === 'GET' && url.pathname === '/state') {
|
|
165
|
+
await ensureState()
|
|
166
|
+
json(res, 200, state)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (req.method === 'POST' && url.pathname === '/init') {
|
|
171
|
+
await initState()
|
|
172
|
+
json(res, 200, state)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (req.method === 'POST' && url.pathname === '/points') {
|
|
177
|
+
await ensureState()
|
|
178
|
+
const body = await readJson(req)
|
|
179
|
+
|
|
180
|
+
const cardId = state.card.id
|
|
181
|
+
|
|
182
|
+
if (body.setPoints !== undefined) {
|
|
183
|
+
await updateLoyaltyPoints({ cardId, setPoints: Number(body.setPoints) })
|
|
184
|
+
} else if (body.delta !== undefined) {
|
|
185
|
+
await updateLoyaltyPoints({ cardId, delta: Number(body.delta) })
|
|
186
|
+
} else {
|
|
187
|
+
throw new Error('Provide setPoints or delta')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Upsert updated object in Google Wallet when creds exist
|
|
191
|
+
await refreshSaveUrlForCard()
|
|
192
|
+
|
|
193
|
+
json(res, 200, state)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
json(res, 404, { error: 'Not found' })
|
|
198
|
+
} catch (err) {
|
|
199
|
+
json(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
server.listen(PORT, () => {
|
|
204
|
+
console.log(`✅ Loyalty admin server running: http://localhost:${PORT}`)
|
|
205
|
+
console.log('Env required for real device add: GOOGLE_ISSUER_ID, GOOGLE_SA_JSON')
|
|
206
|
+
console.log('Optional: LOYALTY_LOCATIONS="lat,lng;lat,lng"')
|
|
207
|
+
})
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>sbcwallet Loyalty Admin</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: dark;
|
|
10
|
+
}
|
|
11
|
+
body {
|
|
12
|
+
margin: 0;
|
|
13
|
+
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
|
14
|
+
background: #0b1220;
|
|
15
|
+
color: #e5e7eb;
|
|
16
|
+
}
|
|
17
|
+
.wrap {
|
|
18
|
+
max-width: 920px;
|
|
19
|
+
margin: 0 auto;
|
|
20
|
+
padding: 28px 16px 48px;
|
|
21
|
+
}
|
|
22
|
+
h1 {
|
|
23
|
+
margin: 0 0 12px;
|
|
24
|
+
font-size: 22px;
|
|
25
|
+
}
|
|
26
|
+
.card {
|
|
27
|
+
background: rgba(17, 24, 39, 0.8);
|
|
28
|
+
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
29
|
+
border-radius: 14px;
|
|
30
|
+
padding: 16px;
|
|
31
|
+
box-shadow: 0 20px 60px rgba(0,0,0,0.35);
|
|
32
|
+
}
|
|
33
|
+
.row {
|
|
34
|
+
display: flex;
|
|
35
|
+
gap: 12px;
|
|
36
|
+
flex-wrap: wrap;
|
|
37
|
+
align-items: center;
|
|
38
|
+
}
|
|
39
|
+
.kv {
|
|
40
|
+
display: grid;
|
|
41
|
+
grid-template-columns: 160px 1fr;
|
|
42
|
+
gap: 6px 10px;
|
|
43
|
+
margin-top: 12px;
|
|
44
|
+
}
|
|
45
|
+
.k { color: #9ca3af; }
|
|
46
|
+
.v { color: #f9fafb; overflow-wrap: anywhere; }
|
|
47
|
+
button {
|
|
48
|
+
background: #111827;
|
|
49
|
+
border: 1px solid rgba(148, 163, 184, 0.25);
|
|
50
|
+
color: #f9fafb;
|
|
51
|
+
padding: 10px 12px;
|
|
52
|
+
border-radius: 10px;
|
|
53
|
+
cursor: pointer;
|
|
54
|
+
}
|
|
55
|
+
button:hover { border-color: rgba(148, 163, 184, 0.45); }
|
|
56
|
+
input {
|
|
57
|
+
background: #0b1220;
|
|
58
|
+
border: 1px solid rgba(148, 163, 184, 0.25);
|
|
59
|
+
color: #f9fafb;
|
|
60
|
+
padding: 10px 12px;
|
|
61
|
+
border-radius: 10px;
|
|
62
|
+
width: 120px;
|
|
63
|
+
}
|
|
64
|
+
a { color: #60a5fa; }
|
|
65
|
+
.muted { color: #9ca3af; font-size: 13px; }
|
|
66
|
+
.pill {
|
|
67
|
+
display: inline-flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
gap: 8px;
|
|
70
|
+
padding: 8px 10px;
|
|
71
|
+
border-radius: 999px;
|
|
72
|
+
border: 1px solid rgba(148, 163, 184, 0.25);
|
|
73
|
+
background: rgba(2, 6, 23, 0.35);
|
|
74
|
+
font-size: 13px;
|
|
75
|
+
}
|
|
76
|
+
.points {
|
|
77
|
+
font-size: 44px;
|
|
78
|
+
font-weight: 750;
|
|
79
|
+
letter-spacing: -0.02em;
|
|
80
|
+
}
|
|
81
|
+
.danger { color: #fca5a5; }
|
|
82
|
+
code {
|
|
83
|
+
background: rgba(2, 6, 23, 0.55);
|
|
84
|
+
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
85
|
+
padding: 2px 6px;
|
|
86
|
+
border-radius: 6px;
|
|
87
|
+
}
|
|
88
|
+
</style>
|
|
89
|
+
</head>
|
|
90
|
+
<body>
|
|
91
|
+
<div class="wrap">
|
|
92
|
+
<h1>sbcwallet — Loyalty Admin</h1>
|
|
93
|
+
<p class="muted">
|
|
94
|
+
This page talks to the local server (<code>loyalty-admin-server.js</code>) to create a loyalty card and update its points.
|
|
95
|
+
</p>
|
|
96
|
+
|
|
97
|
+
<div class="card">
|
|
98
|
+
<div class="row" style="justify-content: space-between;">
|
|
99
|
+
<div class="pill"><span>Card</span> <span id="status" class="muted">loading…</span></div>
|
|
100
|
+
<button id="btnInit">(Re)Init Card</button>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div style="margin-top: 14px;" class="row">
|
|
104
|
+
<div>
|
|
105
|
+
<div class="muted">Points</div>
|
|
106
|
+
<div class="points" id="points">—</div>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="row">
|
|
109
|
+
<button data-delta="-10">-10</button>
|
|
110
|
+
<button data-delta="-1">-1</button>
|
|
111
|
+
<button data-delta="+1">+1</button>
|
|
112
|
+
<button data-delta="+10">+10</button>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="row">
|
|
115
|
+
<input id="setPoints" type="number" min="0" placeholder="Set" />
|
|
116
|
+
<button id="btnSet">Set Points</button>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="row">
|
|
119
|
+
<button id="btnRefresh">Refresh</button>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div class="kv">
|
|
124
|
+
<div class="k">Member ID (QR)</div>
|
|
125
|
+
<div class="v" id="memberId">—</div>
|
|
126
|
+
|
|
127
|
+
<div class="k">Customer</div>
|
|
128
|
+
<div class="v" id="customerName">—</div>
|
|
129
|
+
|
|
130
|
+
<div class="k">Card ID</div>
|
|
131
|
+
<div class="v" id="cardId">—</div>
|
|
132
|
+
|
|
133
|
+
<div class="k">Save URL</div>
|
|
134
|
+
<div class="v"><a id="saveUrl" href="#" target="_blank" rel="noreferrer">—</a></div>
|
|
135
|
+
|
|
136
|
+
<div class="k">Locations</div>
|
|
137
|
+
<div class="v" id="locations">—</div>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<p class="muted" style="margin-top: 14px;">
|
|
141
|
+
Tip: after you add the card once, click +/- to update points. The server upserts the Google Wallet object when creds exist.
|
|
142
|
+
</p>
|
|
143
|
+
<p id="error" class="muted danger" style="display:none;"></p>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<script>
|
|
148
|
+
const el = id => document.getElementById(id)
|
|
149
|
+
const setText = (id, v) => (el(id).textContent = v ?? '—')
|
|
150
|
+
const setError = msg => {
|
|
151
|
+
const e = el('error')
|
|
152
|
+
if (!msg) {
|
|
153
|
+
e.style.display = 'none'
|
|
154
|
+
e.textContent = ''
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
e.style.display = 'block'
|
|
158
|
+
e.textContent = msg
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function api(path, opts) {
|
|
162
|
+
const res = await fetch(path, {
|
|
163
|
+
headers: { 'content-type': 'application/json' },
|
|
164
|
+
...opts
|
|
165
|
+
})
|
|
166
|
+
const body = await res.json().catch(() => ({}))
|
|
167
|
+
if (!res.ok) {
|
|
168
|
+
throw new Error(body?.error || `Request failed: ${res.status}`)
|
|
169
|
+
}
|
|
170
|
+
return body
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function render(state) {
|
|
174
|
+
setError('')
|
|
175
|
+
setText('status', state?.card?.status || '—')
|
|
176
|
+
setText('points', state?.card?.points ?? '0')
|
|
177
|
+
setText('memberId', state?.customer?.memberId)
|
|
178
|
+
setText('customerName', state?.customer?.fullName)
|
|
179
|
+
setText('cardId', state?.card?.id)
|
|
180
|
+
const url = state?.saveUrl || ''
|
|
181
|
+
const a = el('saveUrl')
|
|
182
|
+
a.textContent = url || '—'
|
|
183
|
+
a.href = url || '#'
|
|
184
|
+
|
|
185
|
+
const loc = state?.program?.metadata?.googleWallet?.locations
|
|
186
|
+
if (Array.isArray(loc) && loc.length) {
|
|
187
|
+
el('locations').textContent = loc.map(p => `${p.latitude},${p.longitude}`).join(' | ')
|
|
188
|
+
} else {
|
|
189
|
+
el('locations').textContent = '—'
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function refresh() {
|
|
194
|
+
const s = await api('/state')
|
|
195
|
+
render(s)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function init() {
|
|
199
|
+
const s = await api('/init', { method: 'POST', body: '{}' })
|
|
200
|
+
render(s)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function deltaPoints(delta) {
|
|
204
|
+
const s = await api('/points', {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
body: JSON.stringify({ delta })
|
|
207
|
+
})
|
|
208
|
+
render(s)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function setPoints(setPoints) {
|
|
212
|
+
const s = await api('/points', {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
body: JSON.stringify({ setPoints })
|
|
215
|
+
})
|
|
216
|
+
render(s)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
document.querySelectorAll('button[data-delta]').forEach(btn => {
|
|
220
|
+
btn.addEventListener('click', async () => {
|
|
221
|
+
try {
|
|
222
|
+
const delta = Number(btn.getAttribute('data-delta'))
|
|
223
|
+
await deltaPoints(delta)
|
|
224
|
+
} catch (e) {
|
|
225
|
+
setError(e.message)
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
el('btnSet').addEventListener('click', async () => {
|
|
231
|
+
try {
|
|
232
|
+
const v = Number(el('setPoints').value)
|
|
233
|
+
if (!Number.isFinite(v) || v < 0) throw new Error('Enter a non-negative number')
|
|
234
|
+
await setPoints(v)
|
|
235
|
+
} catch (e) {
|
|
236
|
+
setError(e.message)
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
el('btnRefresh').addEventListener('click', async () => {
|
|
241
|
+
try {
|
|
242
|
+
await refresh()
|
|
243
|
+
} catch (e) {
|
|
244
|
+
setError(e.message)
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
el('btnInit').addEventListener('click', async () => {
|
|
249
|
+
try {
|
|
250
|
+
await init()
|
|
251
|
+
} catch (e) {
|
|
252
|
+
setError(e.message)
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// initial load
|
|
257
|
+
refresh().catch(() => init().catch(e => setError(e.message)))
|
|
258
|
+
</script>
|
|
259
|
+
</body>
|
|
260
|
+
</html>
|