create-githat-app 1.8.5 → 1.8.7

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.
Files changed (46) hide show
  1. package/dist/cli.js +2 -2
  2. package/package.json +1 -1
  3. package/templates/agent/app/account/activity/page.tsx.hbs +176 -0
  4. package/templates/agent/app/account/files/page.tsx.hbs +241 -0
  5. package/templates/agent/app/account/security/page.tsx.hbs +46 -0
  6. package/templates/agent/app/account/sessions/page.tsx.hbs +180 -0
  7. package/templates/base/README.md.hbs +111 -0
  8. package/templates/classroom/app/account/activity/page.tsx.hbs +176 -0
  9. package/templates/classroom/app/account/files/page.tsx.hbs +241 -0
  10. package/templates/classroom/app/account/security/page.tsx.hbs +46 -0
  11. package/templates/classroom/app/account/sessions/page.tsx.hbs +180 -0
  12. package/templates/content/app/account/activity/page.tsx.hbs +176 -0
  13. package/templates/content/app/account/files/page.tsx.hbs +241 -0
  14. package/templates/content/app/account/security/page.tsx.hbs +46 -0
  15. package/templates/content/app/account/sessions/page.tsx.hbs +180 -0
  16. package/templates/dashboard/app/account/activity/page.tsx.hbs +176 -0
  17. package/templates/dashboard/app/account/files/page.tsx.hbs +241 -0
  18. package/templates/dashboard/app/account/security/page.tsx.hbs +46 -0
  19. package/templates/dashboard/app/account/sessions/page.tsx.hbs +180 -0
  20. package/templates/marketplace/app/account/activity/page.tsx.hbs +176 -0
  21. package/templates/marketplace/app/account/files/page.tsx.hbs +241 -0
  22. package/templates/marketplace/app/account/security/page.tsx.hbs +46 -0
  23. package/templates/marketplace/app/account/sessions/page.tsx.hbs +180 -0
  24. package/templates/marketplace/app/admin/page.tsx.hbs +19 -19
  25. package/templates/marketplace/app/cart/page.tsx.hbs +23 -21
  26. package/templates/marketplace/app/layout.tsx.hbs +7 -7
  27. package/templates/marketplace/app/page.tsx.hbs +82 -31
  28. package/templates/marketplace/app/sell/page.tsx.hbs +17 -18
  29. package/templates/marketplace/src/data/products.ts.hbs +64 -0
  30. package/templates/marketplace/src/lib/categories.ts.hbs +17 -23
  31. package/templates/nextjs/app/account/activity/page.tsx.hbs +176 -0
  32. package/templates/nextjs/app/account/files/page.tsx.hbs +241 -0
  33. package/templates/nextjs/app/account/security/page.tsx.hbs +46 -0
  34. package/templates/nextjs/app/account/sessions/page.tsx.hbs +180 -0
  35. package/templates/plain/app/account/activity/page.tsx.hbs +176 -0
  36. package/templates/plain/app/account/files/page.tsx.hbs +241 -0
  37. package/templates/plain/app/account/security/page.tsx.hbs +46 -0
  38. package/templates/plain/app/account/sessions/page.tsx.hbs +180 -0
  39. package/templates/portfolio/app/account/activity/page.tsx.hbs +176 -0
  40. package/templates/portfolio/app/account/files/page.tsx.hbs +241 -0
  41. package/templates/portfolio/app/account/security/page.tsx.hbs +46 -0
  42. package/templates/portfolio/app/account/sessions/page.tsx.hbs +180 -0
  43. package/templates/saas/app/account/activity/page.tsx.hbs +176 -0
  44. package/templates/saas/app/account/files/page.tsx.hbs +241 -0
  45. package/templates/saas/app/account/security/page.tsx.hbs +46 -0
  46. package/templates/saas/app/account/sessions/page.tsx.hbs +180 -0
