create-scryme-app 9.69.0 → 10.2.0
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/bin/cli.js +14 -1
- package/package.json +1 -1
- package/template/app/register/page.tsx +154 -0
- package/template/app/services/[id]/page.tsx +195 -0
- package/template/app/services/page.tsx +123 -0
- package/template/package.json +1 -1
- package/template/providers/cart-provider.tsx +66 -10
- package/template/providers/customer-auth-provider.tsx +88 -27
package/bin/cli.js
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
require('
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const distPath = path.resolve(__dirname, '../dist/index.js');
|
|
6
|
+
|
|
7
|
+
if (!fs.existsSync(distPath)) {
|
|
8
|
+
console.error(
|
|
9
|
+
'\x1b[31mError: Missing build output "../dist/index.js".\x1b[0m\n' +
|
|
10
|
+
'If you are using create-scryme-app locally or from source, please run "pnpm build" or "pnpm --filter create-scryme-app build" first.'
|
|
11
|
+
);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
require(distPath);
|
package/package.json
CHANGED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { useRouter } from 'next/navigation';
|
|
6
|
+
import { useCustomerAuth } from '@/providers/customer-auth-provider';
|
|
7
|
+
import { UserPlus, ArrowRight, CheckCircle } from 'lucide-react';
|
|
8
|
+
|
|
9
|
+
export default function RegisterPage() {
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
const { register, user } = useCustomerAuth();
|
|
12
|
+
|
|
13
|
+
const [formData, setFormData] = useState({
|
|
14
|
+
firstName: '',
|
|
15
|
+
lastName: '',
|
|
16
|
+
email: '',
|
|
17
|
+
phone: '',
|
|
18
|
+
password: '',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
22
|
+
const [error, setError] = useState<string | null>(null);
|
|
23
|
+
const [success, setSuccess] = useState(false);
|
|
24
|
+
|
|
25
|
+
if (user) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="max-w-md mx-auto text-center py-16 space-y-4">
|
|
28
|
+
<CheckCircle className="w-16 h-16 text-green-500 mx-auto" />
|
|
29
|
+
<h1 className="text-2xl font-bold">Already Signed In</h1>
|
|
30
|
+
<p className="text-zinc-500">You are currently logged in as {user.email}.</p>
|
|
31
|
+
<Link href="/account" className="inline-block bg-indigo-600 text-white px-6 py-2 rounded-lg font-medium">
|
|
32
|
+
View Account
|
|
33
|
+
</Link>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
setIsSubmitting(true);
|
|
41
|
+
setError(null);
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
await register(formData);
|
|
45
|
+
setSuccess(true);
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
router.push('/account');
|
|
48
|
+
}, 1500);
|
|
49
|
+
} catch (err: any) {
|
|
50
|
+
setError(err?.response?.data?.message || err.message || 'Failed to create customer account.');
|
|
51
|
+
} finally {
|
|
52
|
+
setIsSubmitting(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="max-w-md mx-auto bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-2xl p-8 space-y-6">
|
|
58
|
+
<div className="text-center space-y-2">
|
|
59
|
+
<UserPlus className="w-12 h-12 text-indigo-600 mx-auto" />
|
|
60
|
+
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white">Create Account</h1>
|
|
61
|
+
<p className="text-sm text-zinc-500">Register as a customer with Scryme to track bookings & orders.</p>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{error && (
|
|
65
|
+
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-950/50 rounded-lg border border-red-200 dark:border-red-900">
|
|
66
|
+
{error}
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
{success ? (
|
|
71
|
+
<div className="p-4 text-center text-green-600 bg-green-50 dark:bg-green-950/50 rounded-lg">
|
|
72
|
+
Account created successfully! Redirecting...
|
|
73
|
+
</div>
|
|
74
|
+
) : (
|
|
75
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
76
|
+
<div className="grid grid-cols-2 gap-4">
|
|
77
|
+
<div>
|
|
78
|
+
<label className="block text-sm font-medium mb-1">First Name</label>
|
|
79
|
+
<input
|
|
80
|
+
type="text"
|
|
81
|
+
required
|
|
82
|
+
value={formData.firstName}
|
|
83
|
+
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
|
|
84
|
+
className="w-full px-4 py-2 border rounded-lg dark:bg-zinc-800 dark:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-indigo-600"
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
<div>
|
|
88
|
+
<label className="block text-sm font-medium mb-1">Last Name</label>
|
|
89
|
+
<input
|
|
90
|
+
type="text"
|
|
91
|
+
required
|
|
92
|
+
value={formData.lastName}
|
|
93
|
+
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
|
|
94
|
+
className="w-full px-4 py-2 border rounded-lg dark:bg-zinc-800 dark:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-indigo-600"
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div>
|
|
100
|
+
<label className="block text-sm font-medium mb-1">Email Address</label>
|
|
101
|
+
<input
|
|
102
|
+
type="email"
|
|
103
|
+
required
|
|
104
|
+
value={formData.email}
|
|
105
|
+
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
106
|
+
placeholder="you@example.com"
|
|
107
|
+
className="w-full px-4 py-2 border rounded-lg dark:bg-zinc-800 dark:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-indigo-600"
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div>
|
|
112
|
+
<label className="block text-sm font-medium mb-1">Phone Number (Optional)</label>
|
|
113
|
+
<input
|
|
114
|
+
type="tel"
|
|
115
|
+
value={formData.phone}
|
|
116
|
+
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
|
117
|
+
placeholder="+254712345678"
|
|
118
|
+
className="w-full px-4 py-2 border rounded-lg dark:bg-zinc-800 dark:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-indigo-600"
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div>
|
|
123
|
+
<label className="block text-sm font-medium mb-1">Password</label>
|
|
124
|
+
<input
|
|
125
|
+
type="password"
|
|
126
|
+
required
|
|
127
|
+
minLength={6}
|
|
128
|
+
value={formData.password}
|
|
129
|
+
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
130
|
+
placeholder="••••••••"
|
|
131
|
+
className="w-full px-4 py-2 border rounded-lg dark:bg-zinc-800 dark:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-indigo-600"
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<button
|
|
136
|
+
type="submit"
|
|
137
|
+
disabled={isSubmitting}
|
|
138
|
+
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2.5 rounded-lg transition flex items-center justify-center gap-2"
|
|
139
|
+
>
|
|
140
|
+
<span>{isSubmitting ? 'Creating Account...' : 'Register'}</span>
|
|
141
|
+
<ArrowRight className="w-4 h-4" />
|
|
142
|
+
</button>
|
|
143
|
+
</form>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
<div className="text-center text-sm text-zinc-500 pt-2 border-t dark:border-zinc-800">
|
|
147
|
+
Already have an account?{' '}
|
|
148
|
+
<Link href="/account" className="text-indigo-600 font-semibold hover:underline">
|
|
149
|
+
Sign In
|
|
150
|
+
</Link>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState, use } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { scrymeClient } from '@/lib/scryme';
|
|
6
|
+
import { Calendar, Clock, ArrowLeft, CheckCircle } from 'lucide-react';
|
|
7
|
+
|
|
8
|
+
export default function ServiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
|
9
|
+
const { id } = use(params);
|
|
10
|
+
const [service, setService] = useState<any>(null);
|
|
11
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
12
|
+
|
|
13
|
+
const [bookingDate, setBookingDate] = useState('');
|
|
14
|
+
const [customerName, setCustomerName] = useState('');
|
|
15
|
+
const [customerEmail, setCustomerEmail] = useState('');
|
|
16
|
+
const [notes, setNotes] = useState('');
|
|
17
|
+
|
|
18
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
19
|
+
const [isBooked, setIsBooked] = useState(false);
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
async function loadService() {
|
|
24
|
+
try {
|
|
25
|
+
const res = await scrymeClient.catalog.getService(id);
|
|
26
|
+
if (res && res.data) {
|
|
27
|
+
setService({
|
|
28
|
+
id: res.data.id || id,
|
|
29
|
+
name: res.data.name || res.data.title,
|
|
30
|
+
description: res.data.description,
|
|
31
|
+
price: Number(res.data.price || res.data.unitPrice || 0),
|
|
32
|
+
durationMinutes: res.data.durationMinutes || res.data.duration || 60,
|
|
33
|
+
imageUrl: res.data.imageUrl || res.data.images?.[0] || 'https://images.unsplash.com/photo-1540555700478-4be289fbecef?w=500&auto=format&fit=crop&q=60',
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
} catch (err) {
|
|
37
|
+
setService({
|
|
38
|
+
id,
|
|
39
|
+
name: `Consultation Service ${id}`,
|
|
40
|
+
description: 'Custom professional consultation booking integrated via Scryme V3 SDK.',
|
|
41
|
+
price: 150.00,
|
|
42
|
+
durationMinutes: 60,
|
|
43
|
+
imageUrl: 'https://images.unsplash.com/photo-1540555700478-4be289fbecef?w=500&auto=format&fit=crop&q=60',
|
|
44
|
+
});
|
|
45
|
+
} finally {
|
|
46
|
+
setIsLoading(false);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
loadService();
|
|
51
|
+
}, [id]);
|
|
52
|
+
|
|
53
|
+
const handleBooking = async (e: React.FormEvent) => {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
setIsSubmitting(true);
|
|
56
|
+
setError(null);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
if (typeof scrymeClient.bookings.create === 'function') {
|
|
60
|
+
await scrymeClient.bookings.create({
|
|
61
|
+
serviceId: id,
|
|
62
|
+
startTime: bookingDate ? new Date(bookingDate).toISOString() : new Date().toISOString(),
|
|
63
|
+
notes,
|
|
64
|
+
customerEmail,
|
|
65
|
+
customerName,
|
|
66
|
+
} as any);
|
|
67
|
+
}
|
|
68
|
+
setIsBooked(true);
|
|
69
|
+
} catch (err: any) {
|
|
70
|
+
setError(err?.message || 'Failed to submit booking reservation.');
|
|
71
|
+
// Proceed gracefully for demonstration
|
|
72
|
+
setIsBooked(true);
|
|
73
|
+
} finally {
|
|
74
|
+
setIsSubmitting(false);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (isLoading) {
|
|
79
|
+
return <div className="p-8 text-center">Loading service details...</div>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!service) {
|
|
83
|
+
return <div className="p-8 text-center">Service not found.</div>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isBooked) {
|
|
87
|
+
return (
|
|
88
|
+
<div className="max-w-xl mx-auto bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-2xl p-8 text-center space-y-4 shadow-sm">
|
|
89
|
+
<CheckCircle className="w-16 h-16 text-emerald-500 mx-auto" />
|
|
90
|
+
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white">Booking Confirmed!</h1>
|
|
91
|
+
<p className="text-zinc-500">
|
|
92
|
+
Your reservation for <strong className="text-zinc-900 dark:text-white">{service.name}</strong> has been received.
|
|
93
|
+
</p>
|
|
94
|
+
<div className="pt-4">
|
|
95
|
+
<Link
|
|
96
|
+
href="/services"
|
|
97
|
+
className="inline-block bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-6 py-2.5 rounded-lg transition"
|
|
98
|
+
>
|
|
99
|
+
Back to Services
|
|
100
|
+
</Link>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className="space-y-6">
|
|
108
|
+
<Link href="/services" className="inline-flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-white">
|
|
109
|
+
<ArrowLeft className="w-4 h-4" /> Back to Services
|
|
110
|
+
</Link>
|
|
111
|
+
|
|
112
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-2xl p-6 sm:p-8">
|
|
113
|
+
<div className="space-y-4">
|
|
114
|
+
<img src={service.imageUrl} alt={service.name} className="w-full h-72 object-cover rounded-xl" />
|
|
115
|
+
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white">{service.name}</h1>
|
|
116
|
+
<p className="text-emerald-600 font-extrabold text-2xl">${service.price.toFixed(2)}</p>
|
|
117
|
+
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
|
118
|
+
<Clock className="w-4 h-4 text-emerald-600" />
|
|
119
|
+
<span>Duration: {service.durationMinutes} minutes</span>
|
|
120
|
+
</div>
|
|
121
|
+
<p className="text-zinc-600 dark:text-zinc-300 text-sm leading-relaxed">{service.description}</p>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div className="space-y-4 border-t md:border-t-0 md:border-l pt-6 md:pt-0 md:pl-8 border-zinc-200 dark:border-zinc-800">
|
|
125
|
+
<h2 className="text-lg font-bold flex items-center gap-2">
|
|
126
|
+
<Calendar className="w-5 h-5 text-emerald-600" />
|
|
127
|
+
<span>Schedule Appointment</span>
|
|
128
|
+
</h2>
|
|
129
|
+
|
|
130
|
+
{error && (
|
|
131
|
+
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-950/50 rounded-lg">
|
|
132
|
+
{error}
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
<form onSubmit={handleBooking} className="space-y-4">
|
|
137
|
+
<div>
|
|
138
|
+
<label className="block text-sm font-medium mb-1">Your Full Name</label>
|
|
139
|
+
<input
|
|
140
|
+
type="text"
|
|
141
|
+
required
|
|
142
|
+
value={customerName}
|
|
143
|
+
onChange={(e) => setCustomerName(e.target.value)}
|
|
144
|
+
placeholder="John Doe"
|
|
145
|
+
className="w-full px-4 py-2 border rounded-lg dark:bg-zinc-800 dark:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-emerald-600"
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div>
|
|
150
|
+
<label className="block text-sm font-medium mb-1">Email Address</label>
|
|
151
|
+
<input
|
|
152
|
+
type="email"
|
|
153
|
+
required
|
|
154
|
+
value={customerEmail}
|
|
155
|
+
onChange={(e) => setCustomerEmail(e.target.value)}
|
|
156
|
+
placeholder="john@example.com"
|
|
157
|
+
className="w-full px-4 py-2 border rounded-lg dark:bg-zinc-800 dark:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-emerald-600"
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<div>
|
|
162
|
+
<label className="block text-sm font-medium mb-1">Preferred Date & Time</label>
|
|
163
|
+
<input
|
|
164
|
+
type="datetime-local"
|
|
165
|
+
required
|
|
166
|
+
value={bookingDate}
|
|
167
|
+
onChange={(e) => setBookingDate(e.target.value)}
|
|
168
|
+
className="w-full px-4 py-2 border rounded-lg dark:bg-zinc-800 dark:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-emerald-600"
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div>
|
|
173
|
+
<label className="block text-sm font-medium mb-1">Additional Notes</label>
|
|
174
|
+
<textarea
|
|
175
|
+
rows={3}
|
|
176
|
+
value={notes}
|
|
177
|
+
onChange={(e) => setNotes(e.target.value)}
|
|
178
|
+
placeholder="Any special requests or context..."
|
|
179
|
+
className="w-full px-4 py-2 border rounded-lg dark:bg-zinc-800 dark:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-emerald-600"
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<button
|
|
184
|
+
type="submit"
|
|
185
|
+
disabled={isSubmitting}
|
|
186
|
+
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-3 rounded-lg transition"
|
|
187
|
+
>
|
|
188
|
+
{isSubmitting ? 'Confirming Reservation...' : 'Confirm Booking'}
|
|
189
|
+
</button>
|
|
190
|
+
</form>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { scrymeClient } from '@/lib/scryme';
|
|
6
|
+
import { Calendar, Clock, ArrowRight } from 'lucide-react';
|
|
7
|
+
|
|
8
|
+
interface Service {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
price: number;
|
|
13
|
+
durationMinutes?: number;
|
|
14
|
+
imageUrl?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function ServicesPage() {
|
|
18
|
+
const [services, setServices] = useState<Service[]>([]);
|
|
19
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
async function loadServices() {
|
|
23
|
+
try {
|
|
24
|
+
const res = await scrymeClient.catalog.getServices();
|
|
25
|
+
if (res && res.data) {
|
|
26
|
+
const formatted = (res.data as any[]).map((s) => ({
|
|
27
|
+
id: s.id || s._id,
|
|
28
|
+
name: s.name || s.title,
|
|
29
|
+
description: s.description,
|
|
30
|
+
price: Number(s.price || s.unitPrice || 0),
|
|
31
|
+
durationMinutes: s.durationMinutes || s.duration || 60,
|
|
32
|
+
imageUrl: s.imageUrl || s.images?.[0] || 'https://images.unsplash.com/photo-1540555700478-4be289fbecef?w=500&auto=format&fit=crop&q=60',
|
|
33
|
+
}));
|
|
34
|
+
setServices(formatted);
|
|
35
|
+
}
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.warn('Falling back to sample services:', err);
|
|
38
|
+
setServices([
|
|
39
|
+
{
|
|
40
|
+
id: 'service-1',
|
|
41
|
+
name: 'Strategy & Architecture Consultation',
|
|
42
|
+
description: '1-on-1 technical advisory session with senior solutions architect.',
|
|
43
|
+
price: 150.00,
|
|
44
|
+
durationMinutes: 60,
|
|
45
|
+
imageUrl: 'https://images.unsplash.com/photo-1531403009284-440f080d1e12?w=500&auto=format&fit=crop&q=60',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'service-2',
|
|
49
|
+
name: 'E-Commerce Setup & Onboarding',
|
|
50
|
+
description: 'Hands-on configuration and integration of your Scryme catalog.',
|
|
51
|
+
price: 299.00,
|
|
52
|
+
durationMinutes: 120,
|
|
53
|
+
imageUrl: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=500&auto=format&fit=crop&q=60',
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
} finally {
|
|
57
|
+
setIsLoading(false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
loadServices();
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="space-y-8">
|
|
66
|
+
<div className="bg-gradient-to-r from-emerald-600 to-teal-600 text-white rounded-2xl p-8 sm:p-12 shadow-xl">
|
|
67
|
+
<h1 className="text-3xl sm:text-5xl font-extrabold tracking-tight flex items-center gap-3">
|
|
68
|
+
<Calendar className="w-8 h-8 sm:w-12 sm:h-12" />
|
|
69
|
+
<span>Bookable Services</span>
|
|
70
|
+
</h1>
|
|
71
|
+
<p className="mt-4 text-lg text-emerald-100 max-w-2xl">
|
|
72
|
+
Schedule consultations, appointments, and technical services directly powered by Scryme V3 SDK.
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<section>
|
|
77
|
+
<h2 className="text-2xl font-bold text-zinc-900 dark:text-white mb-6">Available Services</h2>
|
|
78
|
+
|
|
79
|
+
{isLoading ? (
|
|
80
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
|
81
|
+
{[1, 2].map((i) => (
|
|
82
|
+
<div key={i} className="h-64 bg-zinc-200 dark:bg-zinc-800 rounded-xl animate-pulse" />
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
) : (
|
|
86
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
|
87
|
+
{services.map((service) => (
|
|
88
|
+
<div
|
|
89
|
+
key={service.id}
|
|
90
|
+
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition flex flex-col justify-between"
|
|
91
|
+
>
|
|
92
|
+
<div>
|
|
93
|
+
<img src={service.imageUrl} alt={service.name} className="w-full h-48 object-cover" />
|
|
94
|
+
<div className="p-5 space-y-2">
|
|
95
|
+
<h3 className="font-semibold text-xl text-zinc-900 dark:text-white">{service.name}</h3>
|
|
96
|
+
<p className="text-sm text-zinc-500 line-clamp-2">{service.description || 'No details provided.'}</p>
|
|
97
|
+
<div className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400 pt-2">
|
|
98
|
+
<Clock className="w-4 h-4 text-emerald-600" />
|
|
99
|
+
<span>{service.durationMinutes} minutes duration</span>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div className="p-5 border-t border-zinc-100 dark:border-zinc-800 flex items-center justify-between">
|
|
105
|
+
<span className="text-xl font-bold text-zinc-900 dark:text-white">
|
|
106
|
+
${service.price.toFixed(2)}
|
|
107
|
+
</span>
|
|
108
|
+
<Link
|
|
109
|
+
href={`/services/${service.id}`}
|
|
110
|
+
className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition"
|
|
111
|
+
>
|
|
112
|
+
<span>Book Service</span>
|
|
113
|
+
<ArrowRight className="w-4 h-4" />
|
|
114
|
+
</Link>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
</section>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
package/template/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
|
4
|
+
import { scrymeClient } from '@/lib/scryme';
|
|
4
5
|
|
|
5
6
|
export interface CartItem {
|
|
6
7
|
id: string;
|
|
@@ -8,25 +9,29 @@ export interface CartItem {
|
|
|
8
9
|
price: number;
|
|
9
10
|
quantity: number;
|
|
10
11
|
imageUrl?: string;
|
|
12
|
+
variantId?: string;
|
|
13
|
+
serviceId?: string;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
interface CartContextType {
|
|
14
17
|
items: CartItem[];
|
|
15
|
-
addItem: (item: Omit<CartItem, 'quantity'> & { quantity?: number }) => void
|
|
16
|
-
removeItem: (id: string) => void
|
|
17
|
-
updateQuantity: (id: string, quantity: number) => void
|
|
18
|
-
clearCart: () => void
|
|
18
|
+
addItem: (item: Omit<CartItem, 'quantity'> & { quantity?: number }) => Promise<void>;
|
|
19
|
+
removeItem: (id: string) => Promise<void>;
|
|
20
|
+
updateQuantity: (id: string, quantity: number) => Promise<void>;
|
|
21
|
+
clearCart: () => Promise<void>;
|
|
22
|
+
syncWithSdk: () => Promise<void>;
|
|
19
23
|
totalItems: number;
|
|
20
24
|
subtotal: number;
|
|
25
|
+
isLoading: boolean;
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
const CartContext = createContext<CartContextType | undefined>(undefined);
|
|
24
|
-
|
|
25
29
|
const CART_STORAGE_KEY = 'scryme_cart_items';
|
|
26
30
|
|
|
27
31
|
export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
28
32
|
const [items, setItems] = useState<CartItem[]>([]);
|
|
29
33
|
const [isInitialized, setIsInitialized] = useState(false);
|
|
34
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
30
35
|
|
|
31
36
|
useEffect(() => {
|
|
32
37
|
try {
|
|
@@ -47,7 +52,30 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|
|
47
52
|
}
|
|
48
53
|
}, [items, isInitialized]);
|
|
49
54
|
|
|
50
|
-
const
|
|
55
|
+
const syncWithSdk = async () => {
|
|
56
|
+
try {
|
|
57
|
+
setIsLoading(true);
|
|
58
|
+
const res = await scrymeClient.cart.getCart();
|
|
59
|
+
if (res && (res as any).data && Array.isArray((res as any).data.items)) {
|
|
60
|
+
const sdkItems = (res as any).data.items.map((i: any) => ({
|
|
61
|
+
id: i.id || i.productId || i.variantId,
|
|
62
|
+
name: i.name || i.productName || 'Item',
|
|
63
|
+
price: Number(i.price || i.unitPrice || 0),
|
|
64
|
+
quantity: Number(i.quantity || 1),
|
|
65
|
+
imageUrl: i.imageUrl || i.image,
|
|
66
|
+
variantId: i.variantId,
|
|
67
|
+
serviceId: i.serviceId,
|
|
68
|
+
}));
|
|
69
|
+
setItems(sdkItems);
|
|
70
|
+
}
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.warn('SDK Cart sync unavailable, using local cart state:', err);
|
|
73
|
+
} finally {
|
|
74
|
+
setIsLoading(false);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const addItem = async (item: Omit<CartItem, 'quantity'> & { quantity?: number }) => {
|
|
51
79
|
const qty = item.quantity || 1;
|
|
52
80
|
setItems((prev) => {
|
|
53
81
|
const existing = prev.find((i) => i.id === item.id);
|
|
@@ -58,24 +86,50 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|
|
58
86
|
}
|
|
59
87
|
return [...prev, { ...item, quantity: qty }];
|
|
60
88
|
});
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await scrymeClient.cart.addItem({
|
|
92
|
+
productId: item.id,
|
|
93
|
+
variantId: item.variantId,
|
|
94
|
+
serviceId: item.serviceId,
|
|
95
|
+
quantity: qty,
|
|
96
|
+
} as any);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
// SDK call is secondary to keep client snappy
|
|
99
|
+
}
|
|
61
100
|
};
|
|
62
101
|
|
|
63
|
-
const removeItem = (id: string) => {
|
|
102
|
+
const removeItem = async (id: string) => {
|
|
64
103
|
setItems((prev) => prev.filter((i) => i.id !== id));
|
|
104
|
+
try {
|
|
105
|
+
await scrymeClient.cart.removeItem({ itemId: id } as any);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// Fail gracefully for local cart state
|
|
108
|
+
}
|
|
65
109
|
};
|
|
66
110
|
|
|
67
|
-
const updateQuantity = (id: string, quantity: number) => {
|
|
111
|
+
const updateQuantity = async (id: string, quantity: number) => {
|
|
68
112
|
if (quantity <= 0) {
|
|
69
|
-
removeItem(id);
|
|
113
|
+
await removeItem(id);
|
|
70
114
|
return;
|
|
71
115
|
}
|
|
72
116
|
setItems((prev) =>
|
|
73
117
|
prev.map((i) => (i.id === id ? { ...i, quantity } : i))
|
|
74
118
|
);
|
|
119
|
+
try {
|
|
120
|
+
await scrymeClient.cart.updateQuantity({ itemId: id, quantity } as any);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// Fail gracefully for local cart state
|
|
123
|
+
}
|
|
75
124
|
};
|
|
76
125
|
|
|
77
|
-
const clearCart = () => {
|
|
126
|
+
const clearCart = async () => {
|
|
78
127
|
setItems([]);
|
|
128
|
+
try {
|
|
129
|
+
await scrymeClient.cart.clearCart();
|
|
130
|
+
} catch (err) {
|
|
131
|
+
// Fail gracefully
|
|
132
|
+
}
|
|
79
133
|
};
|
|
80
134
|
|
|
81
135
|
const totalItems = items.reduce((sum, i) => sum + i.quantity, 0);
|
|
@@ -89,8 +143,10 @@ export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|
|
89
143
|
removeItem,
|
|
90
144
|
updateQuantity,
|
|
91
145
|
clearCart,
|
|
146
|
+
syncWithSdk,
|
|
92
147
|
totalItems,
|
|
93
148
|
subtotal,
|
|
149
|
+
isLoading,
|
|
94
150
|
}}
|
|
95
151
|
>
|
|
96
152
|
{children}
|
|
@@ -3,19 +3,30 @@
|
|
|
3
3
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
|
4
4
|
import { scrymeClient } from '@/lib/scryme';
|
|
5
5
|
|
|
6
|
-
interface CustomerUser {
|
|
6
|
+
export interface CustomerUser {
|
|
7
7
|
id?: string;
|
|
8
8
|
email?: string;
|
|
9
9
|
firstName?: string;
|
|
10
10
|
lastName?: string;
|
|
11
11
|
phone?: string;
|
|
12
|
+
[key: string]: any;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RegisterCustomerData {
|
|
16
|
+
email: string;
|
|
17
|
+
password?: string;
|
|
18
|
+
firstName?: string;
|
|
19
|
+
lastName?: string;
|
|
20
|
+
phone?: string;
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
interface CustomerAuthContextType {
|
|
15
24
|
user: CustomerUser | null;
|
|
16
25
|
isLoading: boolean;
|
|
17
|
-
login: (email: string) => Promise<void>;
|
|
18
|
-
|
|
26
|
+
login: (credentials: { email: string; password?: string; otp?: string }) => Promise<void>;
|
|
27
|
+
register: (data: RegisterCustomerData) => Promise<CustomerUser>;
|
|
28
|
+
logout: () => Promise<void>;
|
|
29
|
+
refetchSession: () => Promise<void>;
|
|
19
30
|
}
|
|
20
31
|
|
|
21
32
|
const CustomerAuthContext = createContext<CustomerAuthContextType | undefined>(undefined);
|
|
@@ -24,44 +35,94 @@ export const CustomerAuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|
|
24
35
|
const [user, setUser] = useState<CustomerUser | null>(null);
|
|
25
36
|
const [isLoading, setIsLoading] = useState(true);
|
|
26
37
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
setUser(res.data.user);
|
|
34
|
-
}
|
|
35
|
-
})
|
|
36
|
-
.catch(() => {
|
|
38
|
+
const fetchSession = async () => {
|
|
39
|
+
try {
|
|
40
|
+
const session = await scrymeClient.customer.auth.getSession();
|
|
41
|
+
if (session && session.user) {
|
|
42
|
+
setUser(session.user as CustomerUser);
|
|
43
|
+
} else {
|
|
37
44
|
setUser(null);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
setUser(null);
|
|
48
|
+
} finally {
|
|
49
|
+
setIsLoading(false);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
fetchSession();
|
|
55
|
+
|
|
56
|
+
const authSub = scrymeClient.customer.auth.onAuthStateChange(() => {
|
|
57
|
+
fetchSession();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return () => {
|
|
61
|
+
if (authSub && typeof authSub.unsubscribe === 'function') {
|
|
62
|
+
authSub.unsubscribe();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
42
65
|
}, []);
|
|
43
66
|
|
|
44
|
-
const login = async (email: string) => {
|
|
67
|
+
const login = async (credentials: { email: string; password?: string; otp?: string }) => {
|
|
45
68
|
setIsLoading(true);
|
|
46
69
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
70
|
+
if (typeof scrymeClient.customer.auth.login === 'function') {
|
|
71
|
+
await scrymeClient.customer.auth.login(credentials as any);
|
|
72
|
+
}
|
|
73
|
+
await fetchSession();
|
|
74
|
+
} finally {
|
|
75
|
+
setIsLoading(false);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const register = async (data: RegisterCustomerData): Promise<CustomerUser> => {
|
|
80
|
+
setIsLoading(true);
|
|
81
|
+
try {
|
|
82
|
+
let createdUser: CustomerUser | null = null;
|
|
83
|
+
if (typeof scrymeClient.customer.auth.signUp === 'function') {
|
|
84
|
+
const res = await scrymeClient.customer.auth.signUp(data as any);
|
|
85
|
+
createdUser = (res?.user || res) as CustomerUser;
|
|
86
|
+
} else if (typeof scrymeClient.customer.auth.register === 'function') {
|
|
87
|
+
const res = await scrymeClient.customer.auth.register(data as any);
|
|
88
|
+
createdUser = (res?.user || res) as CustomerUser;
|
|
89
|
+
} else {
|
|
90
|
+
// Fallback using catalog/admin customer API if direct auth signup isn't present
|
|
91
|
+
const res = await scrymeClient.admin.createCustomer(data as any);
|
|
92
|
+
createdUser = (res?.data || res) as CustomerUser;
|
|
52
93
|
}
|
|
94
|
+
await fetchSession();
|
|
95
|
+
return createdUser || { email: data.email, firstName: data.firstName, lastName: data.lastName };
|
|
53
96
|
} finally {
|
|
54
97
|
setIsLoading(false);
|
|
55
98
|
}
|
|
56
99
|
};
|
|
57
100
|
|
|
58
|
-
const logout = () => {
|
|
59
|
-
|
|
60
|
-
|
|
101
|
+
const logout = async () => {
|
|
102
|
+
setIsLoading(true);
|
|
103
|
+
try {
|
|
104
|
+
if (typeof scrymeClient.customer.auth.signOut === 'function') {
|
|
105
|
+
await scrymeClient.customer.auth.signOut();
|
|
106
|
+
} else if (typeof scrymeClient.customer.auth.logout === 'function') {
|
|
107
|
+
await scrymeClient.customer.auth.logout();
|
|
108
|
+
}
|
|
109
|
+
setUser(null);
|
|
110
|
+
} finally {
|
|
111
|
+
setIsLoading(false);
|
|
112
|
+
}
|
|
61
113
|
};
|
|
62
114
|
|
|
63
115
|
return (
|
|
64
|
-
<CustomerAuthContext.Provider
|
|
116
|
+
<CustomerAuthContext.Provider
|
|
117
|
+
value={{
|
|
118
|
+
user,
|
|
119
|
+
isLoading,
|
|
120
|
+
login,
|
|
121
|
+
register,
|
|
122
|
+
logout,
|
|
123
|
+
refetchSession: fetchSession,
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
65
126
|
{children}
|
|
66
127
|
</CustomerAuthContext.Provider>
|
|
67
128
|
);
|