dh-remixer-sdk 0.0.29-dfab95e → 0.0.29-eafb9f2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dh-remixer-sdk",
3
- "version": "0.0.29-dfab95e",
3
+ "version": "0.0.29-eafb9f2",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "templates",
@@ -5,6 +5,7 @@
5
5
  "public/.htaccess",
6
6
  "public/robots.txt",
7
7
  ".gitignore",
8
+ "lib/supabase.ts",
8
9
  "tsconfig.json",
9
10
  "vite.config.ts",
10
11
  "new.package.json"
@@ -9,13 +9,17 @@
9
9
  "robots.txt": "public/robots.txt",
10
10
  ".htaccess": "public/.htaccess",
11
11
  "components/seo/SEOHead.tsx": "components/seo/SEOHead.tsx",
12
- "supabase.ts": "lib/supabase.ts",
13
12
  "remixer.ts": "lib/remixer.ts",
14
13
  "remixer/actions.ts": "lib/remixer/actions.ts",
14
+ "remixer/anonToken.ts": "lib/remixer/anonToken.ts",
15
15
  "remixer/auth.ts": "lib/remixer/auth.ts",
16
+ "remixer/context.ts": "lib/remixer/context.ts",
16
17
  "remixer/core.ts": "lib/remixer/core.ts",
17
18
  "remixer/data.ts": "lib/remixer/data.ts",
19
+ "remixer/dpop.ts": "lib/remixer/dpop.ts",
18
20
  "remixer/ecommerce.ts": "lib/remixer/ecommerce.ts",
21
+ "remixer/forms.ts": "lib/remixer/forms.ts",
19
22
  "remixer/runtime.ts": "lib/remixer/runtime.ts",
20
- "remixer/storage.ts": "lib/remixer/storage.ts"
23
+ "remixer/storage.ts": "lib/remixer/storage.ts",
24
+ "remixer/supabase.ts": "lib/remixer/supabase.ts"
21
25
  }
@@ -34,4 +34,4 @@ root.render(
34
34
  </LanguageProvider>
35
35
  </HelmetProvider>
36
36
  </React.StrictMode>
37
- );
37
+ );
@@ -11,18 +11,23 @@
11
11
  "remixer-sdk:update": "bun install dh-remixer-sdk@${REMIXER_SDK_TAG:-latest} && sdk-update"
12
12
  },
13
13
  "dependencies": {
14
+ "@internationalized/date": "3.10.0",
14
15
  "@openobserve/browser-logs": "0.3.1",
15
16
  "@openobserve/browser-rum-slim": "0.3.1",
16
- "@internationalized/date": "3.10.0",
17
+ "@react-three/drei": "10.0.6",
18
+ "@react-three/fiber": "9.5.0",
19
+ "@react-three/postprocessing": "3.0.4",
17
20
  "@supabase/supabase-js": "2.86.0",
18
21
  "date-fns": "4.1.0",
19
22
  "framer-motion": "12.23.24",
20
23
  "lucide-react": "0.554.0",
24
+ "postprocessing": "6.37.0",
21
25
  "react": "19.2.0",
22
26
  "react-aria-components": "1.11.0",
23
27
  "react-dom": "19.2.0",
24
28
  "react-helmet-async": "3.0.0",
25
- "react-router-dom": "7.9.6"
29
+ "react-router-dom": "7.9.6",
30
+ "three": "0.171.0"
26
31
  },