@@ -3,10 +3,9 @@ import Link from 'next/link';
3
3
  /**
4
4
  * Seller pitch page — `/sell`.
5
5
  *
6
- * Bodega owners land here from the homepage CTA "Vende en
7
- * {{businessName}}." The job of this page is to convince a small
8
- * shop owner often someone who's never used a SaaS — that the
9
- * platform is worth five minutes of their time.
6
+ * Sellers land here from the homepage CTA "Sell on {{businessName}}."
7
+ * The job of this page is to convince a shop owner that the platform
8
+ * is worth five minutes of their time.
10
9
  *
11
10
  * Three commitments worth making explicit:
12
11
  * 1. They keep 96% (we take 4% as the platform fee)
@@ -23,11 +22,11 @@ export default function SellPage() {
23
22
  lineHeight: 1.1,
24
23
  marginBottom: 'var(--space-3)',
25
24
  }}>
26
- Tu colmado, online en 5 minutos.
25
+ Your store, online in 5 minutes.
27
26
  </h1>
28
27
  <p style=\{{ color: 'var(--fg-muted)', fontSize: '1.125rem', marginBottom: 'var(--space-6)' }}>
29
- Take orders from your block, take payment to your bank. We handle
30
- the website, the cart, the receipts. You handle the colmado.
28
+ Take orders from your customers, take payment to your bank. We handle
29
+ the website, the cart, the receipts. You handle the products.
31
30
  </p>
32
31
  <Link href="/sign-up?role=seller" style=\{{
33
32
  display: 'inline-block',
@@ -39,35 +38,35 @@ export default function SellPage() {
39
38
  fontSize: '1.125rem',
40
39
  textDecoration: 'none',
41
40
  }}>
42
- Empieza gratis
41
+ Start for free
43
42
  </Link>
44
43
  </section>
45
44
 
46
45
  <section style=\{{ padding: 'var(--space-8) var(--space-4)', background: 'var(--surface-sub)' }}>
47
46
  <div style=\{{ maxWidth: '48rem', margin: '0 auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 'var(--space-6)' }}>
48
- <Promise emoji="💵" title="Te quedas con el 96%" body="We take a flat 4% on each order. No monthly fee on the free tier. No surprise charges." />
49
- <Promise emoji="🏦" title="Pagos directo a tu cuenta" body="Sebastn pays you to your bank the next business day. You keep your existing bank account." />
50
- <Promise emoji="🚪" title="Sin contratos" body="Cancel any time. Export your customers, your products, your orders. They're yours." />
47
+ <SellerProp emoji="💵" title="You keep 96%" body="We take a flat 4% on each order. No monthly fee on the free tier. No surprise charges." />
48
+ <SellerProp emoji="🏦" title="Direct bank payouts" body="Sebastn pays you to your bank the next business day. You keep your existing bank account." />
49
+ <SellerProp emoji="🚪" title="No lock-in" body="Cancel any time. Export your customers, your products, your orders. They're yours." />
51
50
  </div>
52
51
  </section>
53
52
 
54
53
  <section style=\{{ padding: 'var(--space-12) var(--space-4)', textAlign: 'center', maxWidth: '40rem', margin: '0 auto' }}>
55
54
  <h2 style=\{{ fontFamily: 'var(--font-wordmark)', fontSize: '1.75rem', marginBottom: 'var(--space-3)' }}>
56
- ¿Cómo funciona?
55
+ How it works
57
56
  </h2>
58
57
  <ol style=\{{ textAlign: 'left', color: 'var(--fg-muted)', lineHeight: 1.8, paddingLeft: 'var(--space-6)' }}>
59
- <li>Te creas tu cuenta de {{businessName}} (es gratis).</li>
60
- <li>Conectas tu banco (a través de Sebastn).</li>
61
- <li>Subes tus productoso los importas de un CSV.</li>
62
- <li>Tu colmado vive en <code>{{businessName}}/tu-tienda</code>.</li>
63
- <li>Los pedidos te llegan por email. decides cómo entregar.</li>
58
+ <li>Create your {{businessName}} seller account (it's free).</li>
59
+ <li>Connect your bank via Sebastn.</li>
60
+ <li>Add your productsor import them from a CSV.</li>
61
+ <li>Your store lives at <code>{{businessName}}/your-store</code>.</li>
62
+ <li>Orders arrive by email. You decide how to fulfill them.</li>
64
63
  </ol>
65
64
  </section>
66
65
  </div>
67
66
  );
68
67
  }
69
68
 
70
- function Promise({ emoji, title, body }: { emoji: string; title: string; body: string }) {
69
+ function SellerProp({ emoji, title, body }: { emoji: string; title: string; body: string }) {
71
70
  return (
72
71
  <div>
73
72
  <div style=\{{ fontSize: '2rem', marginBottom: 'var(--space-2)' }} aria-hidden>{emoji}</div>
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Marketplace product catalog — edit this file to add your products.
3
+ *
4
+ * This is the ONLY place product data lives in the template. All pages
5
+ * (homepage, cart, admin) import from here. When you're ready to move
6
+ * products to a database (Postgres, DynamoDB, Sebastn), replace the
7
+ * PRODUCTS array with a fetch from your backend and keep the Product
8
+ * interface unchanged so your UI components don't break.
9
+ *
10
+ * Quick start:
11
+ * 1. Replace the sample entries below with your real products.
12
+ * 2. Add or remove fields on the Product interface as needed.
13
+ * 3. Run `npm run dev` — all pages update automatically.
14
+ */
15
+
16
+ export interface Product {
17
+ id: string;
18
+ name: string;
19
+ description: string;
20
+ price: number; // in your currency's base unit (e.g. USD)
21
+ category: string; // must match a Category slug in categories.ts
22
+ inStock: boolean;
23
+ imageAlt?: string; // describe the product for screen readers
24
+ }
25
+
26
+ export const PRODUCTS: Product[] = [
27
+ {
28
+ id: 'sample-1',
29
+ name: 'Sample product 1',
30
+ description: 'Replace this with your first product description.',
31
+ price: 9.99,
32
+ category: 'general',
33
+ inStock: true,
34
+ imageAlt: 'Sample product 1',
35
+ },
36
+ {
37
+ id: 'sample-2',
38
+ name: 'Sample product 2',
39
+ description: 'Replace this with your second product description.',
40
+ price: 14.99,
41
+ category: 'general',
42
+ inStock: true,
43
+ imageAlt: 'Sample product 2',
44
+ },
45
+ {
46
+ id: 'sample-3',
47
+ name: 'Sample product 3',
48
+ description: 'Replace this with your third product description.',
49
+ price: 4.99,
50
+ category: 'general',
51
+ inStock: false,
52
+ imageAlt: 'Sample product 3',
53
+ },
54
+ ];
55
+
56
+ /** Convenience helper — returns only in-stock products. */
57
+ export function getAvailableProducts(): Product[] {
58
+ return PRODUCTS.filter((p) => p.inStock);
59
+ }
60
+
61
+ /** Look up a single product by id. Returns undefined if not found. */
62
+ export function getProductById(id: string): Product | undefined {
63
+ return PRODUCTS.find((p) => p.id === id);
64
+ }
@@ -1,35 +1,29 @@
1
1
  /**
2
- * Category seed for the marketplace template.
2
+ * Category definitions for the marketplace template.
3
3
  *
4
- * Default values are shaped for a Caribbean colmado (Dominican-flavored),
5
- * which is the {{businessName}} project's first audience. Replace as
6
- * needed for your region — see CULTURE.md for what's region-specific
7
- * vs universal.
4
+ * Edit this list to match your product taxonomy. Each entry needs:
5
+ * - slug: used in URLs (/search?cat=<slug>)
6
+ * - label: shown to shoppers
7
+ * - emoji: visual icon (optional but helpful for quick scanning)
8
8
  *
9
- * Each category is bilingual on purpose: the Spanish term is what
10
- * the shopper actually says ("recargas," "víveres") and the English
11
- * gloss helps Anglophone shoppers navigate without hiding the
12
- * cultural identity.
9
+ * Add as many or as few categories as you need. Keep slugs URL-safe
10
+ * (lowercase, hyphens only no spaces or special characters).
11
+ *
12
+ * If you add a second language, add a `labelEs` / `labelFr` field and
13
+ * update the category card in app/page.tsx to render it.
13
14
  */
