@shoppexio/storefront-react 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +215 -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 +79 -1
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Sign-in, in the page the buyer is already standing on.
3
+ *
4
+ * Two steps and no password: ask for a code, type it back, and the same panel
5
+ * becomes the account. There is no redirect and no second surface to keep in
6
+ * sync.
7
+ *
8
+ * Two things this component must never do, on any theme:
9
+ * - touch a session token. `verifyOtp` resolves with nothing secret — the
10
+ * Shoppex worker reads the response and sets an HttpOnly cookie the bundle
11
+ * cannot see. Storefront code that stored a token would be holding a
12
+ * credential the platform deliberately kept out of its reach.
13
+ * - send a shop. The worker derives the tenant from the host the buyer is on.
14
+ * A shop chosen in the page would put a tenant boundary behind a text field
15
+ * on an unauthenticated route.
16
+ *
17
+ * Every refusal is printed in the server's own sentence. The code's lifetime,
18
+ * the attempt limit and the lockout rule are all decided server-side, so a
19
+ * storefront that paraphrases them is telling the buyer something that may
20
+ * already be false.
21
+ */
22
+
23
+ import { useState } from 'react';
24
+ import type { AccountSignInPrefill } from './account-signin-link';
25
+ import { callCustomerApi } from './customer-account';
26
+ import { t } from './i18n';
27
+ import { MailIcon } from './icons';
28
+ import { AccountCard, AccountFieldError, AccountNotice, BusySpinner } from './shared';
29
+
30
+ /** Long enough for any code the producer mints, short enough to reject a paste of a page. */
31
+ const OTP_MAX_LENGTH = 32;
32
+
33
+ type Step =
34
+ | { name: 'email' }
35
+ | { name: 'code'; email: string };
36
+
37
+ /** The template's field skin — the same one the checkout and contact forms wear. */
38
+ const FIELD =
39
+ 'sx-account-field sxa:w-full 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 sxa:disabled:opacity-50';
40
+
41
+ const LABEL = 'sx-account-label sxa:block sxa:text-small sxa:font-medium sxa:text-muted';
42
+
43
+ const ERROR_ID = 'account-login-error';
44
+
45
+ /**
46
+ * A prefill puts the buyer on the code step with both fields already filled —
47
+ * and stops there. It never submits: the code stays unspent until the buyer
48
+ * presses the button, which is the only reason the link is allowed to carry
49
+ * one at all.
50
+ */
51
+ export function AccountLogin({
52
+ onSignedIn,
53
+ prefill = null,
54
+ }: {
55
+ onSignedIn: () => void;
56
+ prefill?: AccountSignInPrefill | null;
57
+ }) {
58
+ const [step, setStep] = useState<Step>(
59
+ prefill ? { name: 'code', email: prefill.email } : { name: 'email' },
60
+ );
61
+ const [email, setEmail] = useState(prefill?.email ?? '');
62
+ const [otp, setOtp] = useState(prefill?.code ?? '');
63
+ const [busy, setBusy] = useState(false);
64
+ const [error, setError] = useState<string | null>(null);
65
+ const [notice, setNotice] = useState<string | null>(null);
66
+
67
+ const sendCode = async (address: string, resend = false) => {
68
+ setBusy(true);
69
+ setError(null);
70
+ setNotice(null);
71
+ const result = await callCustomerApi((sdk) => sdk.requestOtp(address));
72
+ setBusy(false);
73
+
74
+ if (!result.success) {
75
+ setError(result.message?.trim() || t('login.sendFailed'));
76
+ return;
77
+ }
78
+
79
+ setStep({ name: 'code', email: address });
80
+ // Only a resend is worth announcing. The step's own intro already names the
81
+ // address the first code went to, so repeating it under the field is noise.
82
+ if (resend) setNotice(t('login.resent', { email: address }));
83
+ };
84
+
85
+ const submitEmail = () => {
86
+ const address = email.trim();
87
+ // The cheapest check that catches a real mistake — a missing local part or
88
+ // a missing domain. Anything stricter would refuse an address the server
89
+ // would have accepted, and the server is the one that has to deliver to it.
90
+ const at = address.indexOf('@');
91
+ if (at < 1 || at === address.length - 1) {
92
+ setError(t('login.invalidEmail'));
93
+ return;
94
+ }
95
+ void sendCode(address);
96
+ };
97
+
98
+ const submitCode = async (address: string) => {
99
+ const code = otp.trim();
100
+ if (!code) {
101
+ setError(t('login.missingCode'));
102
+ return;
103
+ }
104
+
105
+ setBusy(true);
106
+ setError(null);
107
+ const result = await callCustomerApi((sdk) => sdk.verifyOtp(address, code));
108
+ setBusy(false);
109
+
110
+ if (!result.success) {
111
+ setError(result.message?.trim() || t('login.verifyFailed'));
112
+ return;
113
+ }
114
+
115
+ // The session cookie rode back on that response. Nothing is kept here.
116
+ onSignedIn();
117
+ };
118
+
119
+ return (
120
+ <AccountCard className="sxa:mx-auto sxa:w-full sxa:max-w-md sxa:px-6 sxa:py-8 sxa:sm:px-8">
121
+ <div className="sxa:flex sxa:h-10 sxa:w-10 sxa:items-center sxa:justify-center sxa:rounded-card sxa:bg-[color-mix(in_oklab,_var(--sx-accent)_10%,_transparent)]">
122
+ <MailIcon className="sxa:h-5 sxa:w-5 sxa:text-accent" />
123
+ </div>
124
+ <h2 className="sxa:font-heading sxa:mt-5 sxa:text-h3 sxa:font-semibold">
125
+ {step.name === 'email' ? t('login.titleEmail') : t('login.titleCode')}
126
+ </h2>
127
+ <p className="sxa:mt-2 sxa:text-small sxa:leading-relaxed sxa:text-muted">
128
+ {step.name === 'email'
129
+ ? t('login.introEmail')
130
+ : t('login.introCode', { email: step.email })}
131
+ </p>
132
+
133
+ {step.name === 'email' ? (
134
+ <form
135
+ noValidate
136
+ className="sxa:mt-6 sxa:space-y-1.5"
137
+ onSubmit={(event) => {
138
+ event.preventDefault();
139
+ submitEmail();
140
+ }}
141
+ >
142
+ <label htmlFor="account-email" className={LABEL}>
143
+ {t('login.emailLabel')}
144
+ </label>
145
+ <input
146
+ id="account-email"
147
+ name="email"
148
+ type="email"
149
+ required
150
+ autoComplete="email"
151
+ value={email}
152
+ disabled={busy}
153
+ aria-invalid={error !== null}
154
+ aria-describedby={error ? ERROR_ID : undefined}
155
+ className={`${FIELD} sxa:h-10 sxa:rounded-input ${error ? 'sxa:border-error' : ''}`}
156
+ onChange={(event) => {
157
+ setEmail(event.target.value);
158
+ setError(null);
159
+ }}
160
+ />
161
+ {error ? (
162
+ <div id={ERROR_ID}>
163
+ <AccountFieldError message={error} />
164
+ </div>
165
+ ) : null}
166
+ <button
167
+ type="submit"
168
+ disabled={busy}
169
+ aria-busy={busy || undefined}
170
+ className="sx-account-cta sxa:cta-button sxa:mt-5 sxa:inline-flex sxa:h-11 sxa:w-full sxa:items-center sxa:justify-center sxa:text-small sxa:font-semibold sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
171
+ >
172
+ {busy ? <BusySpinner className="sxa:mr-2" /> : null}
173
+ {busy ? t('login.sending') : t('login.send')}
174
+ </button>
175
+ </form>
176
+ ) : (
177
+ <form
178
+ noValidate
179
+ className="sxa:mt-6 sxa:space-y-1.5"
180
+ onSubmit={(event) => {
181
+ event.preventDefault();
182
+ void submitCode(step.email);
183
+ }}
184
+ >
185
+ <label htmlFor="account-otp" className={LABEL}>
186
+ {t('login.codeLabel')}
187
+ </label>
188
+ <input
189
+ id="account-otp"
190
+ name="otp"
191
+ type="text"
192
+ required
193
+ inputMode="numeric"
194
+ autoComplete="one-time-code"
195
+ maxLength={OTP_MAX_LENGTH}
196
+ value={otp}
197
+ disabled={busy}
198
+ aria-invalid={error !== null}
199
+ aria-describedby={error ? ERROR_ID : undefined}
200
+ className={`${FIELD} sxa:font-mono sxa:tracking-[0.3em] sxa:tabular-nums sxa:h-10 sxa:rounded-input ${error ? 'sxa:border-error' : ''}`}
201
+ onChange={(event) => {
202
+ setOtp(event.target.value);
203
+ setError(null);
204
+ }}
205
+ />
206
+ {notice ? <AccountNotice message={notice} /> : null}
207
+ {error ? (
208
+ <div id={ERROR_ID}>
209
+ <AccountFieldError message={error} />
210
+ </div>
211
+ ) : null}
212
+ <button
213
+ type="submit"
214
+ disabled={busy}
215
+ aria-busy={busy || undefined}
216
+ className="sx-account-cta sxa:cta-button sxa:mt-5 sxa:inline-flex sxa:h-11 sxa:w-full sxa:items-center sxa:justify-center sxa:text-small sxa:font-semibold sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
217
+ >
218
+ {busy ? <BusySpinner className="sxa:mr-2" /> : null}
219
+ {busy ? t('login.submitting') : t('login.submit')}
220
+ </button>
221
+ <div className="sxa:mt-4 sxa:flex sxa:flex-wrap sxa:items-center sxa:justify-between sxa:gap-3">
222
+ <button
223
+ type="button"
224
+ disabled={busy}
225
+ className="sxa:text-small sxa:text-muted sxa:transition-opacity sxa:hover:opacity-80 sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
226
+ onClick={() => {
227
+ setStep({ name: 'email' });
228
+ setOtp('');
229
+ setError(null);
230
+ setNotice(null);
231
+ }}
232
+ >
233
+ {t('login.changeEmail')}
234
+ </button>
235
+ <button
236
+ type="button"
237
+ disabled={busy}
238
+ className="sxa:text-small sxa:font-semibold sxa:text-accent sxa:transition-opacity sxa:hover:opacity-80 sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
239
+ onClick={() => {
240
+ // The old code is dead the moment a new one is minted; leaving
241
+ // it in the field invites the buyer to submit it anyway.
242
+ setOtp('');
243
+ void sendCode(step.email, true);
244
+ }}
245
+ >
246
+ {t('login.resend')}
247
+ </button>
248
+ </div>
249
+ </form>
250
+ )}
251
+ </AccountCard>
252
+ );
253
+ }