27
32
  "devDependencies": {
28
33
  "@types/node": "22.19.1",
@@ -0,0 +1,85 @@
1
+ import { getAuthBrokerBaseUrl, getProjectId } from './context';
2
+ import { getPublicJwk } from './dpop';
3
+
4
+ type CachedToken = {
5
+ token: string;
6
+ expiresAt: number;
7
+ };
8
+
9
+ let memoryToken: CachedToken | null = null;
10
+ let pendingToken: Promise<string> | null = null;
11
+
12
+ function storageKey() {
13
+ return `remixer:${getProjectId()}:anonymous-token`;
14
+ }
15
+
16
+ function isUsableToken(token: CachedToken | null) {
17
+ return Boolean(token?.token && token.expiresAt - Math.floor(Date.now() / 1000) > 5 * 60);
18
+ }
19
+
20
+ function readSessionToken() {
21
+ try {
22
+ const raw = sessionStorage.getItem(storageKey());
23
+ return raw ? JSON.parse(raw) as CachedToken : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function writeSessionToken(token: CachedToken) {
30
+ try {
31
+ sessionStorage.setItem(storageKey(), JSON.stringify(token));
32
+ } catch {
33
+ // Session storage can be disabled. In-memory cache still covers this page lifetime.
34
+ }
35
+ }
36
+
37
+ export function clearAnonymousToken() {
38
+ memoryToken = null;
39
+ pendingToken = null;
40
+ try {
41
+ sessionStorage.removeItem(storageKey());
42
+ } catch {
43
+ // Session storage can be disabled.
44
+ }
45
+ }
46
+
47
+ async function mintAnonymousToken() {
48
+ const publicJwk = await getPublicJwk();
49
+ const response = await fetch(`${getAuthBrokerBaseUrl()}/anonymous/bootstrap`, {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ body: JSON.stringify({ publicJwk }),
53
+ });
54
+
55
+ const body = await response.json().catch(() => ({}));
56
+ if (!response.ok) {
57
+ throw new Error(body.message || body.error || `Anonymous bootstrap failed (${response.status})`);
58
+ }
59
+
60
+ const cached = {
61
+ token: body.token,
62
+ expiresAt: body.expiresAt,
63
+ };
64
+ memoryToken = cached;
65
+ writeSessionToken(cached);
66
+ return cached.token;
67
+ }
68
+
69
+ export async function getAnonymousToken() {
70
+ if (isUsableToken(memoryToken)) return memoryToken!.token;
71
+
72
+ const sessionToken = readSessionToken();
73
+ if (isUsableToken(sessionToken)) {
74
+ memoryToken = sessionToken;
75
+ return sessionToken!.token;
76
+ }
77
+
78
+ if (!pendingToken) {
79
+ pendingToken = mintAnonymousToken().finally(() => {
80
+ pendingToken = null;
81
+ });
82
+ }
83
+
84
+ return pendingToken;
85
+ }
@@ -1,4 +1,40 @@
1
- import { getProjectSession, isValidProjectSession, supabase } from '../supabase';
1
+ import { getProjectSession, isValidProjectSession, supabase } from './supabase';
2
+ import { getAuthBrokerBaseUrl } from './context';
3
+
4
+ export type SignInWithGoogleOptions = {
5
+ returnTo?: string;
6
+ };
7
+
8
+ export type SignupMetadata = Record<string, unknown>;
9
+
10
+ async function brokerEmailAuth(path: 'signin' | 'signup', email: string, password: string, metadata?: SignupMetadata) {
11
+ const res = await fetch(`${getAuthBrokerBaseUrl()}/auth/email/${path}`, {
12
+ method: 'POST',
13
+ headers: { 'Content-Type': 'application/json' },
14
+ body: JSON.stringify({
15
+ email,
16
+ password,
17
+ metadata,
18
+ return_to: window.location.origin,
19
+ }),
20
+ });
21
+
22
+ if (!res.ok) {
23
+ const body = await res.json().catch(() => ({}));
24
+ throw new Error(body.error || `Email auth failed (${res.status})`);
25
+ }
26
+
27
+ const payload = await res.json();
28
+ if (payload.access_token && payload.refresh_token) {
29
+ const { error } = await supabase.auth.setSession({
30
+ access_token: payload.access_token,
31
+ refresh_token: payload.refresh_token,
32
+ });
33
+ if (error) throw error;
34
+ }
35
+
36
+ return payload;
37
+ }
2
38
 
3
39
  export const auth = {
4
40
  getSession: getProjectSession,
@@ -15,6 +51,36 @@ export const auth = {
15
51
  refresh_token: refreshToken,
16
52
  });
17
53
  },
54
+ signInWithGoogle(options: SignInWithGoogleOptions = {}) {
55
+ const startUrl = new URL(`${getAuthBrokerBaseUrl()}/auth/start`);
56
+ startUrl.searchParams.set('return_to', options.returnTo || `${window.location.origin}/account`);
57
+ startUrl.searchParams.set('nonce', crypto.randomUUID());
58
+ window.location.href = startUrl.toString();
59
+ },
60
+ signInWithEmail(email: string, password: string) {
61
+ return brokerEmailAuth('signin', email, password);
62
+ },
63
+ signUpWithEmail(email: string, password: string, metadata?: SignupMetadata) {
64
+ return brokerEmailAuth('signup', email, password, metadata);
65
+ },
66
+ async completeAuthExchange(exchangeCode: string) {
67
+ const res = await fetch(`${getAuthBrokerBaseUrl()}/auth/exchange`, {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/json' },
70
+ body: JSON.stringify({ exchange_code: exchangeCode }),
71
+ });
72
+
73
+ if (!res.ok) {
74
+ const body = await res.json().catch(() => ({}));
75
+ throw new Error(body.error || `Exchange failed (${res.status})`);
76
+ }
77
+
78
+ const payload = await res.json();
79
+ return supabase.auth.setSession({
80
+ access_token: payload.access_token,
81
+ refresh_token: payload.refresh_token,
82
+ });
83
+ },
18
84
  signOut() {
19
85
  return supabase.auth.signOut();
20
86
  },
@@ -0,0 +1,49 @@
1
+ export type RemixerContext = {
2
+ supabaseUrl: string;
3
+ supabaseAnonKey: string;
4
+ projectId: string;
5
+ runtimeUrl?: string;
6
+ authBrokerBaseUrl: string;
7
+ };
8
+
9
+ declare global {
10
+ interface Window {
11
+ __REMIXER_CTX__?: RemixerContext;
12
+ }
13
+ }
14
+
15
+ function requireContextValue<K extends keyof RemixerContext>(ctx: RemixerContext, key: K): RemixerContext[K] {
16
+ const value = ctx[key];
17
+ if (!value) {
18
+ throw new Error(`Remixer context is missing ${String(key)}`);
19
+ }
20
+ return value;
21
+ }
22
+
23
+ export function getRemixerContext() {
24
+ if (typeof window === 'undefined' || !window.__REMIXER_CTX__) {
25
+ throw new Error('Remixer context is not available');
26
+ }
27
+ return window.__REMIXER_CTX__;
28
+ }
29
+
30
+ export function getSupabaseUrl() {
31
+ return requireContextValue(getRemixerContext(), 'supabaseUrl');
32
+ }
33
+
34
+ export function getSupabaseAnonKey() {
35
+ return requireContextValue(getRemixerContext(), 'supabaseAnonKey');
36
+ }
37
+
38
+ export function getProjectId() {
39
+ return requireContextValue(getRemixerContext(), 'projectId');
40
+ }
41
+
42
+ export function getAuthBrokerBaseUrl() {
43
+ return requireContextValue(getRemixerContext(), 'authBrokerBaseUrl').replace(/\/+$/, '');
44
+ }
45
+
46
+ export function getRuntimeUrl() {
47
+ const ctx = getRemixerContext();
48
+ return (ctx.runtimeUrl || `${getSupabaseUrl().replace(/\/+$/, '')}/functions/v1`).replace(/\/+$/, '');
49
+ }
@@ -1,18 +1,62 @@
1
- import { authHeader } from '../supabase';
1
+ import { getProjectSession } from './supabase';
2
+ import { clearAnonymousToken, getAnonymousToken } from './anonToken';
3
+ import { getRuntimeUrl } from './context';
4
+ import { createDpopProof } from './dpop';
2
5
 
3
6
  export type RemixerRecordData = Record<string, unknown>;
4
7
 
5
- export async function runtimeDataFetch<T>(path: string, body: RemixerRecordData): Promise<T> {
6
- const headers = await authHeader();
7
- const response = await fetch(`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/${path}`, {
8
- method: 'POST',
8
+ type RuntimeAuthHeaders = {
9
+ headers: Record<string, string>;
10
+ tokenSource: 'anonymous' | 'session';
11
+ };
12
+
13
+ async function runtimeAuthHeaders(url: string, method: string): Promise<RuntimeAuthHeaders> {
14
+ const session = await getProjectSession();
15
+ const tokenSource = session?.access_token ? 'session' : 'anonymous';
16
+ const token = session?.access_token ?? await getAnonymousToken();
17
+
18
+ return {
19
+ headers: {
20
+ Authorization: `Bearer ${token}`,
21
+ DPoP: await createDpopProof(url, method),
22
+ },
23
+ tokenSource,
24
+ };
25
+ }
26
+
27
+ export async function runtimeHeaders(url: string, method: string) {
28
+ return (await runtimeAuthHeaders(url, method)).headers;
29
+ }
30
+
31
+ export async function runtimeJsonFetch(
32
+ url: string,
33
+ method: 'GET' | 'POST',
34
+ body?: RemixerRecordData,
35
+ retryOnUnauthorized = true,
36
+ ) {
37
+ const { headers, tokenSource } = await runtimeAuthHeaders(url, method);
38
+ const response = await fetch(url, {
39
+ method,
9
40
  headers: {
10
41
  ...headers,
11
42
  'Content-Type': 'application/json',
12
43
  },
13
- body: JSON.stringify(body),
44
+ body: body ? JSON.stringify(body) : undefined,
14
45
  });
15
46
 
47
+ if (response.status === 401 && retryOnUnauthorized && tokenSource === 'anonymous') {
48
+ clearAnonymousToken();
49
+ return runtimeJsonFetch(url, method, body, false);
50
+ }
51
+
52
+ return response;
53
+ }
54
+
55
+ export async function runtimeDataFetch<T>(path: string, body: RemixerRecordData): Promise<T> {
56
+ const url = `${getRuntimeUrl()}/${path}`;
57
+ const method = 'POST';
58
+ const response = await runtimeJsonFetch(url, method, body);
59
+
16
60
  if (!response.ok) {
17
61
  const error = await response.json().catch(() => ({}));
18
62
  throw new Error(error.message || error.error || `Remixer data request failed (${response.status})`);
@@ -25,20 +69,12 @@ export async function remixerFunctionFetch<T>(
25
69
  path: string,
26
70
  options: { method?: 'GET' | 'POST'; body?: RemixerRecordData; query?: Record<string, string | number | boolean | undefined> } = {},
27
71
  ): Promise<T> {
28
- const headers = await authHeader();
29
- const url = new URL(`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/${path}`);
72
+ const url = new URL(`${getRuntimeUrl()}/${path}`);
30
73
  Object.entries(options.query || {}).forEach(([key, value]) => {
31
74
  if (value !== undefined) url.searchParams.set(key, String(value));
32
75
  });
33
-
34
- const response = await fetch(url.toString(), {
35
- method: options.method || 'POST',
36
- headers: {
37
- ...headers,
38
- 'Content-Type': 'application/json',
39
- },
40
- body: options.body ? JSON.stringify(options.body) : undefined,
41
- });
76
+ const method = options.method || 'POST';
77
+ const response = await runtimeJsonFetch(url.toString(), method, options.body);
42
78
 
43
79
  if (!response.ok) {
44
80
  const error = await response.json().catch(() => ({}));
@@ -1,4 +1,4 @@
1
- import { supabase } from '../supabase';
1
+ import { supabase } from './supabase';
2
2
  import { runtimeDataFetch } from './core';
3
3
  import type { RemixerRecordData } from './core';
4
4
 
@@ -0,0 +1,128 @@
1
+ const DB_NAME = 'remixer-dpop';
2
+ const STORE_NAME = 'keys';
3
+ const KEY_ID = 'default';
4
+
5
+ type StoredKeyPair = {
6
+ privateKey: CryptoKey;
7
+ publicKey: CryptoKey;
8
+ };
9
+
10
+ let cachedKeyPair: Promise<StoredKeyPair> | null = null;
11
+ let cachedPublicJwk: Promise<JsonWebKey> | null = null;
12
+ let cachedThumbprint: Promise<string> | null = null;
13
+
14
+ function openDb(): Promise<IDBDatabase> {
15
+ return new Promise((resolve, reject) => {
16
+ const request = indexedDB.open(DB_NAME, 1);
17
+ request.onupgradeneeded = () => {
18
+ request.result.createObjectStore(STORE_NAME);
19
+ };
20
+ request.onsuccess = () => resolve(request.result);
21
+ request.onerror = () => reject(request.error);
22
+ });
23
+ }
24
+
25
+ async function idbGet<T>(key: string): Promise<T | undefined> {
26
+ const db = await openDb();
27
+ return new Promise((resolve, reject) => {
28
+ const tx = db.transaction(STORE_NAME, 'readonly');
29
+ const request = tx.objectStore(STORE_NAME).get(key);
30
+ request.onsuccess = () => resolve(request.result as T | undefined);
31
+ request.onerror = () => reject(request.error);
32
+ tx.oncomplete = () => db.close();
33
+ });
34
+ }
35
+
36
+ async function idbSet(key: string, value: unknown): Promise<void> {
37
+ const db = await openDb();
38
+ return new Promise((resolve, reject) => {
39
+ const tx = db.transaction(STORE_NAME, 'readwrite');
40
+ const request = tx.objectStore(STORE_NAME).put(value, key);
41
+ request.onsuccess = () => resolve();
42
+ request.onerror = () => reject(request.error);
43
+ tx.oncomplete = () => db.close();
44
+ });
45
+ }
46
+
47
+ function base64Url(bytes: ArrayBuffer | Uint8Array) {
48
+ let raw = '';
49
+ for (const byte of new Uint8Array(bytes)) raw += String.fromCharCode(byte);
50
+ return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
51
+ }
52
+
53
+ function canonicalizeJwk(jwk: JsonWebKey) {
54
+ return {
55
+ crv: jwk.crv,
56
+ kty: jwk.kty,
57
+ x: jwk.x,
58
+ y: jwk.y,
59
+ };
60
+ }
61
+
62
+ async function generateKeyPair() {
63
+ return crypto.subtle.generateKey(
64
+ { name: 'ECDSA', namedCurve: 'P-256' },
65
+ false,
66
+ ['sign', 'verify'],
67
+ ) as Promise<CryptoKeyPair>;
68
+ }
69
+
70
+ export async function getDpopKeyPair() {
71
+ if (!cachedKeyPair) {
72
+ cachedKeyPair = (async () => {
73
+ const stored = await idbGet<StoredKeyPair>(KEY_ID);
74
+ if (stored?.privateKey && stored?.publicKey) return stored;
75
+
76
+ const pair = await generateKeyPair();
77
+ const value = { privateKey: pair.privateKey, publicKey: pair.publicKey };
78
+ await idbSet(KEY_ID, value);
79
+ return value;
80
+ })();
81
+ }
82
+ return cachedKeyPair;
83
+ }
84
+
85
+ export async function getPublicJwk() {
86
+ if (!cachedPublicJwk) {
87
+ cachedPublicJwk = (async () => {
88
+ const { publicKey } = await getDpopKeyPair();
89
+ const jwk = await crypto.subtle.exportKey('jwk', publicKey);
90
+ return canonicalizeJwk(jwk);
91
+ })();
92
+ }
93
+ return cachedPublicJwk;
94
+ }
95
+
96
+ export async function getThumbprint() {
97
+ if (!cachedThumbprint) {
98
+ cachedThumbprint = (async () => {
99
+ const canonical = JSON.stringify(await getPublicJwk());
100
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical));
101
+ return base64Url(digest);
102
+ })();
103
+ }
104
+ return cachedThumbprint;
105
+ }
106
+
107
+ export async function createDpopProof(url: string, method: string) {
108
+ const { privateKey } = await getDpopKeyPair();
109
+ const publicJwk = await getPublicJwk();
110
+ const header = {
111
+ typ: 'dpop+jwt',
112
+ alg: 'ES256',
113
+ jwk: publicJwk,
114
+ };
115
+ const payload = {
116
+ htm: method.toUpperCase(),
117
+ htu: url,
118
+ iat: Math.floor(Date.now() / 1000),
119
+ jti: crypto.randomUUID(),
120
+ };
121
+ const signingInput = `${base64Url(new TextEncoder().encode(JSON.stringify(header)))}.${base64Url(new TextEncoder().encode(JSON.stringify(payload)))}`;
122
+ const signature = await crypto.subtle.sign(
123
+ { name: 'ECDSA', hash: 'SHA-256' },
124
+ privateKey,
125
+ new TextEncoder().encode(signingInput),
126
+ );
127
+ return `${signingInput}.${base64Url(signature)}`;
128
+ }
@@ -81,9 +81,28 @@ export type RemixerStripeSubscription = {
81
81
  created_at: string | null;
82
82
  };
83
83
 
84
+ export type RemixerParcel = {
85
+ length: string;
86
+ width: string;
87
+ height: string;
88
+ distanceUnit: 'in' | 'cm';
89
+ weight: string;
90
+ massUnit: 'lb' | 'kg';
91
+ };
92
+
93
+ export type RemixerShippingLineItem = {
94
+ quantity: number;
95
+ totalPrice: string;
96
+ currency: string;
97
+ weight: string;
98
+ weightUnit: 'lb' | 'kg' | 'oz' | 'g';
99
+ title?: string;
100
+ sku?: string;
101
+ };
102
+
84
103
  export type RemixerShippingRate = {
85
104
  object_id: string;
86
- amount: string | number;
105
+ amount: string;
87
106
  currency: string;
88
107
  provider: string;
89
108
  provider_image_75?: string;
@@ -94,10 +113,10 @@ export type RemixerShippingRate = {
94
113
  };
95
114
 
96
115
  export type RemixerShippingRatesParams = {
97
- address_from?: Record<string, unknown>;
98
- address_to: RemixerShippingAddress;
99
- parcel?: Record<string, unknown>;
100
- line_items?: Record<string, unknown>[];
116
+ addressTo: RemixerShippingAddress;
117
+ addressFrom?: RemixerShippingAddress;
118
+ parcel?: RemixerParcel;
119
+ lineItems?: RemixerShippingLineItem[];
101
120
  };
102
121
 
103
122
  export type RemixerShippingRatesResponse = {
@@ -134,6 +153,29 @@ export type RemixerTrackingResponse = {
134
153
  eta?: string | null;
135
154
  };
136
155
 
156
+ function mapParcel(parcel: RemixerParcel) {
157
+ return {
158
+ length: parcel.length,
159
+ width: parcel.width,
160
+ height: parcel.height,
161
+ distance_unit: parcel.distanceUnit,
162
+ weight: parcel.weight,
163
+ mass_unit: parcel.massUnit,
164
+ };
165
+ }
166
+
167
+ function mapLineItem(item: RemixerShippingLineItem) {
168
+ return {
169
+ quantity: item.quantity,
170
+ total_price: item.totalPrice,
171
+ currency: item.currency,
172
+ weight: item.weight,
173
+ weight_unit: item.weightUnit,
174
+ title: item.title,
175
+ sku: item.sku,
176
+ };
177
+ }
178
+
137
179
  export const ecommerce = {
138
180
  createCheckoutSession(params: RemixerCheckoutParams) {
139
181
  return runtimeDataFetch<RemixerCheckoutSession>('remixer-runtime/ecommerce/checkout-session', {
@@ -162,7 +204,12 @@ export const ecommerce = {
162
204
 
163
205
  shipping: {
164
206
  getRates(params: RemixerShippingRatesParams) {
165
- return runtimeDataFetch<RemixerShippingRatesResponse>('remixer-runtime/shipping/rates', params as unknown as RemixerRecordData);
207
+ return runtimeDataFetch<RemixerShippingRatesResponse>('remixer-runtime/shipping/rates', {
208
+ address_to: params.addressTo,
209
+ address_from: params.addressFrom,
210
+ parcel: params.parcel ? mapParcel(params.parcel) : undefined,
211
+ line_items: params.lineItems ? params.lineItems.map(mapLineItem) : undefined,
212
+ });
166
213
  },
167
214
 
168
215
  validateAddress(address: RemixerShippingAddress) {
@@ -0,0 +1,12 @@
1
+ import { remixerFunctionFetch } from './core';
2
+
3
+ export type RemixerFormPayload = Record<string, unknown>;
4
+
5
+ export const forms = {
6
+ submit<T = unknown>(payload: RemixerFormPayload): Promise<T> {
7
+ return remixerFunctionFetch<T>('remixer-runtime/forms/submit', {
8
+ method: 'POST',
9
+ body: payload,
10
+ });
11
+ },
12
+ };
@@ -1,4 +1,5 @@
1
- import { authHeader } from '../supabase';
1
+ import { getRuntimeUrl } from './context';
2
+ import { runtimeJsonFetch } from './core';
2
3
 
3
4
  type RemixerActionPayload = Record<string, unknown>;
4
5
 
@@ -53,15 +54,9 @@ async function sha256Hex(file: File): Promise<string> {
53
54
  }
54
55
 
55
56
  async function runtimeFetch<T>(path: string, body: RemixerActionPayload): Promise<T> {
56
- const headers = await authHeader();
57
- const response = await fetch(`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/${path}`, {
58
- method: 'POST',
59
- headers: {
60
- ...headers,
61
- 'Content-Type': 'application/json',
62
- },
63
- body: JSON.stringify(body),
64
- });
57
+ const url = `${getRuntimeUrl()}/${path}`;
58
+ const method = 'POST';
59
+ const response = await runtimeJsonFetch(url, method, body);
65
60
 
66
61
  if (!response.ok) {
67
62
  const error = await response.json().catch(() => ({}));
@@ -0,0 +1,49 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ import type { Session } from '@supabase/supabase-js';
3
+ import { getAnonymousToken } from './anonToken';
4
+ import { getProjectId, getSupabaseAnonKey, getSupabaseUrl } from './context';
5
+
6
+ /**
7
+ * User-scoped Supabase client for auth session state and realtime channels.
8
+ * Data reads/writes should go through remixer.data so runtime enforcement
9
+ * stays centralized.
10
+ */
11
+ export const supabase = createClient(
12
+ getSupabaseUrl(),
13
+ getSupabaseAnonKey(),
14
+ );
15
+
16
+ export function isValidProjectSession(session: Session | null) {
17
+ if (!session) return true;
18
+ const sessionProjectId = session?.user?.app_metadata?.project_id;
19
+ return sessionProjectId === getProjectId();
20
+ }
21
+
22
+ export async function getProjectSession() {
23
+ const {
24
+ data: { session },
25
+ } = await supabase.auth.getSession();
26
+
27
+ if (!isValidProjectSession(session)) {
28
+ await supabase.auth.signOut({ scope: 'local' });
29
+ return null;
30
+ }
31
+
32
+ return session;
33
+ }
34
+
35
+ /**
36
+ * authHeader() — helper for manual fetch() calls (Edge Functions/REST)
37
+ * • Returns `{ Authorization: 'Bearer <token>' }` where `<token>` is:
38
+ * - the logged-in user's JWT (if session exists), or
39
+ * - a short-lived anonymous project token minted by the auth broker
40
+ *
41
+ * project_id is stamped into app_metadata by the auth broker at login
42
+ * time — no client-side updateUser() needed.
43
+ */
44
+ export async function authHeader() {
45
+ const session = await getProjectSession();
46
+ const token = session?.access_token ?? await getAnonymousToken();
47
+
48
+ return { Authorization: `Bearer ${token}` };
49
+ }
@@ -2,6 +2,7 @@ import { actions } from './remixer/actions';
2
2
  import { auth } from './remixer/auth';
3
3
  import { data } from './remixer/data';
4
4
  import { ecommerce } from './remixer/ecommerce';
5
+ import { forms } from './remixer/forms';
5
6
  import { storage } from './remixer/storage';
6
7
 
7
8
  export type { RemixerRecordData } from './remixer/core';
@@ -26,7 +27,9 @@ export type {
26
27
  RemixerCheckoutItem,
27
28
  RemixerCheckoutParams,
28
29
  RemixerCheckoutSession,
30
+ RemixerParcel,
29
31
  RemixerShippingAddress,
32
+ RemixerShippingLineItem,
30
33
  RemixerShippingRate,
31
34
  RemixerShippingRatesParams,
32
35
  RemixerShippingRatesResponse,
@@ -43,6 +46,7 @@ export const remixer = {
43
46
  auth,
44
47
  data,
45
48
  ecommerce,
49
+ forms,
46
50
  storage,
47
51
  };
48
52
 
@@ -17,7 +17,7 @@ function injectRemixerBadgePlugin(): Plugin {
17
17
 
18
18
  transformIndexHtml(html) {
19
19
  try {
20
- const IS_SHOW_REMIXER_BADGE: boolean = process.env.IS_SHOW_REMIXER_BADGE as boolean | undefined || false;
20
+ const IS_SHOW_REMIXER_BADGE = process.env.IS_SHOW_REMIXER_BADGE === 'true';
21
21
  const REMIXER_BADGE_TYPE: string = process.env.REMIXER_BADGE_TYPE || 'DEFAULT';
22
22
 
23
23
  const domain = process.env.NEXT_PUBLIC_DOMAIN || '';
@@ -1,5 +1 @@
1
- {
2
- "stripe-checkout.ts": "lib/stripe-checkout.ts",
3
- "supabase.ts": "lib/supabase.ts",
4
- "shipping.ts": "lib/shipping.ts"
5
- }
1
+ {}
@@ -95,36 +95,32 @@ export function ImmersiveStory<K extends string>({
95
95
 
96
96
  const activeOverlay = activeBeat ? beats[activeBeat]?.overlay : null;
97
97
 
98
- return (
99
- <>
100
- <Stage3D
101
- palette={palette}
102
- environment={environment}
103
- camera={camera}
104
- effects={effects}
105
- shadows={shadows}
106
- >
107
- <ScrollStory beats={beatHeights}>{scene}</ScrollStory>
108
- </Stage3D>
109
- {activeBeat && activeOverlay ? (
110
- <div
111
- className="fixed inset-0 z-20 pointer-events-none"
112
- aria-hidden="false"
98
+ const overlayNode =
99
+ activeBeat && activeOverlay ? (
100
+ <AnimatePresence mode="wait">
101
+ <motion.div
102
+ key={activeBeat}
103
+ className="absolute inset-0"
104
+ initial={{ opacity: 0 }}
105
+ animate={{ opacity: 1 }}
106
+ exit={{ opacity: 0 }}
107
+ transition={overlayTransition}
113
108
  >
114
- <AnimatePresence mode="wait">
115
- <motion.div
116
- key={activeBeat}
117
- className="absolute inset-0"
118
- initial={{ opacity: 0 }}
119
- animate={{ opacity: 1 }}
120
- exit={{ opacity: 0 }}
121
- transition={overlayTransition}
122
- >
123
- {activeOverlay}
124
- </motion.div>
125
- </AnimatePresence>
126
- </div>
127
- ) : null}
128
- </>
109
+ {activeOverlay}
110
+ </motion.div>
111
+ </AnimatePresence>
112
+ ) : null;
113
+
114
+ return (
115
+ <Stage3D
116
+ palette={palette}
117
+ environment={environment}
118
+ camera={camera}
119
+ effects={effects}
120
+ shadows={shadows}
121
+ overlay={overlayNode}
122
+ >
123
+ <ScrollStory beats={beatHeights}>{scene}</ScrollStory>
124
+ </Stage3D>
129
125
  );
130
126
  }
@@ -39,6 +39,10 @@ interface Stage3DProps {
39
39
  maxDprDesktop?: number;
40
40
  maxDprMobile?: number;
41
41
  className?: string;
42
+ // DOM laid over the canvas inside the same sticky viewport-locked layer.
43
+ // Use this instead of a sibling `position: fixed` overlay — the canvas and
44
+ // overlay share the story section's bounds and cannot paint outside it.
45
+ overlay?: ReactNode;
42
46
  children: ReactNode;
43
47
  }
44
48
 
@@ -64,7 +68,10 @@ function isAnchorActive(reg: AnchorRegistration): boolean {
64
68
  return threshold(w);
65
69
  }
66
70
 
67
- // One Canvas per page. Mounts lazily only while at least one anchor is active.
71
+ // One Canvas per immersive section. Canvas + overlay live inside a sticky
72
+ // viewport-locked layer scoped to the section's DOM, so they cannot paint
73
+ // over content that follows. Canvas mounts lazily only while an anchor is
74
+ // active.
68
75
  export function Stage3D({
69
76
  palette,
70
77
  fallback = null,
@@ -75,6 +82,7 @@ export function Stage3D({
75
82
  maxDprDesktop = 2,
76
83
  maxDprMobile = 1.5,
77
84
  className,
85
+ overlay,
78
86
  children,
79
87
  }: Stage3DProps) {
80
88
  const [anchors, setAnchors] = useState<AnchorRegistration[]>([]);
@@ -154,41 +162,49 @@ export function Stage3D({
154
162
 
155
163
  return (
156
164
  <StageContext.Provider value={contextValue}>
157
- {shouldMountCanvas ? (
165
+ <section
166
+ className={['relative grid', className].filter(Boolean).join(' ')}
167
+ style={{ gridTemplateColumns: '1fr', gridTemplateRows: 'auto' }}
168
+ >
158
169
  <div
159
- className={['fixed inset-0 z-0 pointer-events-none', className]
160
- .filter(Boolean)
161
- .join(' ')}
162
- aria-hidden="true"
170
+ className="sticky top-0 h-screen overflow-hidden pointer-events-none"
171
+ style={{ gridArea: '1 / 1' }}
163
172
  >
164
- <PrerenderGate fallback={fallback} className="absolute inset-0">
165
- <CanvasErrorBoundary fallback={fallback}>
166
- <Canvas
167
- dpr={[1, maxDpr]}
168
- shadows={shadows}
169
- camera={{
170
- position: camera?.position ?? [0, 0, 5],
171
- fov: camera?.fov ?? 42,
172
- }}
173
- gl={{ antialias: true, powerPreference: 'high-performance' }}
174
- >
175
- <Suspense fallback={null}>
176
- <Atmosphere palette={palette} />
177
- <SceneLighting palette={palette} />
178
- {environment && (
179
- <Environment preset={environment as any} />
180
- )}
181
- <AnchorsRenderer anchors={anchors} />
182
- </Suspense>
183
- {effectNodes.length > 0 ? (
184
- <EffectComposer>{effectNodes}</EffectComposer>
185
- ) : null}
186
- </Canvas>
187
- </CanvasErrorBoundary>
188
- </PrerenderGate>
173
+ {shouldMountCanvas ? (
174
+ <div className="absolute inset-0" aria-hidden="true">
175
+ <PrerenderGate fallback={fallback} className="absolute inset-0">
176
+ <CanvasErrorBoundary fallback={fallback}>
177
+ <Canvas
178
+ dpr={[1, maxDpr]}
179
+ shadows={shadows}
180
+ camera={{
181
+ position: camera?.position ?? [0, 0, 5],
182
+ fov: camera?.fov ?? 42,
183
+ }}
184
+ gl={{ antialias: true, powerPreference: 'high-performance' }}
185
+ >
186
+ <Suspense fallback={null}>
187
+ <Atmosphere palette={palette} />
188
+ <SceneLighting palette={palette} />
189
+ {environment && (
190
+ <Environment preset={environment as any} />
191
+ )}
192
+ <AnchorsRenderer anchors={anchors} />
193
+ </Suspense>
194
+ {effectNodes.length > 0 ? (
195
+ <EffectComposer>{effectNodes}</EffectComposer>
196
+ ) : null}
197
+ </Canvas>
198
+ </CanvasErrorBoundary>
199
+ </PrerenderGate>
200
+ </div>
201
+ ) : null}
202
+ {overlay ? (
203
+ <div className="absolute inset-0">{overlay}</div>
204
+ ) : null}
189
205
  </div>
190
- ) : null}
191
- {children}
206
+ <div style={{ gridArea: '1 / 1' }}>{children}</div>
207
+ </section>
192
208
  </StageContext.Provider>
193
209
  );
194
210
  }
@@ -2,12 +2,5 @@
2
2
  "name": "remixer-immersive",
3
3
  "remixerMetadata": {
4
4
  "template": "immersive"
5
- },
6
- "dependencies": {
7
- "three": "0.171.0",
8
- "@react-three/fiber": "9.5.0",
9
- "@react-three/drei": "10.0.6",
10
- "@react-three/postprocessing": "3.0.4",
11
- "postprocessing": "6.37.0"
12
5
  }
13
6
  }
@@ -1,73 +0,0 @@
1
-
2
- import { createClient } from '@supabase/supabase-js';
3
- import type { Session } from '@supabase/supabase-js';
4
-
5
- /**
6
- * 1) supabase — user-scoped client
7
- * • Before login: uses the project access token
8
- * • After login: automatically swaps to the user's JWT
9
- */
10
- export const supabase = createClient(
11
- import.meta.env.VITE_SUPABASE_URL!,
12
- import.meta.env.VITE_SUPABASE_ANON_KEY!,
13
- );
14
-
15
- /**
16
- * 2) supabaseProject — tenant-wide client
17
- * • Always uses the project access token
18
- * • For public or calendar-style views where you want every project's data
19
- */
20
- export const supabaseProject = createClient(
21
- import.meta.env.VITE_SUPABASE_URL!,
22
- import.meta.env.VITE_SUPABASE_ANON_KEY!,
23
- {
24
- global: {
25
- headers: {
26
- Authorization: `Bearer ${import.meta.env.VITE_PROJECT_ACCESS_TOKEN}`,
27
- },
28
- },
29
- auth: {
30
- // disable session persistence so it never picks up a user JWT
31
- storageKey: 'sb-project-client',
32
- detectSessionInUrl: false,
33
- persistSession: false,
34
- autoRefreshToken: false,
35
- },
36
- }
37
- );
38
-
39
- export function isValidProjectSession(session: Session | null) {
40
- if (!session) return true;
41
- const sessionProjectId = session?.user?.app_metadata?.project_id;
42
- return sessionProjectId === import.meta.env.VITE_PROJECT_ID;
43
- }
44
-
45
- export async function getProjectSession() {
46
- const {
47
- data: { session },
48
- } = await supabase.auth.getSession();
49
-
50
- if (!isValidProjectSession(session)) {
51
- await supabase.auth.signOut({ scope: 'local' });
52
- return null;
53
- }
54
-
55
- return session;
56
- }
57
-
58
- /**
59
- * authHeader() — helper for manual fetch() calls (Edge Functions/REST)
60
- * • Returns `{ Authorization: 'Bearer <token>' }` where `<token>` is:
61
- * - the logged-in user's JWT (if session exists), or
62
- * - the project access token (anonymous)
63
- *
64
- * project_id is stamped into app_metadata by the auth broker at login
65
- * time — no client-side updateUser() needed.
66
- */
67
- export async function authHeader() {
68
- const session = await getProjectSession();
69
- const token =
70
- session?.access_token ?? import.meta.env.VITE_PROJECT_ACCESS_TOKEN;
71
-
72
- return { Authorization: `Bearer ${token}` };
73
- }
@@ -1,68 +0,0 @@
1
- import { remixer } from "@/lib/remixer";
2
- import type { RemixerShippingAddress } from "@/lib/remixer";
3
-
4
- export type ShippingAddress = RemixerShippingAddress;
5
-
6
- export interface Parcel {
7
- length: string;
8
- width: string;
9
- height: string;
10
- distance_unit: "in" | "cm";
11
- weight: string;
12
- mass_unit: "lb" | "kg";
13
- }
14
-
15
- export interface ShippingLineItem {
16
- quantity: number;
17
- total_price: string;
18
- currency: string;
19
- weight: string;
20
- weight_unit: "lb" | "kg" | "oz" | "g";
21
- title?: string;
22
- sku?: string;
23
- }
24
-
25
- export interface ShippingRate {
26
- object_id: string;
27
- amount: string;
28
- currency: string;
29
- provider: string;
30
- provider_image_75: string;
31
- servicelevel_name: string;
32
- servicelevel_token: string;
33
- estimated_days: number;
34
- duration_terms: string;
35
- }
36
-
37
- export interface ShippingRatesResponse {
38
- rates: ShippingRate[];
39
- shipment_id: string;
40
- }
41
-
42
- export interface AddressValidationResponse {
43
- is_valid: boolean;
44
- messages: { source: string; code: string; type: string; text: string }[];
45
- }
46
-
47
- export async function getShippingRates(
48
- addressTo: ShippingAddress,
49
- options: {
50
- lineItems?: ShippingLineItem[];
51
- parcel?: Parcel;
52
- addressFrom?: ShippingAddress;
53
- }
54
- ): Promise<ShippingRatesResponse> {
55
- const response = await remixer.ecommerce.shipping.getRates({
56
- address_to: addressTo,
57
- address_from: options.addressFrom as unknown as Record<string, unknown> | undefined,
58
- parcel: options.parcel as unknown as Record<string, unknown> | undefined,
59
- line_items: options.lineItems as unknown as Record<string, unknown>[] | undefined,
60
- });
61
- return response as ShippingRatesResponse;
62
- }
63
-
64
- export async function validateAddress(
65
- address: ShippingAddress
66
- ): Promise<AddressValidationResponse> {
67
- return remixer.ecommerce.shipping.validateAddress(address) as Promise<AddressValidationResponse>;
68
- }
@@ -1,41 +0,0 @@
1
- import { remixer } from "@/lib/remixer";
2
- import type { RemixerCheckoutParams, RemixerCheckoutSession, RemixerShippingAddress } from "@/lib/remixer";
3
-
4
- export interface CreateCheckoutSessionParams {
5
- items: { priceId: string; quantity: number }[];
6
- mode: "payment" | "subscription";
7
- successUrl: string;
8
- cancelUrl: string;
9
- guestEmail?: string;
10
- shippingRateId?: string;
11
- shippingCost?: number;
12
- shippingLabel?: string;
13
- shippingAddress?: RemixerShippingAddress;
14
- }
15
-
16
- export type CheckoutSessionResponse = RemixerCheckoutSession;
17
-
18
- export const createCheckoutSession = async (
19
- params: CreateCheckoutSessionParams
20
- ): Promise<CheckoutSessionResponse> => {
21
- const user = await remixer.auth.getUser();
22
- const payload: RemixerCheckoutParams = {
23
- items: params.items,
24
- mode: params.mode,
25
- successUrl: params.successUrl,
26
- cancelUrl: params.cancelUrl,
27
- };
28
-
29
- if (!user && params.guestEmail) {
30
- payload.guestEmail = params.guestEmail;
31
- }
32
-
33
- if (params.shippingRateId && params.mode !== 'subscription') {
34
- payload.shippingRateId = params.shippingRateId;
35
- payload.shippingCost = params.shippingCost;
36
- payload.shippingLabel = params.shippingLabel;
37
- payload.shippingAddress = params.shippingAddress;
38
- }
39
-
40
- return remixer.ecommerce.createCheckoutSession(payload);
41
- };
@@ -1,73 +0,0 @@
1
-
2
- import { createClient } from '@supabase/supabase-js';
3
- import type { Session } from '@supabase/supabase-js';
4
-
5
- /**
6
- * 1) supabase — user-scoped client
7
- * • Before login: uses the project access token
8
- * • After login: automatically swaps to the user's JWT
9
- */
10
- export const supabase = createClient(
11
- import.meta.env.VITE_SUPABASE_URL!,
12
- import.meta.env.VITE_SUPABASE_ANON_KEY!,
13
- );
14
-
15
- /**
16
- * 2) supabaseProject — tenant-wide client
17
- * • Always uses the project access token
18
- * • For public or calendar-style views where you want every project's data
19
- */
20
- export const supabaseProject = createClient(
21
- import.meta.env.VITE_SUPABASE_URL!,
22
- import.meta.env.VITE_SUPABASE_ANON_KEY!,
23
- {
24
- global: {
25
- headers: {
26
- Authorization: `Bearer ${import.meta.env.VITE_PROJECT_ACCESS_TOKEN}`,
27
- },
28
- },
29
- auth: {
30
- // disable session persistence so it never picks up a user JWT
31
- storageKey: 'sb-project-client',
32
- detectSessionInUrl: false,
33
- persistSession: false,
34
- autoRefreshToken: false,
35
- },
36
- }
37
- );
38
-
39
- export function isValidProjectSession(session: Session | null) {
40
- if (!session) return true;
41
- const sessionProjectId = session?.user?.app_metadata?.project_id;
42
- return sessionProjectId === import.meta.env.VITE_PROJECT_ID;
43
- }
44
-
45
- export async function getProjectSession() {
46
- const {
47
- data: { session },
48
- } = await supabase.auth.getSession();
49
-
50
- if (!isValidProjectSession(session)) {
51
- await supabase.auth.signOut({ scope: 'local' });
52
- return null;
53
- }
54
-
55
- return session;
56
- }
57
-
58
- /**
59
- * authHeader() — helper for manual fetch() calls (Edge Functions/REST)
60
- * • Returns `{ Authorization: 'Bearer <token>' }` where `<token>` is:
61
- * - the logged-in user's JWT (if session exists), or
62
- * - the project access token (anonymous)
63
- *
64
- * project_id is stamped into app_metadata by the auth broker at login
65
- * time — no client-side updateUser() needed.
66
- */
67
- export async function authHeader() {
68
- const session = await getProjectSession();
69
- const token =
70
- session?.access_token ?? import.meta.env.VITE_PROJECT_ACCESS_TOKEN;
71
-
72
- return { Authorization: `Bearer ${token}` };
73
- }