create-githat-app 1.8.5 → 1.8.6
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/dist/cli.js +2 -2
- package/package.json +1 -1
- package/templates/agent/app/account/activity/page.tsx.hbs +176 -0
- package/templates/agent/app/account/security/page.tsx.hbs +46 -0
- package/templates/agent/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/base/README.md.hbs +76 -0
- package/templates/classroom/app/account/activity/page.tsx.hbs +176 -0
- package/templates/classroom/app/account/security/page.tsx.hbs +46 -0
- package/templates/classroom/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/content/app/account/activity/page.tsx.hbs +176 -0
- package/templates/content/app/account/security/page.tsx.hbs +46 -0
- package/templates/content/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/dashboard/app/account/activity/page.tsx.hbs +176 -0
- package/templates/dashboard/app/account/security/page.tsx.hbs +46 -0
- package/templates/dashboard/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/marketplace/app/account/activity/page.tsx.hbs +176 -0
- package/templates/marketplace/app/account/security/page.tsx.hbs +46 -0
- package/templates/marketplace/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/marketplace/app/admin/page.tsx.hbs +19 -19
- package/templates/marketplace/app/cart/page.tsx.hbs +23 -21
- package/templates/marketplace/app/layout.tsx.hbs +7 -7
- package/templates/marketplace/app/page.tsx.hbs +82 -31
- package/templates/marketplace/app/sell/page.tsx.hbs +17 -18
- package/templates/marketplace/src/data/products.ts.hbs +64 -0
- package/templates/marketplace/src/lib/categories.ts.hbs +17 -23
- package/templates/nextjs/app/account/activity/page.tsx.hbs +176 -0
- package/templates/nextjs/app/account/security/page.tsx.hbs +46 -0
- package/templates/nextjs/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/plain/app/account/activity/page.tsx.hbs +176 -0
- package/templates/plain/app/account/security/page.tsx.hbs +46 -0
- package/templates/plain/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/portfolio/app/account/activity/page.tsx.hbs +176 -0
- package/templates/portfolio/app/account/security/page.tsx.hbs +46 -0
- package/templates/portfolio/app/account/sessions/page.tsx.hbs +180 -0
- package/templates/saas/app/account/activity/page.tsx.hbs +176 -0
- package/templates/saas/app/account/security/page.tsx.hbs +46 -0
- package/templates/saas/app/account/sessions/page.tsx.hbs +180 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useAuditLog } from '@githat/nextjs';
|
|
5
|
+
import type { AuditEvent, AuditEventType } from '@githat/nextjs';
|
|
6
|
+
|
|
7
|
+
const EVENT_ICONS: Record<string, string> = {
|
|
8
|
+
login_success: '✓',
|
|
9
|
+
login_failed: '✗',
|
|
10
|
+
mfa_enabled: '🔒',
|
|
11
|
+
mfa_disabled: '🔓',
|
|
12
|
+
mfa_challenge_failed: '⚠',
|
|
13
|
+
password_changed: '🔑',
|
|
14
|
+
email_verified: '✉',
|
|
15
|
+
passkey_added: '🔐',
|
|
16
|
+
passkey_removed: '🗑',
|
|
17
|
+
magic_link_requested: '✉',
|
|
18
|
+
session_revoked: '⊗',
|
|
19
|
+
account_locked: '⛔',
|
|
20
|
+
user_created: '★',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const EVENT_LABELS: Record<string, string> = {
|
|
24
|
+
login_success: 'Signed in',
|
|
25
|
+
login_failed: 'Failed sign-in attempt',
|
|
26
|
+
mfa_enabled: '2FA enabled',
|
|
27
|
+
mfa_disabled: '2FA disabled',
|
|
28
|
+
mfa_challenge_failed: '2FA challenge failed',
|
|
29
|
+
password_changed: 'Password changed',
|
|
30
|
+
email_verified: 'Email verified',
|
|
31
|
+
passkey_added: 'Passkey added',
|
|
32
|
+
passkey_removed: 'Passkey removed',
|
|
33
|
+
magic_link_requested: 'Magic link sent',
|
|
34
|
+
session_revoked: 'Session revoked',
|
|
35
|
+
account_locked: 'Account locked',
|
|
36
|
+
user_created: 'Account created',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* /account/activity — Sign-in audit log for {{businessName}}.
|
|
41
|
+
*
|
|
42
|
+
* Immutable per-user record of auth events. Paginated table view
|
|
43
|
+
* with type icon, when, IP, user-agent, and country flag.
|
|
44
|
+
*/
|
|
45
|
+
export default function ActivityPage() {
|
|
46
|
+
const { list } = useAuditLog();
|
|
47
|
+
const [events, setEvents] = useState<AuditEvent[]>([]);
|
|
48
|
+
const [loading, setLoading] = useState(true);
|
|
49
|
+
const [error, setError] = useState<string | null>(null);
|
|
50
|
+
const [cursor, setCursor] = useState<string | null>(null);
|
|
51
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
52
|
+
|
|
53
|
+
async function load(append = false) {
|
|
54
|
+
if (append) setLoadingMore(true); else setLoading(true);
|
|
55
|
+
setError(null);
|
|
56
|
+
try {
|
|
57
|
+
const result = await list({ limit: 50, ...(append && cursor ? { cursor } : {}) });
|
|
58
|
+
setEvents((prev) => append ? [...prev, ...result.events] : result.events);
|
|
59
|
+
setCursor(result.nextCursor);
|
|
60
|
+
} catch (err: unknown) {
|
|
61
|
+
setError(err instanceof Error ? err.message : 'Failed to load activity');
|
|
62
|
+
} finally {
|
|
63
|
+
if (append) setLoadingMore(false); else setLoading(false);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
useEffect(() => { load(); }, []);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<main
|
|
71
|
+
style=\{{
|
|
72
|
+
maxWidth: '900px',
|
|
73
|
+
margin: '0 auto',
|
|
74
|
+
padding: 'var(--space-8, 2rem) var(--space-4, 1rem)',
|
|
75
|
+
color: 'var(--fg, #111)',
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<header style=\{{ marginBottom: '1.5rem' }}>
|
|
79
|
+
<div style=\{{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
|
80
|
+
<a href="/account/security" style=\{{ color: 'var(--fg-muted, #666)', textDecoration: 'none', fontSize: '0.875rem' }}>
|
|
81
|
+
Security
|
|
82
|
+
</a>
|
|
83
|
+
<span style=\{{ color: 'var(--fg-muted, #666)' }}>›</span>
|
|
84
|
+
<span style=\{{ fontSize: '0.875rem' }}>Activity</span>
|
|
85
|
+
</div>
|
|
86
|
+
<h1 style=\{{ fontSize: '1.875rem', fontWeight: 700, margin: 0 }}>Sign-in activity</h1>
|
|
87
|
+
<p style=\{{ color: 'var(--fg-muted, #666)', marginTop: '0.25rem' }}>
|
|
88
|
+
Immutable record of security events for your {{businessName}} account.
|
|
89
|
+
</p>
|
|
90
|
+
</header>
|
|
91
|
+
|
|
92
|
+
{error && (
|
|
93
|
+
<div style=\{{ padding: '0.75rem 1rem', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: '6px', color: '#dc2626', marginBottom: '1rem' }}>
|
|
94
|
+
{error}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{loading ? (
|
|
99
|
+
<p style=\{{ color: 'var(--fg-muted, #666)' }}>Loading activity...</p>
|
|
100
|
+
) : events.length === 0 ? (
|
|
101
|
+
<p style=\{{ color: 'var(--fg-muted, #666)' }}>No activity found.</p>
|
|
102
|
+
) : (
|
|
103
|
+
<>
|
|
104
|
+
<div style=\{{ overflowX: 'auto' }}>
|
|
105
|
+
<table style=\{{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
|
106
|
+
<thead>
|
|
107
|
+
<tr style=\{{ borderBottom: '1px solid var(--border, #e5e7eb)', textAlign: 'left' }}>
|
|
108
|
+
<th style=\{{ padding: '0.5rem 0.75rem', fontWeight: 600, color: 'var(--fg-muted, #666)', width: '2rem' }}></th>
|
|
109
|
+
<th style=\{{ padding: '0.5rem 0.75rem', fontWeight: 600 }}>Event</th>
|
|
110
|
+
<th style=\{{ padding: '0.5rem 0.75rem', fontWeight: 600 }}>When</th>
|
|
111
|
+
<th style=\{{ padding: '0.5rem 0.75rem', fontWeight: 600 }}>IP</th>
|
|
112
|
+
<th style=\{{ padding: '0.5rem 0.75rem', fontWeight: 600 }}>Country</th>
|
|
113
|
+
<th style=\{{ padding: '0.5rem 0.75rem', fontWeight: 600 }}>Device</th>
|
|
114
|
+
</tr>
|
|
115
|
+
</thead>
|
|
116
|
+
<tbody>
|
|
117
|
+
{events.map((evt, i) => {
|
|
118
|
+
const meta = evt.metadata as Record<string, string | undefined>;
|
|
119
|
+
const ua = meta?.userAgent || '';
|
|
120
|
+
const truncatedUa = ua.length > 50 ? ua.slice(0, 50) + '…' : ua;
|
|
121
|
+
return (
|
|
122
|
+
<tr
|
|
123
|
+
key={evt.sk || i}
|
|
124
|
+
style=\{{ borderBottom: '1px solid var(--border, #e5e7eb)' }}
|
|
125
|
+
>
|
|
126
|
+
<td style=\{{ padding: '0.6rem 0.75rem', textAlign: 'center', fontSize: '1rem' }}>
|
|
127
|
+
{EVENT_ICONS[evt.action] || '•'}
|
|
128
|
+
</td>
|
|
129
|
+
<td style=\{{ padding: '0.6rem 0.75rem', fontWeight: 500 }}>
|
|
130
|
+
{EVENT_LABELS[evt.action] || evt.action}
|
|
131
|
+
</td>
|
|
132
|
+
<td style=\{{ padding: '0.6rem 0.75rem', color: 'var(--fg-muted, #666)', whiteSpace: 'nowrap' }}>
|
|
133
|
+
{new Date(evt.created_at).toLocaleString()}
|
|
134
|
+
</td>
|
|
135
|
+
<td style=\{{ padding: '0.6rem 0.75rem', color: 'var(--fg-muted, #666)', fontFamily: 'monospace' }}>
|
|
136
|
+
{meta?.ipAddress || '—'}
|
|
137
|
+
</td>
|
|
138
|
+
<td style=\{{ padding: '0.6rem 0.75rem', color: 'var(--fg-muted, #666)' }}>
|
|
139
|
+
{meta?.country || '—'}
|
|
140
|
+
</td>
|
|
141
|
+
<td
|
|
142
|
+
style=\{{ padding: '0.6rem 0.75rem', color: 'var(--fg-muted, #666)', maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
|
143
|
+
title={ua}
|
|
144
|
+
>
|
|
145
|
+
{truncatedUa || '—'}
|
|
146
|
+
</td>
|
|
147
|
+
</tr>
|
|
148
|
+
);
|
|
149
|
+
})}
|
|
150
|
+
</tbody>
|
|
151
|
+
</table>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{cursor && (
|
|
155
|
+
<div style=\{{ marginTop: '1.5rem', textAlign: 'center' }}>
|
|
156
|
+
<button
|
|
157
|
+
onClick={() => load(true)}
|
|
158
|
+
disabled={loadingMore}
|
|
159
|
+
style=\{{
|
|
160
|
+
padding: '0.5rem 1.5rem',
|
|
161
|
+
background: 'transparent',
|
|
162
|
+
border: '1px solid var(--border, #e5e7eb)',
|
|
163
|
+
borderRadius: '6px',
|
|
164
|
+
cursor: loadingMore ? 'not-allowed' : 'pointer',
|
|
165
|
+
fontSize: '0.875rem',
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
{loadingMore ? 'Loading...' : 'Load more'}
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
</>
|
|
173
|
+
)}
|
|
174
|
+
</main>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -26,7 +26,53 @@ export default function SecurityPage() {
|
|
|
26
26
|
Manage two-factor authentication for your {{businessName}} account.
|
|
27
27
|
</p>
|
|
28
28
|
</header>
|
|
29
|
+
|
|
29
30
|
<MfaManager />
|
|
31
|
+
|
|
32
|
+
<nav style=\{{ marginTop: '2.5rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
33
|
+
<a
|
|
34
|
+
href="/account/sessions"
|
|
35
|
+
style=\{{
|
|
36
|
+
display: 'flex',
|
|
37
|
+
alignItems: 'center',
|
|
38
|
+
justifyContent: 'space-between',
|
|
39
|
+
padding: '0.875rem 1.25rem',
|
|
40
|
+
border: '1px solid var(--border, #e5e7eb)',
|
|
41
|
+
borderRadius: '8px',
|
|
42
|
+
textDecoration: 'none',
|
|
43
|
+
color: 'var(--fg, #111)',
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<div>
|
|
47
|
+
<div style=\{{ fontWeight: 600, fontSize: '0.9rem' }}>Active sessions</div>
|
|
48
|
+
<div style=\{{ fontSize: '0.8rem', color: 'var(--fg-muted, #666)', marginTop: '0.125rem' }}>
|
|
49
|
+
See and revoke devices signed in to your account
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<span style=\{{ color: 'var(--fg-muted, #666)' }}>›</span>
|
|
53
|
+
</a>
|
|
54
|
+
<a
|
|
55
|
+
href="/account/activity"
|
|
56
|
+
style=\{{
|
|
57
|
+
display: 'flex',
|
|
58
|
+
alignItems: 'center',
|
|
59
|
+
justifyContent: 'space-between',
|
|
60
|
+
padding: '0.875rem 1.25rem',
|
|
61
|
+
border: '1px solid var(--border, #e5e7eb)',
|
|
62
|
+
borderRadius: '8px',
|
|
63
|
+
textDecoration: 'none',
|
|
64
|
+
color: 'var(--fg, #111)',
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<div>
|
|
68
|
+
<div style=\{{ fontWeight: 600, fontSize: '0.9rem' }}>Sign-in activity</div>
|
|
69
|
+
<div style=\{{ fontSize: '0.8rem', color: 'var(--fg-muted, #666)', marginTop: '0.125rem' }}>
|
|
70
|
+
Review your account's sign-in history
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
<span style=\{{ color: 'var(--fg-muted, #666)' }}>›</span>
|
|
74
|
+
</a>
|
|
75
|
+
</nav>
|
|
30
76
|
</main>
|
|
31
77
|
);
|
|
32
78
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useSessions } from '@githat/nextjs';
|
|
5
|
+
import type { Session } from '@githat/nextjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* /account/sessions — Active session management for {{businessName}}.
|
|
9
|
+
*
|
|
10
|
+
* Lists all active sessions with device/IP/country info.
|
|
11
|
+
* Users can revoke individual sessions or sign out everywhere else.
|
|
12
|
+
*/
|
|
13
|
+
export default function SessionsPage() {
|
|
14
|
+
const { list, revoke, revokeAll } = useSessions();
|
|
15
|
+
const [sessions, setSessions] = useState<Session[]>([]);
|
|
16
|
+
const [loading, setLoading] = useState(true);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
const [revoking, setRevoking] = useState<string | null>(null);
|
|
19
|
+
const [revokingAll, setRevokingAll] = useState(false);
|
|
20
|
+
|
|
21
|
+
async function load() {
|
|
22
|
+
setLoading(true);
|
|
23
|
+
setError(null);
|
|
24
|
+
try {
|
|
25
|
+
const { sessions: data } = await list();
|
|
26
|
+
setSessions(data);
|
|
27
|
+
} catch (err: unknown) {
|
|
28
|
+
setError(err instanceof Error ? err.message : 'Failed to load sessions');
|
|
29
|
+
} finally {
|
|
30
|
+
setLoading(false);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
useEffect(() => { load(); }, []);
|
|
35
|
+
|
|
36
|
+
async function handleRevoke(sessionId: string) {
|
|
37
|
+
setRevoking(sessionId);
|
|
38
|
+
try {
|
|
39
|
+
await revoke(sessionId);
|
|
40
|
+
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
|
41
|
+
} catch (err: unknown) {
|
|
42
|
+
setError(err instanceof Error ? err.message : 'Failed to revoke session');
|
|
43
|
+
} finally {
|
|
44
|
+
setRevoking(null);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function handleRevokeAll() {
|
|
49
|
+
if (!confirm('Sign out of all other devices?')) return;
|
|
50
|
+
setRevokingAll(true);
|
|
51
|
+
try {
|
|
52
|
+
await revokeAll();
|
|
53
|
+
setSessions((prev) => prev.filter((s) => s.current));
|
|
54
|
+
} catch (err: unknown) {
|
|
55
|
+
setError(err instanceof Error ? err.message : 'Failed to revoke sessions');
|
|
56
|
+
} finally {
|
|
57
|
+
setRevokingAll(false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const otherCount = sessions.filter((s) => !s.current).length;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<main
|
|
65
|
+
style=\{{
|
|
66
|
+
maxWidth: '720px',
|
|
67
|
+
margin: '0 auto',
|
|
68
|
+
padding: 'var(--space-8, 2rem) var(--space-4, 1rem)',
|
|
69
|
+
color: 'var(--fg, #111)',
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<header style=\{{ marginBottom: '1.5rem' }}>
|
|
73
|
+
<div style=\{{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
|
74
|
+
<a href="/account/security" style=\{{ color: 'var(--fg-muted, #666)', textDecoration: 'none', fontSize: '0.875rem' }}>
|
|
75
|
+
Security
|
|
76
|
+
</a>
|
|
77
|
+
<span style=\{{ color: 'var(--fg-muted, #666)' }}>›</span>
|
|
78
|
+
<span style=\{{ fontSize: '0.875rem' }}>Active sessions</span>
|
|
79
|
+
</div>
|
|
80
|
+
<h1 style=\{{ fontSize: '1.875rem', fontWeight: 700, margin: 0 }}>Active sessions</h1>
|
|
81
|
+
<p style=\{{ color: 'var(--fg-muted, #666)', marginTop: '0.25rem' }}>
|
|
82
|
+
Devices signed in to your {{businessName}} account.
|
|
83
|
+
</p>
|
|
84
|
+
</header>
|
|
85
|
+
|
|
86
|
+
{error && (
|
|
87
|
+
<div style=\{{ padding: '0.75rem 1rem', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: '6px', color: '#dc2626', marginBottom: '1rem' }}>
|
|
88
|
+
{error}
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{otherCount > 0 && (
|
|
93
|
+
<div style=\{{ marginBottom: '1.5rem' }}>
|
|
94
|
+
<button
|
|
95
|
+
onClick={handleRevokeAll}
|
|
96
|
+
disabled={revokingAll}
|
|
97
|
+
style=\{{
|
|
98
|
+
padding: '0.5rem 1.25rem',
|
|
99
|
+
background: '#fef2f2',
|
|
100
|
+
color: '#dc2626',
|
|
101
|
+
border: '1px solid #fecaca',
|
|
102
|
+
borderRadius: '6px',
|
|
103
|
+
cursor: revokingAll ? 'not-allowed' : 'pointer',
|
|
104
|
+
fontWeight: 500,
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{revokingAll ? 'Signing out...' : `Sign out everywhere else (${otherCount})`}
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
{loading ? (
|
|
113
|
+
<p style=\{{ color: 'var(--fg-muted, #666)' }}>Loading sessions...</p>
|
|
114
|
+
) : sessions.length === 0 ? (
|
|
115
|
+
<p style=\{{ color: 'var(--fg-muted, #666)' }}>No active sessions found.</p>
|
|
116
|
+
) : (
|
|
117
|
+
<div style=\{{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
118
|
+
{sessions.map((session) => (
|
|
119
|
+
<div
|
|
120
|
+
key={session.id}
|
|
121
|
+
style=\{{
|
|
122
|
+
padding: '1rem 1.25rem',
|
|
123
|
+
border: session.current ? '1px solid #6366f1' : '1px solid var(--border, #e5e7eb)',
|
|
124
|
+
borderRadius: '8px',
|
|
125
|
+
display: 'flex',
|
|
126
|
+
justifyContent: 'space-between',
|
|
127
|
+
alignItems: 'flex-start',
|
|
128
|
+
gap: '1rem',
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
<div style=\{{ flex: 1, minWidth: 0 }}>
|
|
132
|
+
<div style=\{{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
|
133
|
+
<span style=\{{ fontWeight: 600, fontSize: '0.9rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
134
|
+
{session.userAgent || 'Unknown device'}
|
|
135
|
+
</span>
|
|
136
|
+
{session.current && (
|
|
137
|
+
<span style=\{{
|
|
138
|
+
padding: '0.1rem 0.5rem',
|
|
139
|
+
background: '#ede9fe',
|
|
140
|
+
color: '#6366f1',
|
|
141
|
+
borderRadius: '999px',
|
|
142
|
+
fontSize: '0.75rem',
|
|
143
|
+
fontWeight: 600,
|
|
144
|
+
whiteSpace: 'nowrap',
|
|
145
|
+
}}>
|
|
146
|
+
This device
|
|
147
|
+
</span>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
<div style=\{{ fontSize: '0.8rem', color: 'var(--fg-muted, #666)', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
|
151
|
+
{session.ipAddress && <span>{session.ipAddress}</span>}
|
|
152
|
+
{session.country && <span>{session.country}</span>}
|
|
153
|
+
<span>Last active {new Date(session.lastActiveAt).toLocaleDateString()}</span>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
{!session.current && (
|
|
157
|
+
<button
|
|
158
|
+
onClick={() => handleRevoke(session.id)}
|
|
159
|
+
disabled={revoking === session.id}
|
|
160
|
+
style=\{{
|
|
161
|
+
padding: '0.375rem 0.875rem',
|
|
162
|
+
background: 'transparent',
|
|
163
|
+
border: '1px solid var(--border, #e5e7eb)',
|
|
164
|
+
borderRadius: '6px',
|
|
165
|
+
cursor: revoking === session.id ? 'not-allowed' : 'pointer',
|
|
166
|
+
fontSize: '0.875rem',
|
|
167
|
+
whiteSpace: 'nowrap',
|
|
168
|
+
flexShrink: 0,
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{revoking === session.id ? 'Revoking...' : 'Revoke'}
|
|
172
|
+
</button>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</main>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -5,15 +5,15 @@ import { useAuth } from '@githat/nextjs';
|
|
|
5
5
|
/**
|
|
6
6
|
* Seller dashboard — `/admin`.
|
|
7
7
|
*
|
|
8
|
-
* Auth-gated: a
|
|
9
|
-
* orders,
|
|
10
|
-
* wire the data fetches
|
|
8
|
+
* Auth-gated: a signed-in seller lands here to manage products,
|
|
9
|
+
* orders, and bank payouts. This is a placeholder shell —
|
|
10
|
+
* wire the data fetches to your own backend.
|
|
11
11
|
*
|
|
12
|
-
* The four
|
|
13
|
-
* 1.
|
|
14
|
-
* 2.
|
|
15
|
-
* 3.
|
|
16
|
-
* 4.
|
|
12
|
+
* The four tiles below reflect what a seller typically wants to know:
|
|
13
|
+
* 1. Pending orders (how many need fulfilling today)
|
|
14
|
+
* 2. Revenue this week
|
|
15
|
+
* 3. Active products (in stock)
|
|
16
|
+
* 4. Next payout date (via Sebastn)
|
|
17
17
|
*/
|
|
18
18
|
export default function AdminPage() {
|
|
19
19
|
const { isSignedIn, user, isLoading } = useAuth();
|
|
@@ -29,10 +29,10 @@ export default function AdminPage() {
|
|
|
29
29
|
<div style=\{{ background: 'var(--bg)', color: 'var(--fg)', minHeight: 'calc(100vh - 64px)' }}>
|
|
30
30
|
<div style=\{{ maxWidth: '64rem', margin: '0 auto', padding: 'var(--space-8) var(--space-4)' }}>
|
|
31
31
|
<h1 style=\{{ fontFamily: 'var(--font-wordmark)', fontSize: '2rem', marginBottom: 'var(--space-2)' }}>
|
|
32
|
-
|
|
32
|
+
Welcome back, {user?.name || 'seller'}.
|
|
33
33
|
</h1>
|
|
34
34
|
<p style=\{{ color: 'var(--fg-muted)', marginBottom: 'var(--space-8)' }}>
|
|
35
|
-
|
|
35
|
+
Here's what's happening in your store today.
|
|
36
36
|
</p>
|
|
37
37
|
|
|
38
38
|
<div style=\{{
|
|
@@ -40,17 +40,17 @@ export default function AdminPage() {
|
|
|
40
40
|
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
|
41
41
|
gap: 'var(--space-4)',
|
|
42
42
|
}}>
|
|
43
|
-
<Stat label="
|
|
44
|
-
<Stat label="
|
|
45
|
-
<Stat label="
|
|
46
|
-
<Stat label="
|
|
43
|
+
<Stat label="Pending orders" value="—" hint="Today's open orders" />
|
|
44
|
+
<Stat label="Revenue this week" value="$—" hint="This week's sales" />
|
|
45
|
+
<Stat label="Active products" value="—" hint="In stock right now" />
|
|
46
|
+
<Stat label="Next payout" value="—" hint="Next bank payout via Sebastn" />
|
|
47
47
|
</div>
|
|
48
48
|
|
|
49
49
|
<p style=\{{ marginTop: 'var(--space-12)', fontSize: '0.875rem', color: 'var(--fg-subtle)' }}>
|
|
50
50
|
This dashboard is a starter shell — wire each tile to your
|
|
51
51
|
real data layer (Postgres, DynamoDB, Sebastn webhooks).
|
|
52
|
-
|
|
53
|
-
model.
|
|
52
|
+
Products are configured in <code>src/data/products.ts</code>.
|
|
53
|
+
See <code>src/lib/anon-session.ts</code> for the guest cart model.
|
|
54
54
|
</p>
|
|
55
55
|
</div>
|
|
56
56
|
</div>
|
|
@@ -75,7 +75,7 @@ function Stat({ label, value, hint }: { label: string; value: string; hint: stri
|
|
|
75
75
|
function Loading() {
|
|
76
76
|
return (
|
|
77
77
|
<div style=\{{ display: 'flex', minHeight: 'calc(100vh - 64px)', alignItems: 'center', justifyContent: 'center', color: 'var(--fg-muted)' }}>
|
|
78
|
-
|
|
78
|
+
Loading…
|
|
79
79
|
</div>
|
|
80
80
|
);
|
|
81
81
|
}
|
|
@@ -84,9 +84,9 @@ function SignInPrompt() {
|
|
|
84
84
|
return (
|
|
85
85
|
<div style=\{{ display: 'flex', minHeight: 'calc(100vh - 64px)', alignItems: 'center', justifyContent: 'center' }}>
|
|
86
86
|
<div style=\{{ textAlign: 'center', maxWidth: '24rem' }}>
|
|
87
|
-
<h2 style=\{{ fontFamily: 'var(--font-wordmark)', marginBottom: 'var(--space-3)' }}>
|
|
87
|
+
<h2 style=\{{ fontFamily: 'var(--font-wordmark)', marginBottom: 'var(--space-3)' }}>Sign in to continue</h2>
|
|
88
88
|
<p style=\{{ color: 'var(--fg-muted)', marginBottom: 'var(--space-4)' }}>
|
|
89
|
-
Sign in to see your
|
|
89
|
+
Sign in to see your store's dashboard.
|
|
90
90
|
</p>
|
|
91
91
|
<a href="/sign-in" style=\{{ color: 'var(--primary)' }}>Sign in →</a>
|
|
92
92
|
</div>
|
|
@@ -5,28 +5,30 @@ import Link from 'next/link';
|
|
|
5
5
|
import { SignInButton, SignUpButton, useAuth } from '@githat/nextjs';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* Cart page — anonymous-aware.
|
|
9
9
|
*
|
|
10
10
|
* Three checkout choices, in the order they should appear:
|
|
11
11
|
* 1. Continue as guest (the DEFAULT) — primary button.
|
|
12
12
|
* 2. Sign in (existing GitHat user).
|
|
13
|
-
* 3. Sign up (new GitHat user, gets
|
|
13
|
+
* 3. Sign up (new GitHat user, gets order history).
|
|
14
14
|
*
|
|
15
|
-
* The order matters: the
|
|
16
|
-
*
|
|
17
|
-
* primary button "for the data," re-read CULTURE.md.
|
|
15
|
+
* The order matters: the UX promise is "you don't have to make an
|
|
16
|
+
* account to shop." Guest checkout is the primary path.
|
|
18
17
|
*
|
|
19
18
|
* Real cart line items should come from your backend (read by the
|
|
20
|
-
* anon-session id from `src/lib/anon-session.ts`). The
|
|
21
|
-
*
|
|
19
|
+
* anon-session id from `src/lib/anon-session.ts`). The sample below
|
|
20
|
+
* is just a placeholder shell — replace sampleItems with a fetch or
|
|
21
|
+
* a cart state hook connected to your data layer.
|
|
22
22
|
*/
|
|
23
23
|
export default function CartPage() {
|
|
24
24
|
const { isSignedIn } = useAuth();
|
|
25
25
|
const [emailForReceipt, setEmailForReceipt] = useState('');
|
|
26
|
+
|
|
27
|
+
// TODO: replace with real cart items from your backend or cart state
|
|
26
28
|
const sampleItems = [
|
|
27
|
-
{ id: '1', name: '
|
|
28
|
-
{ id: '2', name: '
|
|
29
|
-
{ id: '3', name: '
|
|
29
|
+
{ id: '1', name: 'Sample product 1', price: 9.99, store: 'Store A' },
|
|
30
|
+
{ id: '2', name: 'Sample product 2', price: 14.99, store: 'Store B' },
|
|
31
|
+
{ id: '3', name: 'Sample product 3', price: 4.99, store: 'Store A' },
|
|
30
32
|
];
|
|
31
33
|
const total = sampleItems.reduce((s, i) => s + i.price, 0);
|
|
32
34
|
|
|
@@ -34,10 +36,10 @@ export default function CartPage() {
|
|
|
34
36
|
<div style=\{{ background: 'var(--bg)', color: 'var(--fg)', minHeight: 'calc(100vh - 64px)' }}>
|
|
35
37
|
<div style=\{{ maxWidth: '40rem', margin: '0 auto', padding: 'var(--space-8) var(--space-4)' }}>
|
|
36
38
|
<h1 style=\{{ fontFamily: 'var(--font-wordmark)', fontSize: '2rem', marginBottom: 'var(--space-2)' }}>
|
|
37
|
-
|
|
39
|
+
Your cart
|
|
38
40
|
</h1>
|
|
39
41
|
<p style=\{{ color: 'var(--fg-muted)', marginBottom: 'var(--space-6)' }}>
|
|
40
|
-
|
|
42
|
+
Review your items, then choose how you want to pay.
|
|
41
43
|
</p>
|
|
42
44
|
|
|
43
45
|
<ul style=\{{ listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 'var(--space-3)', marginBottom: 'var(--space-6)' }}>
|
|
@@ -53,7 +55,7 @@ export default function CartPage() {
|
|
|
53
55
|
}}>
|
|
54
56
|
<div>
|
|
55
57
|
<div style=\{{ fontWeight: 600 }}>{it.name}</div>
|
|
56
|
-
<div style=\{{ fontSize: '0.75rem', color: 'var(--fg-muted)' }}>
|
|
58
|
+
<div style=\{{ fontSize: '0.75rem', color: 'var(--fg-muted)' }}>From {it.store}</div>
|
|
57
59
|
</div>
|
|
58
60
|
<div style=\{{ fontWeight: 600 }}>${it.price.toFixed(2)}</div>
|
|
59
61
|
</li>
|
|
@@ -80,9 +82,9 @@ export default function CartPage() {
|
|
|
80
82
|
border: '1px solid var(--border)',
|
|
81
83
|
background: 'var(--surface-sub)',
|
|
82
84
|
}}>
|
|
83
|
-
<h2 style=\{{ fontSize: '1.25rem', marginBottom: 'var(--space-2)' }}
|
|
85
|
+
<h2 style=\{{ fontSize: '1.25rem', marginBottom: 'var(--space-2)' }}>How would you like to pay?</h2>
|
|
84
86
|
<p style=\{{ color: 'var(--fg-muted)', marginBottom: 'var(--space-4)', fontSize: '0.875rem' }}>
|
|
85
|
-
|
|
87
|
+
You don't need an account to check out.
|
|
86
88
|
</p>
|
|
87
89
|
|
|
88
90
|
{!isSignedIn && (
|
|
@@ -93,7 +95,7 @@ export default function CartPage() {
|
|
|
93
95
|
style=\{{ marginBottom: 'var(--space-4)' }}
|
|
94
96
|
>
|
|
95
97
|
<label htmlFor="email" style=\{{ display: 'block', marginBottom: 'var(--space-2)', fontSize: '0.875rem' }}>
|
|
96
|
-
Email
|
|
98
|
+
Email for your receipt (we'll send the receipt and that's it)
|
|
97
99
|
</label>
|
|
98
100
|
<div style=\{{ display: 'flex', gap: 'var(--space-2)' }}>
|
|
99
101
|
<input
|
|
@@ -102,7 +104,7 @@ export default function CartPage() {
|
|
|
102
104
|
required
|
|
103
105
|
value={emailForReceipt}
|
|
104
106
|
onChange={(e) => setEmailForReceipt(e.target.value)}
|
|
105
|
-
placeholder="
|
|
107
|
+
placeholder="you@email.com"
|
|
106
108
|
style=\{{
|
|
107
109
|
flex: 1,
|
|
108
110
|
padding: 'var(--space-3)',
|
|
@@ -121,16 +123,16 @@ export default function CartPage() {
|
|
|
121
123
|
fontWeight: 600,
|
|
122
124
|
cursor: 'pointer',
|
|
123
125
|
}}>
|
|
124
|
-
|
|
126
|
+
Check out as guest
|
|
125
127
|
</button>
|
|
126
128
|
</div>
|
|
127
129
|
<p style=\{{ marginTop: 'var(--space-2)', fontSize: '0.75rem', color: 'var(--fg-subtle)' }}>
|
|
128
|
-
|
|
130
|
+
No password, no account, no saved data.
|
|
129
131
|
</p>
|
|
130
132
|
</form>
|
|
131
133
|
|
|
132
134
|
<div style=\{{ display: 'flex', gap: 'var(--space-3)', flexWrap: 'wrap', alignItems: 'center', marginTop: 'var(--space-4)', paddingTop: 'var(--space-4)', borderTop: '1px dashed var(--border)' }}>
|
|
133
|
-
<span style=\{{ fontSize: '0.875rem', color: 'var(--fg-muted)' }}>
|
|
135
|
+
<span style=\{{ fontSize: '0.875rem', color: 'var(--fg-muted)' }}>Or save your details for next time:</span>
|
|
134
136
|
<SignInButton />
|
|
135
137
|
<SignUpButton />
|
|
136
138
|
</div>
|
|
@@ -147,7 +149,7 @@ export default function CartPage() {
|
|
|
147
149
|
fontWeight: 600,
|
|
148
150
|
textDecoration: 'none',
|
|
149
151
|
}}>
|
|
150
|
-
|
|
152
|
+
Check out →
|
|
151
153
|
</Link>
|
|
152
154
|
)}
|
|
153
155
|
</section>
|
|
@@ -4,8 +4,8 @@ import Link from 'next/link';
|
|
|
4
4
|
import './globals.css';
|
|
5
5
|
|
|
6
6
|
export const metadata = {
|
|
7
|
-
title: '{{businessName}} —
|
|
8
|
-
description: '{{
|
|
7
|
+
title: '{{businessName}} — your marketplace',
|
|
8
|
+
description: '{{businessName}} — shop local, built with GitHat.',
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -13,15 +13,15 @@ export const metadata = {
|
|
|
13
13
|
*
|
|
14
14
|
* Header has three slots, in order of importance:
|
|
15
15
|
* 1. Wordmark (always visible)
|
|
16
|
-
* 2.
|
|
16
|
+
* 2. Cart link — anonymous-aware
|
|
17
17
|
* 3. "Save my stuff" / Sign in — low contrast, never a gate
|
|
18
18
|
*
|
|
19
|
-
* The "
|
|
19
|
+
* The "Sell on {{businessName}}" link sits in the footer, not the
|
|
20
20
|
* header. Sellers are a small minority of visitors; shoppers come first.
|
|
21
21
|
*/
|
|
22
22
|
export default function RootLayout({ children }{{#if typescript}}: { children: React.ReactNode }{{/if}}) {
|
|
23
23
|
return (
|
|
24
|
-
<html lang="
|
|
24
|
+
<html lang="en">
|
|
25
25
|
<body>
|
|
26
26
|
<GitHatProvider config=\{{
|
|
27
27
|
publishableKey: process.env.NEXT_PUBLIC_GITHAT_PUBLISHABLE_KEY || '',
|
|
@@ -54,7 +54,7 @@ export default function RootLayout({ children }{{#if typescript}}: { children: R
|
|
|
54
54
|
</Link>
|
|
55
55
|
<nav style=\{{ display: 'flex', alignItems: 'center', gap: 'var(--space-4, 1rem)', fontSize: '0.875rem' }}>
|
|
56
56
|
<Link href="/cart" style=\{{ color: 'var(--fg, inherit)', textDecoration: 'none' }}>
|
|
57
|
-
|
|
57
|
+
Cart
|
|
58
58
|
</Link>
|
|
59
59
|
<Link href="/sign-in" style=\{{ color: 'var(--fg-subtle, #71717a)', textDecoration: 'none' }}>
|
|
60
60
|
Save my stuff
|
|
@@ -70,7 +70,7 @@ export default function RootLayout({ children }{{#if typescript}}: { children: R
|
|
|
70
70
|
textAlign: 'center',
|
|
71
71
|
}}>
|
|
72
72
|
<Link href="/sell" style=\{{ color: 'var(--fg-muted, #525252)', textDecoration: 'none' }}>
|
|
73
|
-
|
|
73
|
+
Sell on {{businessName}} →
|
|
74
74
|
</Link>
|
|
75
75
|
<span style=\{{ display: 'block', marginTop: 'var(--space-2, 0.5rem)' }}>
|
|
76
76
|
{{businessName}} — built on GitHat + Sebastn.
|