@shoppexio/storefront-react 0.0.0 → 0.1.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/dist/account.css +1171 -0
  5. package/dist/account.d.ts +23 -0
  6. package/dist/account.js +12685 -0
  7. package/dist/account.js.map +1 -0
  8. package/dist/chunk-D6XKDVAV.js +181 -0
  9. package/dist/chunk-D6XKDVAV.js.map +1 -0
  10. package/dist/index.d.ts +3087 -0
  11. package/dist/index.js +2801 -0
  12. package/dist/index.js.map +1 -0
  13. package/eject/account/account-availability.ts +171 -0
  14. package/eject/account/account-downloads.ts +118 -0
  15. package/eject/account/account-favorites.ts +99 -0
  16. package/eject/account/account-loyalty.ts +241 -0
  17. package/eject/account/account-page.tsx +410 -0
  18. package/eject/account/account-payment-method.ts +99 -0
  19. package/eject/account/account-referrals.ts +68 -0
  20. package/eject/account/account-reseller-invite.ts +88 -0
  21. package/eject/account/account-reseller.ts +685 -0
  22. package/eject/account/account-rewards.ts +104 -0
  23. package/eject/account/account-settings.ts +181 -0
  24. package/eject/account/account-signin-link.ts +122 -0
  25. package/eject/account/account-subscriptions.ts +130 -0
  26. package/eject/account/account-support.ts +155 -0
  27. package/eject/account/account-warranties.ts +118 -0
  28. package/eject/account/customer-account.ts +93 -0
  29. package/eject/account/dashboard.tsx +139 -0
  30. package/eject/account/downloads.tsx +343 -0
  31. package/eject/account/favorites.tsx +201 -0
  32. package/eject/account/i18n.ts +175 -0
  33. package/eject/account/icons.tsx +218 -0
  34. package/eject/account/login.tsx +253 -0
  35. package/eject/account/messages/ar.json +464 -0
  36. package/eject/account/messages/de.json +464 -0
  37. package/eject/account/messages/en.json +464 -0
  38. package/eject/account/messages/es.json +464 -0
  39. package/eject/account/messages/fi.json +464 -0
  40. package/eject/account/messages/fr.json +464 -0
  41. package/eject/account/messages/hi.json +464 -0
  42. package/eject/account/messages/it.json +464 -0
  43. package/eject/account/messages/lt.json +464 -0
  44. package/eject/account/messages/nl.json +464 -0
  45. package/eject/account/messages/pl.json +464 -0
  46. package/eject/account/messages/pt.json +464 -0
  47. package/eject/account/messages/ru.json +464 -0
  48. package/eject/account/messages/tr.json +464 -0
  49. package/eject/account/messages/uk.json +464 -0
  50. package/eject/account/messages/zh.json +464 -0
  51. package/eject/account/orders.tsx +791 -0
  52. package/eject/account/overview.tsx +147 -0
  53. package/eject/account/referrals.tsx +327 -0
  54. package/eject/account/reseller.tsx +1365 -0
  55. package/eject/account/rewards.tsx +641 -0
  56. package/eject/account/settings.tsx +699 -0
  57. package/eject/account/shared.tsx +398 -0
  58. package/eject/account/subscriptions.tsx +421 -0
  59. package/eject/account/support.tsx +453 -0
  60. package/package.json +75 -1