14
15
 
15
16
  export interface Category {
16
17
  slug: string;
17
- es: string;
18
- en: string;
18
+ label: string;
19
19
  emoji: string;
20
20
  }
21
21
 
22
22
  export const CATEGORIES: Category[] = [
23
- { slug: 'viveres', es: 'Víveres', en: 'Staples', emoji: '🍚' },
24
- { slug: 'frutas', es: 'Frutas y verduras', en: 'Produce', emoji: '🥑' },
25
- { slug: 'carnes', es: 'Carnes', en: 'Meat', emoji: '🥩' },
26
- { slug: 'lacteos', es: 'Lácteos y huevos', en: 'Dairy & eggs', emoji: '🥚' },
27
- { slug: 'bebidas', es: 'Bebidas', en: 'Drinks', emoji: '🍺' },
28
- { slug: 'hielo', es: 'Hielo', en: 'Ice', emoji: '🧊' },
29
- { slug: 'pan', es: 'Pan y galletas', en: 'Bread & crackers', emoji: '🍞' },
30
- { slug: 'recargas', es: 'Recargas', en: 'Phone top-ups', emoji: '📱' },
31
- { slug: 'limpieza', es: 'Limpieza', en: 'Cleaning', emoji: '🧼' },
32
- { slug: 'higiene', es: 'Higiene personal', en: 'Personal care', emoji: '🪥' },
33
- { slug: 'sazon', es: 'Sazón', en: 'Seasonings', emoji: '🧂' },
34
- { slug: 'fritura', es: 'Frituras', en: 'Hot snacks', emoji: '🥟' },
23
+ { slug: 'general', label: 'General', emoji: '🏪' },
24
+ { slug: 'food', label: 'Food & drinks', emoji: '🍎' },
25
+ { slug: 'household', label: 'Household', emoji: '🧹' },
26
+ { slug: 'personal', label: 'Personal care', emoji: '🪥' },
27
+ { slug: 'electronics', label: 'Electronics', emoji: '📱' },
28
+ { slug: 'clothing', label: 'Clothing', emoji: '👕' },
35
29
  ];
