@usequeek/theme-kit 0.1.19 → 0.1.21

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 (34) hide show
  1. package/README.md +121 -1
  2. package/components/auth/auth-email-login-step.tsx +9 -7
  3. package/components/auth/auth-email-otp-step.tsx +9 -7
  4. package/components/auth/auth-email-otp-verify-step.tsx +8 -6
  5. package/components/auth/auth-email-register-step.tsx +10 -8
  6. package/components/auth/auth-signup-step.tsx +8 -6
  7. package/components/blog/category-filter.tsx +6 -5
  8. package/components/blog/pagination.tsx +8 -7
  9. package/components/blog/post-meta.tsx +15 -8
  10. package/components/blog/related-posts.tsx +5 -3
  11. package/components/blog/share-buttons.tsx +10 -8
  12. package/components/checkout/checkout-contact.tsx +6 -4
  13. package/components/checkout/default-checkout-shell.tsx +17 -15
  14. package/components/checkout/policy-links.tsx +5 -3
  15. package/components/powered-by-queek.tsx +12 -3
  16. package/hooks/use-auth-flow.tsx +18 -17
  17. package/index.ts +4 -0
  18. package/locales/en.default.json +45 -7
  19. package/package.json +1 -1
  20. package/strings/pseudo-locale.ts +264 -0
  21. package/strings/theme-strings.ts +178 -15
  22. package/tests/auth-email-errors.test.tsx +55 -0
  23. package/tests/display-format.test.ts +364 -2
  24. package/tests/powered-by-queek.test.tsx +31 -3
  25. package/tests/powered-by-url.test.ts +49 -0
  26. package/tests/shopper-chrome-strings.test.tsx +272 -0
  27. package/tests/theme-hook-placement.test.tsx +179 -0
  28. package/tests/theme-strings-pseudo.test.tsx +171 -0
  29. package/tests/theme-strings-typed.test.tsx +97 -0
  30. package/tests/theme-strings.test.tsx +8 -2
  31. package/tests/theme-typed-keys.fixture.ts +76 -0
  32. package/utils/format.ts +97 -9
  33. package/utils/locale.ts +52 -0
  34. package/utils/powered-by.ts +31 -0
@@ -3,6 +3,7 @@
3
3
  import type { JSX } from 'react';
4
4
  import { useEffect, useRef, useState } from 'react';
5
5
  import { useAuth } from '../../hooks/use-auth';
6
+ import { useThemeStrings } from '../../provider';
6
7
  import { Image } from '../image';
7
8
 
8
9
  /**
@@ -13,6 +14,7 @@ import { Image } from '../image';
13
14
  */