@@ -0,0 +1,453 @@
1
+ /**
2
+ * The buyer's conversations with the shop.
3
+ *
4
+ * The ticket LIST arrives inside the dashboard payload — `tickets.routes.ts`
5
+ * publishes per-ticket routes and no collection — while a thread is a read of
6
+ * its own. Opening a ticket and replying to one are the only writes this lane
7
+ * admits. Closing is the merchant's, so a closed ticket loses its reply box
8
+ * rather than gaining a control the producer answers 400 to.
9
+ *
10
+ * Three panels, three states each: a skeleton while a read is out, the
11
+ * producer's own sentence plus a retry when one fails, and an empty state that
12
+ * still offers the one thing a buyer with no tickets wants — a new ticket.
13
+ */
14
+
15
+ import { useId, useState } from 'react';
16
+ import {
17
+ isOwnMessage,
18
+ isSendableMessage,
19
+ isTicketClosed,
20
+ messageAuthorKey,
21
+ narrowSupportTicket,
22
+ readCreatedTicketUniqid,
23
+ ticketTitleToSend,
24
+ type SupportTicket,
25
+ } from './account-support';
26
+ import { callCustomerApi } from './customer-account';
27
+ import { t } from './i18n';
28
+ import { MessageSquareIcon } from './icons';
29
+ import { useAccountDashboard } from './dashboard';
30
+ import {
31
+ AccountBackButton,
32
+ AccountCard,
33
+ AccountEmpty,
34
+ AccountError,
35
+ AccountFieldError,
36
+ AccountRowsSkeleton,
37
+ AccountSectionTitle,
38
+ BusySpinner,
39
+ StatusChip,
40
+ formatAccountDateTime,
41
+ useAccountResource,
42
+ } from './shared';
43
+
44
+ /** The contact form's control skin — see `components/contact/contact-form.tsx`. */
45
+ const FIELD =
46
+ 'sx-account-field sxa:w-full sxa:rounded-card sxa:border sxa:bg-[color-mix(in_oklab,_var(--sx-bg)_40%,_transparent)] sxa:px-3 sxa:py-2.5 sxa:text-small sxa:shadow-card sxa:placeholder:text-muted sxa:focus-visible:border-accent sxa:focus-visible:ring-[3px] sxa:focus-visible:ring-[color-mix(in_oklab,_var(--sx-accent)_50%,_transparent)] sxa:focus-visible:outline-none';
47
+
48
+ type Mode =
49
+ | { name: 'list' }
50
+ | { name: 'create' }
51
+ | { name: 'thread'; uniqid: string };
52
+
53
+ export function AccountSupport() {
54
+ const { state, reload } = useAccountDashboard();
55
+ const [mode, setMode] = useState<Mode>({ name: 'list' });
56
+
57
+ if (state.status === 'loading') {
58
+ return <AccountRowsSkeleton rows={3} label={t('support.loading')} />;
59
+ }
60
+
61
+ if (state.status === 'error') {
62
+ return <AccountError message={state.message} onRetry={reload} />;
63
+ }
64
+
65
+ const tickets = state.data.tickets;
66
+
67
+ if (mode.name === 'create') {
68
+ return (
69
+ <CreateTicket
70
+ canGoBack={tickets.length > 0}
71
+ onCancel={() => setMode({ name: 'list' })}
72
+ onCreated={(uniqid) => {
73
+ // The list lives in the dashboard payload, so a new ticket only
74
+ // exists here once that payload has been read again.
75
+ reload();
76
+ setMode({ name: 'thread', uniqid });
77
+ }}
78
+ />
79
+ );
80
+ }
81
+
82
+ if (mode.name === 'thread') {
83
+ return (
84
+ <SupportThread
85
+ uniqid={mode.uniqid}
86
+ onBack={() => setMode({ name: 'list' })}
87
+ onReplied={reload}
88
+ />
89
+ );
90
+ }
91
+
92
+ if (tickets.length === 0) {
93
+ return (
94
+ <AccountEmpty
95
+ title={t('support.emptyTitle')}
96
+ body={t('support.emptyBody')}
97
+ action={(
98
+ <button
99
+ type="button"
100
+ className="sx-account-cta sxa:cta-button sxa:inline-flex sxa:h-10 sxa:items-center sxa:px-5 sxa:text-small sxa:font-semibold"
101
+ onClick={() => setMode({ name: 'create' })}
102
+ >
103
+ {t('support.newTicket')}
104
+ </button>
105
+ )}
106
+ />
107
+ );
108
+ }
109
+
110
+ return (
111
+ <div>
112
+ <div className="sxa:flex sxa:flex-wrap sxa:items-center sxa:justify-between sxa:gap-3">
113
+ <AccountSectionTitle>{t('support.ticketsTitle')}</AccountSectionTitle>
114
+ <button
115
+ type="button"
116
+ className="sx-account-button sxa:card-button sxa:card-button--add"
117
+ onClick={() => setMode({ name: 'create' })}
118
+ >
119
+ {t('support.newTicket')}
120
+ </button>
121
+ </div>
122
+
123
+ <ul className="sxa:mt-4 sxa:space-y-3">
124
+ {tickets.map((ticket) => (
125
+ <li key={ticket.uniqid}>
126
+ <button
127
+ type="button"
128
+ onClick={() => setMode({ name: 'thread', uniqid: ticket.uniqid })}
129
+ className="sxa:flex sxa:w-full sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:text-left sxa:shadow-card sxa:transition-colors sxa:hover:bg-raised"
130
+ >
131
+ <MessageSquareIcon className="sxa:h-4 sxa:w-4 sxa:shrink-0 sxa:text-muted" />
132
+ <div className="sxa:min-w-0 sxa:flex-1">
133
+ <p className="sxa:truncate sxa:text-small sxa:font-semibold">{ticket.title}</p>
134
+ <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
135
+ {t('support.updated', {
136
+ date: formatAccountDateTime(ticket.updated_at),
137
+ })}
138
+ </p>
139
+ </div>
140
+ <StatusChip status={ticket.status} />
141
+ </button>
142
+ </li>
143
+ ))}
144
+ </ul>
145
+ </div>
146
+ );
147
+ }
148
+
149
+ function CreateTicket({
150
+ canGoBack,
151
+ onCancel,
152
+ onCreated,
153
+ }: {
154
+ canGoBack: boolean;
155
+ onCancel: () => void;
156
+ onCreated: (uniqid: string) => void;
157
+ }) {
158
+ const fieldId = useId();
159
+ const [title, setTitle] = useState('');
160
+ const [message, setMessage] = useState('');
161
+ const [busy, setBusy] = useState(false);
162
+ const [error, setError] = useState<string | null>(null);
163
+
164
+ const submit = async () => {
165
+ if (!isSendableMessage(message)) {
166
+ setError(t('support.tooShort'));
167
+ return;
168
+ }
169
+
170
+ const subject = ticketTitleToSend(title);
171
+
172
+ setBusy(true);
173
+ setError(null);
174
+ const result = await callCustomerApi((sdk) => sdk.createTicket({
175
+ ...(subject === undefined ? {} : { title: subject }),
176
+ message: message.trim(),
177
+ }));
178
+ setBusy(false);
179
+
180
+ if (!result.success) {
181
+ setError(result.message?.trim() || t('support.createFailed'));
182
+ return;
183
+ }
184
+
185
+ const uniqid = readCreatedTicketUniqid(result.data);
186
+ if (uniqid === null) {
187
+ // The ticket may well exist, but without its id there is nothing to
188
+ // open — saying so beats sending the buyer into a blank thread.
189
+ setError(t('support.createFailed'));
190
+ return;
191
+ }
192
+
193
+ onCreated(uniqid);
194
+ };
195
+
196
+ return (
197
+ <div>
198
+ {canGoBack ? (
199
+ <AccountBackButton onClick={onCancel} label={t('support.backToTickets')} />
200
+ ) : null}
201
+
202
+ <AccountCard className={`sxa:p-6 ${canGoBack ? 'sxa:mt-5' : ''}`}>
203
+ <form
204
+ noValidate
205
+ onSubmit={(event) => {
206
+ event.preventDefault();
207
+ void submit();
208
+ }}
209
+ >
210
+ <label htmlFor={`${fieldId}-title`} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
211
+ {t('support.subjectLabel')}
212
+ <span className="sxa:ml-2 sxa:text-micro sxa:font-normal sxa:text-muted">
213
+ {t('support.subjectOptional')}
214
+ </span>
215
+ </label>
216
+ <input
217
+ id={`${fieldId}-title`}
218
+ type="text"
219
+ value={title}
220
+ disabled={busy}
221
+ onChange={(event) => setTitle(event.target.value)}
222
+ className={FIELD}
223
+ />
224
+
225
+ <label
226
+ htmlFor={`${fieldId}-message`}
227
+ className="sxa:mt-5 sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium"
228
+ >
229
+ {t('support.messageLabel')}
230
+ </label>
231
+ <textarea
232
+ id={`${fieldId}-message`}
233
+ rows={5}
234
+ value={message}
235
+ disabled={busy}
236
+ aria-invalid={error ? true : undefined}
237
+ onChange={(event) => {
238
+ setMessage(event.target.value);
239
+ setError(null);
240
+ }}
241
+ className={`${FIELD} sxa:resize-none`}
242
+ />
243
+
244
+ {error ? <AccountFieldError message={error} /> : null}
245
+
246
+ <button
247
+ type="submit"
248
+ disabled={busy}
249
+ aria-busy={busy || undefined}
250
+ className="sx-account-cta sxa:cta-button sxa:mt-5 sxa:inline-flex sxa:h-10 sxa:items-center sxa:px-5 sxa:text-small sxa:font-semibold sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
251
+ >
252
+ {busy ? <BusySpinner className="sxa:mr-1.5" /> : null}
253
+ {busy ? t('support.creating') : t('support.create')}
254
+ </button>
255
+ </form>
256
+ </AccountCard>
257
+ </div>
258
+ );
259
+ }
260
+
261
+ function SupportThread({
262
+ uniqid,
263
+ onBack,
264
+ onReplied,
265
+ }: {
266
+ uniqid: string;
267
+ onBack: () => void;
268
+ onReplied: () => void;
269
+ }) {
270
+ const { state, reload } = useAccountResource(`ticket:${uniqid}`, () =>
271
+ callCustomerApi((sdk) => sdk.ticket(uniqid)));
272
+
273
+ const back = <AccountBackButton onClick={onBack} label={t('support.backToTickets')} />;
274
+
275
+ if (state.status === 'loading') {
276
+ return (
277
+ <div>
278
+ {back}
279
+ <div className="sxa:mt-5">
280
+ <AccountRowsSkeleton rows={3} label={t('support.loadingThread')} />
281
+ </div>
282
+ </div>
283
+ );
284
+ }
285
+
286
+ if (state.status === 'error') {
287
+ return (
288
+ <div>
289
+ {back}
290
+ <div className="sxa:mt-5">
291
+ <AccountError message={state.message} onRetry={reload} />
292
+ </div>
293
+ </div>
294
+ );
295
+ }
296
+
297
+ const narrowed = narrowSupportTicket(state.data);
298
+ if (!narrowed.ok) {
299
+ // A thread missing one message reads as the merchant never having answered,
300
+ // so a partial read is not shown at all.
301
+ return (
302
+ <div>
303
+ {back}
304
+ <div className="sxa:mt-5">
305
+ <AccountError message={t('common.genericError')} onRetry={reload} />
306
+ </div>
307
+ </div>
308
+ );
309
+ }
310
+
311
+ return (
312
+ <div>
313
+ {back}
314
+ <ThreadBody
315
+ ticket={narrowed.ticket}
316
+ onReplied={() => {
317
+ // Both reads move: the thread gains a message, and the dashboard's
318
+ // row for it gains a new status and "updated" time.
319
+ reload();
320
+ onReplied();
321
+ }}
322
+ />
323
+ </div>
324
+ );
325
+ }
326
+
327
+ function ThreadBody({
328
+ ticket,
329
+ onReplied,
330
+ }: {
331
+ ticket: SupportTicket;
332
+ onReplied: () => void;
333
+ }) {
334
+ const replyId = useId();
335
+ const [reply, setReply] = useState('');
336
+ const [busy, setBusy] = useState(false);
337
+ const [error, setError] = useState<string | null>(null);
338
+ const closed = isTicketClosed(ticket.status);
339
+
340
+ const send = async () => {
341
+ if (!isSendableMessage(reply)) {
342
+ setError(t('support.tooShort'));
343
+ return;
344
+ }
345
+
346
+ setBusy(true);
347
+ setError(null);
348
+ const result = await callCustomerApi((sdk) => sdk.replyToTicket(ticket.uniqid, reply.trim()));
349
+ setBusy(false);
350
+
351
+ if (!result.success) {
352
+ setError(result.message?.trim() || t('support.replyFailed'));
353
+ return;
354
+ }
355
+
356
+ setReply('');
357
+ onReplied();
358
+ };
359
+
360
+ return (
361
+ <div className="sxa:mt-5">
362
+ <div className="sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-4">
363
+ <div className="sxa:min-w-0">
364
+ <h2 className="sxa:font-heading sxa:truncate sxa:text-h3 sxa:font-semibold">
365
+ {ticket.title ?? t('support.untitled')}
366
+ </h2>
367
+ <p className="sxa:mt-1 sxa:text-small sxa:text-muted">
368
+ {t('support.updated', { date: formatAccountDateTime(ticket.updated_at) })}
369
+ </p>
370
+ {ticket.invoice_id ? (
371
+ <p className="sxa:mt-1 sxa:text-small sxa:text-muted">
372
+ {t('support.aboutOrder', { id: ticket.invoice_id })}
373
+ </p>
374
+ ) : null}
375
+ </div>
376
+ <StatusChip status={ticket.status} />
377
+ </div>
378
+
379
+ {ticket.messages.length === 0 ? (
380
+ <p className="sxa:mt-5 sxa:text-small sxa:leading-relaxed sxa:text-muted">
381
+ {t('support.noMessages')}
382
+ </p>
383
+ ) : (
384
+ <ul className="sxa:mt-5 sxa:space-y-3">
385
+ {ticket.messages.map((message) => (
386
+ <li key={message.id}>
387
+ <AccountCard className="sxa:p-5">
388
+ <div className="sxa:flex sxa:flex-wrap sxa:items-baseline sxa:justify-between sxa:gap-2">
389
+ <p
390
+ className={`sxa:text-small sxa:font-semibold ${isOwnMessage(message.role) ? 'sxa:text-accent' : ''}`}
391
+ >
392
+ {t(messageAuthorKey(message.role))}
393
+ </p>
394
+ <p className="sxa:text-micro sxa:text-muted">
395
+ {formatAccountDateTime(message.created_at)}
396
+ </p>
397
+ </div>
398
+ {/* A written message keeps its own line breaks. */}
399
+ <p className="sxa:mt-2 sxa:text-small sxa:leading-relaxed sxa:text-muted sxa:whitespace-pre-wrap">
400
+ {message.message}
401
+ </p>
402
+ </AccountCard>
403
+ </li>
404
+ ))}
405
+ </ul>
406
+ )}
407
+
408
+ {closed ? (
409
+ <AccountCard className="sxa:mt-5 sxa:p-5">
410
+ <p className="sxa:text-small sxa:font-semibold">{t('support.closedTitle')}</p>
411
+ <p className="sxa:mt-1 sxa:text-small sxa:leading-relaxed sxa:text-muted">
412
+ {t('support.closedBody')}
413
+ </p>
414
+ </AccountCard>
415
+ ) : (
416
+ <form
417
+ noValidate
418
+ className="sxa:mt-5"
419
+ onSubmit={(event) => {
420
+ event.preventDefault();
421
+ void send();
422
+ }}
423
+ >
424
+ <label htmlFor={replyId} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
425
+ {t('support.replyLabel')}
426
+ </label>
427
+ <textarea
428
+ id={replyId}
429
+ rows={4}
430
+ value={reply}
431
+ disabled={busy}
432
+ aria-invalid={error ? true : undefined}
433
+ onChange={(event) => {
434
+ setReply(event.target.value);
435
+ setError(null);
436
+ }}
437
+ className={`${FIELD} sxa:resize-none`}
438
+ />
439
+ {error ? <AccountFieldError message={error} /> : null}
440
+ <button
441
+ type="submit"
442
+ disabled={busy}
443
+ aria-busy={busy || undefined}
444
+ className="sx-account-cta sxa:cta-button sxa:mt-4 sxa:inline-flex sxa:h-10 sxa:items-center sxa:px-5 sxa:text-small sxa:font-semibold sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
445
+ >
446
+ {busy ? <BusySpinner className="sxa:mr-1.5" /> : null}
447
+ {busy ? t('support.replying') : t('support.reply')}
448
+ </button>
449
+ </form>
450
+ )}
451
+ </div>
452
+ );
453
+ }
package/package.json CHANGED
@@ -1 +1,75 @@
1
- {"name":"@shoppexio/storefront-react","version":"0.0.0","description":"Placeholder, see 0.1.0","license":"MIT"}
1
+ {
2
+ "name": "@shoppexio/storefront-react",
3
+ "version": "0.1.0",
4
+ "description": "Commerce state, hooks, models and the customer account UI for Shoppex code storefronts",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ShoppexIO/shoppex.git",
9
+ "directory": "packages/storefront-react"
10
+ },
11
+ "homepage": "https://docs.shoppex.io/storefront/code-storefront-development",
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "sideEffects": [
16
+ "*.css"
17
+ ],
18
+ "types": "./dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ },
24
+ "./account": {
25
+ "types": "./dist/account.d.ts",
26
+ "import": "./dist/account.js"
27
+ },
28
+ "./account.css": "./dist/account.css",
29
+ "./package.json": "./package.json"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "eject",
34
+ "README.md",
35
+ "CHANGELOG.md",
36
+ "LICENSE"
37
+ ],
38
+ "scripts": {
39
+ "build": "tsup && bun run build:css && bun run build:eject",
40
+ "build:css": "tailwindcss --input src/account/account.css --output dist/account.css --optimize",
41
+ "lint": "biome lint src tests scripts",
42
+ "typecheck": "tsc --noEmit",
43
+ "test": "bun run build:css && bun test ./tests",
44
+ "build:eject": "bun scripts/build-eject.ts"
45
+ },
46
+ "keywords": [
47
+ "shoppex",
48
+ "storefront",
49
+ "react",
50
+ "ecommerce"
51
+ ],
52
+ "author": "Shoppex",
53
+ "license": "MIT",
54
+ "dependencies": {
55
+ "dompurify": "^3.2.7",
56
+ "marked": "^17.0.4"
57
+ },
58
+ "peerDependencies": {
59
+ "react": "^19.2.7",
60
+ "react-dom": "^19.2.7",
61
+ "react-router-dom": "^7.18.1"
62
+ },
63
+ "devDependencies": {
64
+ "@tailwindcss/cli": "4.2.1",
65
+ "@types/bun": "^1.4.0",
66
+ "@types/react": "19.2.14",
67
+ "@types/react-dom": "19.2.3",
68
+ "react": "19.2.7",
69
+ "react-dom": "19.2.7",
70
+ "react-router-dom": "7.18.1",
71
+ "tailwindcss": "4.2.1",
72
+ "tsup": "^8.5.0",
73
+ "typescript": "^5.8.3"
74
+ }
75
+ }