@@ -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
+ }
@@ -0,0 +1,241 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useRef } from 'react';
4
+ import { useStorage, StorageDropzone } from '@githat/nextjs';
5
+ import type { StorageObject } from '@githat/nextjs';
6
+
7
+ /**
8
+ * /account/files — File storage for {{businessName}}.
9
+ *
10
+ * Lists uploaded files with download links. Uses <StorageDropzone/>
11
+ * for browser-direct uploads to S3 via the GitHat storage API.
12
+ * Works with Next.js static export — no server actions required.
13
+ */
14
+ export default function FilesPage() {
15
+ const { list, getUrl, remove } = useStorage();
16
+ const [files, setFiles] = useState<StorageObject[]>([]);
17
+ const [nextCursor, setNextCursor] = useState<string | null>(null);
18
+ const [loading, setLoading] = useState(true);
19
+ const [error, setError] = useState<string | null>(null);
20
+ const [deletingId, setDeletingId] = useState<string | null>(null);
21
+
22
+ async function load(cursor?: string) {
23
+ setLoading(true);
24
+ setError(null);
25
+ try {
26
+ const result = await list({ limit: 20, cursor });
27
+ if (cursor) {
28
+ setFiles((prev) => [...prev, ...result.items]);
29
+ } else {
30
+ setFiles(result.items);
31
+ }
32
+ setNextCursor(result.nextCursor);
33
+ } catch (err: unknown) {
34
+ setError(err instanceof Error ? err.message : 'Failed to load files');
35
+ } finally {
36
+ setLoading(false);
37
+ }
38
+ }
39
+
40
+ useEffect(() => { load(); }, []);
41
+
42
+ async function handleDownload(objectId: string) {
43
+ try {
44
+ const obj = await getUrl(objectId);
45
+ if (obj.downloadUrl) {
46
+ window.open(obj.downloadUrl, '_blank', 'noopener,noreferrer');
47
+ }
48
+ } catch (err: unknown) {
49
+ setError(err instanceof Error ? err.message : 'Failed to get download URL');
50
+ }
51
+ }
52
+
53
+ async function handleDelete(objectId: string) {
54
+ if (!confirm('Delete this file? This cannot be undone.')) return;
55
+ setDeletingId(objectId);
56
+ try {
57
+ await remove(objectId);
58
+ setFiles((prev) => prev.filter((f) => f.objectId !== objectId));
59
+ } catch (err: unknown) {
60
+ setError(err instanceof Error ? err.message : 'Failed to delete file');
61
+ } finally {
62
+ setDeletingId(null);
63
+ }
64
+ }
65
+
66
+ function formatBytes(bytes: number): string {
67
+ if (bytes < 1024) return `${bytes} B`;
68
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
69
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
70
+ }
71
+
72
+ return (
73
+ <main
74
+ style=\{{
75
+ maxWidth: '720px',
76
+ margin: '0 auto',
77
+ padding: 'var(--space-8, 2rem) var(--space-4, 1rem)',
78
+ color: 'var(--fg, #111)',
79
+ }}
80
+ >
81
+ <header style=\{{ marginBottom: '1.5rem' }}>
82
+ <h1 style=\{{ fontSize: '1.875rem', fontWeight: 700, margin: 0 }}>Files</h1>
83
+ <p style=\{{ color: 'var(--fg-muted, #666)', marginTop: '0.25rem' }}>
84
+ Upload and manage files in your {{businessName}} account.
85
+ </p>
86
+ </header>
87
+
88
+ {/* Upload dropzone */}
89
+ <StorageDropzone
90
+ accept="image/*,application/pdf,text/*"
91
+ onUpload={() => load()}
92
+ onError={(err) => setError(err.message)}
93
+ uploadOptions=\{{ isPublic: false }}
94
+ style=\{{ marginBottom: '1.5rem' }}
95
+ />
96
+
97
+ {error && (
98
+ <div
99
+ style=\{{
100
+ padding: '0.75rem 1rem',
101
+ background: '#fef2f2',
102
+ border: '1px solid #fecaca',
103
+ borderRadius: '6px',
104
+ color: '#dc2626',
105
+ marginBottom: '1rem',
106
+ }}
107
+ >
108
+ {error}
109
+ </div>
110
+ )}
111
+
112
+ {loading && files.length === 0 ? (
113
+ <p style=\{{ color: 'var(--fg-muted, #666)' }}>Loading files...</p>
114
+ ) : files.length === 0 ? (
115
+ <p style=\{{ color: 'var(--fg-muted, #666)', textAlign: 'center', padding: '2rem 0' }}>
116
+ No files uploaded yet. Drop a file above to get started.
117
+ </p>
118
+ ) : (
119
+ <div style=\{{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
120
+ {files.map((file) => (
121
+ <div
122
+ key={file.objectId}
123
+ style=\{{
124
+ display: 'flex',
125
+ alignItems: 'center',
126
+ gap: '0.75rem',
127
+ padding: '0.875rem 1rem',
128
+ border: '1px solid var(--border, #e5e7eb)',
129
+ borderRadius: '8px',
130
+ }}
131
+ >
132
+ {/* File icon */}
133
+ <svg
134
+ width="20"
135
+ height="20"
136
+ viewBox="0 0 24 24"
137
+ fill="none"
138
+ stroke="#9ca3af"
139
+ strokeWidth="1.5"
140
+ strokeLinecap="round"
141
+ strokeLinejoin="round"
142
+ style=\{{ flexShrink: 0 }}
143
+ aria-hidden
144
+ >
145
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
146
+ <polyline points="14 2 14 8 20 8" />
147
+ </svg>
148
+
149
+ {/* File info */}
150
+ <div style=\{{ flex: 1, minWidth: 0 }}>
151
+ <p
152
+ style=\{{
153
+ margin: 0,
154
+ fontWeight: 500,
155
+ fontSize: '0.9rem',
156
+ overflow: 'hidden',
157
+ textOverflow: 'ellipsis',
158
+ whiteSpace: 'nowrap',
159
+ }}
160
+ >
161
+ {file.objectKey}
162
+ </p>
163
+ <p style=\{{ margin: '0.125rem 0 0', fontSize: '0.78rem', color: 'var(--fg-muted, #666)' }}>
164
+ {formatBytes(file.size)} · {file.contentType} ·{' '}
165
+ {new Date(file.uploadedAt).toLocaleDateString()}
166
+ {file.isPublic && (
167
+ <span
168
+ style=\{{
169
+ marginLeft: '0.5rem',
170
+ padding: '0.1rem 0.4rem',
171
+ background: '#dcfce7',
172
+ color: '#16a34a',
173
+ borderRadius: '4px',
174
+ fontSize: '0.7rem',
175
+ fontWeight: 600,
176
+ }}
177
+ >
178
+ Public
179
+ </span>
180
+ )}
181
+ </p>
182
+ </div>
183
+
184
+ {/* Actions */}
185
+ <div style=\{{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
186
+ <button
187
+ onClick={() => handleDownload(file.objectId)}
188
+ style=\{{
189
+ padding: '0.35rem 0.75rem',
190
+ fontSize: '0.825rem',
191
+ border: '1px solid var(--border, #e5e7eb)',
192
+ borderRadius: '6px',
193
+ background: 'transparent',
194
+ cursor: 'pointer',
195
+ }}
196
+ aria-label={`Download ${file.objectKey}`}
197
+ >
198
+ Download
199
+ </button>
200
+ <button
201
+ onClick={() => handleDelete(file.objectId)}
202
+ disabled={deletingId === file.objectId}
203
+ style=\{{
204
+ padding: '0.35rem 0.75rem',
205
+ fontSize: '0.825rem',
206
+ border: '1px solid #fecaca',
207
+ borderRadius: '6px',
208
+ background: 'transparent',
209
+ color: '#dc2626',
210
+ cursor: deletingId === file.objectId ? 'not-allowed' : 'pointer',
211
+ }}
212
+ aria-label={`Delete ${file.objectKey}`}
213
+ >
214
+ {deletingId === file.objectId ? '...' : 'Delete'}
215
+ </button>
216
+ </div>
217
+ </div>
218
+ ))}
219
+
220
+ {nextCursor && (
221
+ <button
222
+ onClick={() => load(nextCursor)}
223
+ disabled={loading}
224
+ style=\{{
225
+ padding: '0.6rem 1.25rem',
226
+ marginTop: '0.5rem',
227
+ border: '1px solid var(--border, #e5e7eb)',
228
+ borderRadius: '6px',
229
+ background: 'transparent',
230
+ cursor: loading ? 'not-allowed' : 'pointer',
231
+ fontSize: '0.875rem',
232
+ }}
233
+ >
234
+ {loading ? 'Loading...' : 'Load more'}
235
+ </button>
236
+ )}
237
+ </div>
238
+ )}
239
+ </main>
240
+ );
241
+ }
@@ -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&apos;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
  }