14
15
  export function CheckoutContactSection(): JSX.Element {
15
16
  const { user, isAuthenticated, openAuthModal, logout } = useAuth();
17
+ const t = useThemeStrings();
16
18
  const [menuOpen, setMenuOpen] = useState(false);
17
19
  const menuRef = useRef<HTMLDivElement>(null);
18
20
 
@@ -31,13 +33,13 @@ export function CheckoutContactSection(): JSX.Element {
31
33
  return (
32
34
  <section className="core-checkout__section core-checkout-contact">
33
35
  <div className="core-checkout-contact__row">
34
- <span className="core-checkout-contact__label">Contact</span>
36
+ <span className="core-checkout-contact__label">{t('checkout.contact.label')}</span>
35
37
  <button
36
38
  type="button"
37
39
  className="core-checkout-contact__signin"
38
40
  onClick={() => openAuthModal('login')}
39
41
  >
40
- Sign in
42
+ {t('checkout.contact.signin')}
41
43
  </button>
42
44
  </div>
43
45
  </section>
@@ -62,7 +64,7 @@ export function CheckoutContactSection(): JSX.Element {
62
64
  <button
63
65
  type="button"
64
66
  className="core-checkout-contact__menu-btn"
65
- aria-label="Account options"
67
+ aria-label={t('checkout.contact.options')}
66
68
  onClick={() => setMenuOpen((v) => !v)}
67
69
  >
68
70
  <svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
@@ -74,7 +76,7 @@ export function CheckoutContactSection(): JSX.Element {
74
76
  {menuOpen ? (
75
77
  <div className="core-checkout-contact__menu">
76
78
  <button type="button" onClick={() => { setMenuOpen(false); logout(); }}>
77
- Sign out
79
+ {t('checkout.contact.signout')}
78
80
  </button>
79
81
  </div>
80
82
  ) : null}
@@ -5,6 +5,7 @@ import type { CheckoutShellProps } from '../../types/theme';
5
5
  import { formatMoney } from '../../utils/format';
6
6
  import { useHref } from '../../hooks/use-href';
7
7
  import { CheckoutContactSection } from './checkout-contact';
8
+ import { useThemeStrings } from '../../provider';
8
9
  import { PolicyLinks } from './policy-links';
9
10
 
10
11
  export function DefaultCheckoutShell({
@@ -29,14 +30,15 @@ export function DefaultCheckoutShell({
29
30
  policies,
30
31
  }: CheckoutShellProps): JSX.Element {
31
32
  const href = useHref();
33
+ const t = useThemeStrings();
32
34
 
33
35
  if (mode === 'page') {
34
36
  if (totalCount === 0) {
35
37
  return (
36
38
  <div className="core-checkout core-checkout--page">
37
39
  <div className="core-checkout__empty">
38
- <p>Your cart is empty.</p>
39
- <Link href={href('/')} className="core-checkout__back-link">Browse products</Link>
40
+ <p>{t('checkout.empty.message')}</p>
41
+ <Link href={href('/')} className="core-checkout__back-link">{t('checkout.empty.browse')}</Link>
40
42
  </div>
41
43
  </div>
42
44
  );
@@ -52,23 +54,23 @@ export function DefaultCheckoutShell({
52
54
  <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" width="14" height="14">
53
55
  <path d="M10 13L5 8l5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
54
56
  </svg>
55
- Continue shopping
57
+ {t('checkout.page.back')}
56
58
  </Link>
57
59
  </div>
58
60
  <CheckoutContactSection />
59
61
  <section className="core-checkout__section">
60
- <h3 className="core-checkout__section-title">How you&apos;ll get it</h3>
62
+ <h3 className="core-checkout__section-title">{t('checkout.delivery.title')}</h3>
61
63
  {deliveryModes}
62
64
  </section>
63
65
  {shippingZonePicker ? (
64
66
  <section className="core-checkout__section">
65
- <h3 className="core-checkout__section-title">Shipping zone</h3>
67
+ <h3 className="core-checkout__section-title">{t('checkout.zone.title')}</h3>
66
68
  {shippingZonePicker}
67
69
  </section>
68
70
  ) : null}
69
71
  {addressPicker ? (
70
72
  <section className="core-checkout__section">
71
- <h3 className="core-checkout__section-title">Delivery address</h3>
73
+ <h3 className="core-checkout__section-title">{t('checkout.address.title')}</h3>
72
74
  {addressPicker}
73
75
  </section>
74
76
  ) : null}
@@ -79,13 +81,13 @@ export function DefaultCheckoutShell({
79
81
  ) : null}
80
82
  {schedulePicker ? (
81
83
  <section className="core-checkout__section">
82
- <h3 className="core-checkout__section-title">Schedule</h3>
84
+ <h3 className="core-checkout__section-title">{t('checkout.schedule.title')}</h3>
83
85
  {schedulePicker}
84
86
  </section>
85
87
  ) : null}
86
88
  <section className="core-checkout__section">
87
- <h3 className="core-checkout__payment-title">Payment</h3>
88
- <p className="core-checkout__payment-subtitle">All transactions are secure and encrypted.</p>
89
+ <h3 className="core-checkout__payment-title">{t('checkout.payment.title')}</h3>
90
+ <p className="core-checkout__payment-subtitle">{t('checkout.payment.secure')}</p>
89
91
  {paymentPicker}
90
92
  </section>
91
93
  <section className="core-checkout__section">
@@ -97,7 +99,7 @@ export function DefaultCheckoutShell({
97
99
  {/* Right pane: off-white — order summary */}
98
100
  <div className="core-checkout__page-summary">
99
101
  <div className="core-checkout__summary-card">
100
- <h3 className="core-checkout__section-title">Order summary</h3>
102
+ <h3 className="core-checkout__section-title">{t('checkout.summary.title')}</h3>
101
103
  {cartItems}
102
104
  <div className="core-checkout__summary-promo">{promoInput}</div>
103
105
  <div className="core-checkout__summary-fees">{feesBreakdown}</div>
@@ -123,7 +125,7 @@ export function DefaultCheckoutShell({
123
125
  className={`core-checkout__tab${activeTab === 'summary' ? ' is-active' : ''}`}
124
126
  onClick={() => onTabChange('summary')}
125
127
  >
126
- Summary {totalCount > 0 ? `(${totalCount})` : ''}
128
+ {t('checkout.tabs.summary')}{totalCount > 0 ? ` (${totalCount})` : ' '}
127
129
  </button>
128
130
  <button
129
131
  type="button"
@@ -131,24 +133,24 @@ export function DefaultCheckoutShell({
131
133
  onClick={() => onTabChange('checkout')}
132
134
  disabled={totalCount === 0}
133
135
  >
134
- Checkout
136
+ {t('checkout.tabs.checkout')}
135
137
  </button>
136
138
  </div>
137
139
 
138
140
  {activeTab === 'summary' ? (
139
141
  <div className="core-checkout__summary">
140
142
  {totalCount === 0 ? (
141
- <div className="core-checkout__empty"><p>Your cart is empty</p></div>
143
+ <div className="core-checkout__empty"><p>{t('checkout.summary.empty')}</p></div>
142
144
  ) : (
143
145
  <>
144
146
  {cartItems}
145
147
  <div className="core-checkout__summary-footer">
146
148
  <div className="core-checkout__subtotal">
147
- <span>Subtotal</span>
149
+ <span>{t('checkout.summary.subtotal')}</span>
148
150
  <span>{formatMoney(subtotal, currency)}</span>
149
151
  </div>
150
152
  <button type="button" className="core-submit-btn" onClick={() => onTabChange('checkout')}>
151
- Proceed to checkout
153
+ {t('checkout.summary.proceed')}
152
154
  </button>
153
155
  </div>
154
156
  </>
@@ -6,6 +6,7 @@ import { createPortal } from 'react-dom';
6
6
  import Link from 'next/link';
7
7
  import type { Page } from '../../types/page';
8
8
  import { useHref } from '../../hooks/use-href';
9
+ import { useThemeStrings } from '../../provider';
9
10
  import { usePolicyModalStore } from '../../stores/policy-modal-store';
10
11
  import { PageRenderer } from '../../page-renderer';
11
12
 
@@ -23,6 +24,7 @@ function subscribeNoop(): () => void {
23
24
  }
24
25
 
25
26
  export function PolicyLinks({ policies }: { policies: Page[] }): JSX.Element | null {
27
+ const t = useThemeStrings();
26
28
  const activeSlug = usePolicyModalStore((s) => s.activeSlug);
27
29
  const open = usePolicyModalStore((s) => s.open);
28
30
  const close = usePolicyModalStore((s) => s.close);
@@ -59,7 +61,7 @@ export function PolicyLinks({ policies }: { policies: Page[] }): JSX.Element | n
59
61
  >
60
62
  <div className="core-policy-modal__head">
61
63
  <h2 className="core-policy-modal__title">{active.title}</h2>
62
- <button type="button" className="core-policy-modal__close" aria-label="Close" onClick={close}>
64
+ <button type="button" className="core-policy-modal__close" aria-label={t('checkout.policy.close')} onClick={close}>
63
65
  <svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
64
66
  <path d="M5 5l10 10M15 5L5 15" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
65
67
  </svg>
@@ -70,7 +72,7 @@ export function PolicyLinks({ policies }: { policies: Page[] }): JSX.Element | n
70
72
  </div>
71
73
  <div className="core-policy-modal__foot">
72
74
  <Link href={href(`/${active.slug}`)} className="core-policy-modal__full-link" onClick={close}>
73
- View full page
75
+ {t('checkout.policy.view')}
74
76
  </Link>
75
77
  </div>
76
78
  </div>
@@ -81,7 +83,7 @@ export function PolicyLinks({ policies }: { policies: Page[] }): JSX.Element | n
81
83
 
82
84
  return (
83
85
  <>
84
- <nav className="core-checkout-policies" aria-label="Store policies">
86
+ <nav className="core-checkout-policies" aria-label={t('checkout.policy.list')}>
85
87
  {policies.map((policy) => (
86
88
  <button
87
89
  key={policy.id}
@@ -1,4 +1,8 @@
1
- import type { JSX } from 'react';
1
+ 'use client';
2
+
3
+ import { useContext, type JSX } from 'react';
4
+ import { StorefrontContext } from '../provider';
5
+ import { poweredByQueekUrl } from '../utils/powered-by';
2
6
 
3
7
  export interface PoweredByQueekProps {
4
8
  /** Extra class so a footer can position the link — styling stays theme-owned. */
@@ -17,15 +21,20 @@ export interface PoweredByQueekProps {
17
21
  * Required on every footer variant of every theme (enforced by `theme-check`
18
22
  * rule `theme/footer-shows-powered-by` in the storefront repo). The link
19
23
  * target (`https://usequeek.com/business`) and `rel` are the contract —
20
- * do not change them. Markup carries `core-powered-by` classes so each theme
24
+ * do not change them. The href is tagged `utm_source=powered_by`,
25
+ * `utm_medium=storefront`, `utm_campaign=<store slug>` via `poweredByQueekUrl`
26
+ * (read from the storefront context, so themes pass nothing; no provider means
27
+ * no campaign, never an error). Markup carries `core-powered-by` classes so each theme
21
28
  * styles it in its own `theme.css`; no kit CSS ships for it.
22
29
  */
23
30
  export function PoweredByQueek({ className, hidden = false }: PoweredByQueekProps): JSX.Element | null {
31
+ const vendor = useContext(StorefrontContext)?.vendor;
32
+
24
33
  if (hidden) return null;
25
34
 
26
35
  return (
27
36
  <a
28
- href="https://usequeek.com/business"
37
+ href={poweredByQueekUrl(vendor?.slug)}
29
38
  target="_blank"
30
39
  rel="noopener noreferrer"
31
40
  className={className ? `core-powered-by ${className}` : 'core-powered-by'}
@@ -3,7 +3,7 @@
3
3
  import type { JSX } from 'react';
4
4
  import { createContext, useContext, useState, type ReactNode } from 'react';
5
5
  import { QueekSdkError, type EmailVerifyOtpResponse } from '@queekai/client-sdk';
6
- import { useStorefront } from '../provider';
6
+ import { useStorefront, useThemeStrings } from '../provider';
7
7
  import { getQueekClient, setAuthTokens, QUEEK_AUTH_URL } from '../sdk/queek-client';
8
8
  import { useAuthModalStore } from '../stores/auth-modal-store';
9
9
  import { useUserStore } from '../stores/user-store';
@@ -63,32 +63,33 @@ interface PasswordAuthData {
63
63
  };
64
64
  }
65
65
 
66
- function mapEmailError(err: unknown): string {
66
+ export function mapEmailError(err: unknown, t: (key: string) => string): string {
67
67
  if (err instanceof QueekSdkError) {
68
68
  switch (err.code) {
69
69
  case 'invalid_credentials':
70
- return 'Wrong email or password. Please try again.';
70
+ return t('auth.error.credentials');
71
71
  case 'login_mode_mismatch':
72
- return 'This account uses a different sign-in method (Google or phone).';
72
+ return t('auth.error.method');
73
73
  case 'account_already_exists':
74
- return 'Email already registered. Please sign in instead.';
74
+ return t('auth.error.exists');
75
75
  case 'phone_already_exists':
76
- return 'Phone number already in use.';
76
+ return t('auth.error.phone');
77
77
  case 'otp_resend_too_soon':
78
- return 'Please wait before requesting another code.';
78
+ return t('auth.error.resend');
79
79
  case 'invalid_otp':
80
- return 'Incorrect code. Please try again.';
80
+ return t('auth.error.code');
81
81
  default:
82
- return err.message || 'Something went wrong. Please try again.';
82
+ return err.message || t('auth.error.generic');
83
83
  }
84
84
  }
85
- return 'Something went wrong. Please try again.';
85
+ return t('auth.error.generic');
86
86
  }
87
87
 
88
88
  const AuthFlowContext = createContext<AuthFlowValue | null>(null);
89
89
 
90
90
  export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Element {
91
91
  const { vendor } = useStorefront();
92
+ const t = useThemeStrings();
92
93
  const closeModal = useAuthModalStore((s) => s.close);
93
94
  const setUser = useUserStore((s) => s.setUser);
94
95
 
@@ -131,7 +132,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
131
132
  setOtp('');
132
133
  setStep('email-otp-verify');
133
134
  } catch (err) {
134
- setError(mapEmailError(err));
135
+ setError(mapEmailError(err, t));
135
136
  } finally {
136
137
  setLoading(false);
137
138
  }
@@ -156,7 +157,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
156
157
  applyUser(data.user);
157
158
  closeModal();
158
159
  } catch (err) {
159
- setError(mapEmailError(err));
160
+ setError(mapEmailError(err, t));
160
161
  setOtp('');
161
162
  } finally {
162
163
  setLoading(false);
@@ -167,7 +168,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
167
168
 
168
169
  const submitRegister = async (): Promise<void> => {
169
170
  if (!firstName.trim()) {
170
- setError('Please enter your first name.');
171
+ setError(t('auth.error.firstname'));
171
172
  return;
172
173
  }
173
174
  setError(null);
@@ -183,7 +184,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
183
184
  applyUser(res.user);
184
185
  closeModal();
185
186
  } catch (err) {
186
- setError(err instanceof QueekSdkError ? err.message : 'Registration failed. Try again.');
187
+ setError(err instanceof QueekSdkError ? err.message : t('auth.error.register'));
187
188
  } finally {
188
189
  setLoading(false);
189
190
  }
@@ -206,7 +207,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
206
207
  applyUser(res.data.user);
207
208
  closeModal();
208
209
  } catch (err) {
209
- setError(mapEmailError(err));
210
+ setError(mapEmailError(err, t));
210
211
  } finally {
211
212
  setLoading(false);
212
213
  }
@@ -230,7 +231,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
230
231
  applyUser(res.data.user);
231
232
  closeModal();
232
233
  } catch (err) {
233
- setError(mapEmailError(err));
234
+ setError(mapEmailError(err, t));
234
235
  } finally {
235
236
  setLoading(false);
236
237
  }
@@ -272,7 +273,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
272
273
  } catch (err) {
273
274
  // eslint-disable-next-line no-console
274
275
  console.error('[google-auth] initiate failed', err);
275
- setError('Could not connect to Google sign-in. Please try again.');
276
+ setError(t('auth.error.google'));
276
277
  } finally {
277
278
  setLoading(false);
278
279
  }
package/index.ts CHANGED
@@ -48,9 +48,13 @@ export * from './utils/benefit-icon';
48
48
  export * from './utils/video-embed';
49
49
  export * from './utils/match-variant';
50
50
  export * from './utils/announcement';
51
+ export * from './utils/locale';
51
52
 
52
53
  // Theme strings — the pure/server-safe i18n core (no next/*, no React).
53
54
  export * from './strings/theme-strings';
55
+ // Pseudo-locale QA — dev/test-only dictionary transform + English-leak scan
56
+ // (pure/server-safe: no next/*, no React, so theme-check can import it too).
57
+ export * from './strings/pseudo-locale';
54
58
 
55
59
  // Rendering a theme's own demo.json — what makes local preview possible.
56
60
  export * from './demo';
@@ -87,18 +87,29 @@
87
87
  "title": "Sign in",
88
88
  "subtitle": "Enter your email and password",
89
89
  "email": "Email address",
90
- "password": "Password"
90
+ "password": "Password",
91
+ "signing": "Signing in…",
92
+ "create": "Don't have an account? Create one",
93
+ "code": "Use an email code instead"
91
94
  },
92
95
  "email": {
93
96
  "title": "Sign in",
94
97
  "subtitle": "Enter your email to continue",
98
+ "placeholder": "your@email.com",
95
99
  "or": "or",
96
- "placeholder": "your@email.com"
100
+ "google": "Continue with Google",
101
+ "sending": "Sending code…",
102
+ "continue": "Continue",
103
+ "password": "Have a password? Sign in with it instead"
97
104
  },
98
105
  "code": {
99
106
  "title": "Enter code",
100
- "sent": "{length}-digit code sent to {email}",
101
- "digit": "Digit {index}"
107
+ "lead": "{length}-digit code sent to",
108
+ "digit": "Digit {index}",
109
+ "verifying": "Verifying…",
110
+ "verify": "Verify",
111
+ "resend": "Resend code",
112
+ "change": "← Change email"
102
113
  },
103
114
  "register": {
104
115
  "title": "Create account",
@@ -106,13 +117,30 @@
106
117
  "first": "First name",
107
118
  "last": "Last name (optional)",
108
119
  "email": "Email address",
109
- "password": "Password (min 8 characters)"
120
+ "password": "Password (min 8 characters)",
121
+ "creating": "Creating account…",
122
+ "signin": "Already have an account? Sign in"
110
123
  },
111
124
  "signup": {
112
125
  "title": "Create your account",
113
126
  "subtitle": "Just a few details to get you started",
114
127
  "first": "First name",
115
- "last": "Last name (optional)"
128
+ "last": "Last name (optional)",
129
+ "create": "Create account",
130
+ "creating": "Creating account…",
131
+ "different": "← Use a different email"
132
+ },
133
+ "error": {
134
+ "credentials": "Wrong email or password. Please try again.",
135
+ "method": "This account uses a different sign-in method (Google or phone).",
136
+ "exists": "Email already registered. Please sign in instead.",
137
+ "phone": "Phone number already in use.",
138
+ "resend": "Please wait before requesting another code.",
139
+ "code": "Incorrect code. Please try again.",
140
+ "generic": "Something went wrong. Please try again.",
141
+ "firstname": "Please enter your first name.",
142
+ "register": "Registration failed. Try again.",
143
+ "google": "Could not connect to Google sign-in. Please try again."
116
144
  }
117
145
  },
118
146
  "blog": {
@@ -132,7 +160,17 @@
132
160
  "x": "Share on X",
133
161
  "facebook": "Share on Facebook",
134
162
  "whatsapp": "Share on WhatsApp",
135
- "copy": "Copy link"
163
+ "copy": "Copy link",
164
+ "copied": "Copied"
165
+ },
166
+ "related": {
167
+ "title": "Related posts"
168
+ },
169
+ "meta": {
170
+ "reading": {
171
+ "one": "{count} min read",
172
+ "other": "{count} min read"
173
+ }
136
174
  }
137
175
  }
138
176
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequeek/theme-kit",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "The framework a Queek storefront theme is built against.",