diff --git a/.gitignore b/.gitignore index 700225f..3851ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,4 @@ android/.gradle/ postgres-data/ *.log -.idea/ -.env -.env.* -!.env.example +.idea/ \ No newline at end of file diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index aa2a03d..123dcee 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { AccountModule } from './account/account.module'; import { NotificationsModule } from './notifications/notifications.module'; +import { ProductsModule } from './products/products.module'; import { SupabaseModule } from './supabase/supabase.module'; @Module({ - imports: [SupabaseModule, AccountModule, NotificationsModule], + imports: [SupabaseModule, AccountModule, NotificationsModule, ProductsModule], }) export class AppModule {} diff --git a/apps/api/src/app.setup.ts b/apps/api/src/app.setup.ts index 8d666b9..d7acc22 100644 --- a/apps/api/src/app.setup.ts +++ b/apps/api/src/app.setup.ts @@ -1,6 +1,10 @@ import { INestApplication, ValidationPipe } from '@nestjs/common'; export function configureApp(app: INestApplication) { + app.enableCors({ + origin: true, + credentials: true, + }); app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }), ); diff --git a/apps/api/src/products/products.controller.ts b/apps/api/src/products/products.controller.ts new file mode 100644 index 0000000..13081bc --- /dev/null +++ b/apps/api/src/products/products.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { SupabaseAuthGuard } from '../auth/supabase-auth.guard'; +import { ProductsService } from './products.service'; + +@Controller('products') +@UseGuards(SupabaseAuthGuard) +export class ProductsController { + constructor(private readonly products: ProductsService) {} + + @Get() + list() { + return this.products.listWithStock(); + } + + @Get(':id/stock') + stock(@Param('id') id: string) { + return this.products.getStock(id); + } +} diff --git a/apps/api/src/products/products.module.ts b/apps/api/src/products/products.module.ts new file mode 100644 index 0000000..432324b --- /dev/null +++ b/apps/api/src/products/products.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SupabaseAuthGuard } from '../auth/supabase-auth.guard'; +import { SupabaseModule } from '../supabase/supabase.module'; +import { ProductsController } from './products.controller'; +import { ProductsService } from './products.service'; + +@Module({ + imports: [SupabaseModule], + controllers: [ProductsController], + providers: [ProductsService, SupabaseAuthGuard], +}) +export class ProductsModule {} diff --git a/apps/api/src/products/products.service.spec.ts b/apps/api/src/products/products.service.spec.ts new file mode 100644 index 0000000..68f51d1 --- /dev/null +++ b/apps/api/src/products/products.service.spec.ts @@ -0,0 +1,75 @@ +import { ProductsService } from './products.service'; + +describe('ProductsService', () => { + it('preserves Shopify decimal-string prices', async () => { + const from = jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + order: jest.fn().mockResolvedValue({ + data: [ + { + slug: 'poster', + name: 'Poster', + store: 'Shop of Magic', + shopify_url: 'https://example.com/product.js', + }, + ], + error: null, + }), + }), + }); + const service = new ProductsService({ admin: { from } } as never); + const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + available: true, + featured_image: null, + price: '29.99', + variants: [], + }), + { status: 200 }, + ), + ); + + await expect(service.listWithStock()).resolves.toEqual([ + expect.objectContaining({ price: '$29.99' }), + ]); + + fetchMock.mockRestore(); + }); + + it('includes the price when the Shopify variant is out of stock', async () => { + const from = jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ + order: jest.fn().mockResolvedValue({ + data: [ + { + slug: 'poster', + name: 'Poster', + store: 'Shop of Magic', + shopify_url: 'https://example.com/product.js', + }, + ], + error: null, + }), + }), + }); + const service = new ProductsService({ admin: { from } } as never); + const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + available: false, + featured_image: null, + price: '29.99', + variants: [{ id: 1, available: false, price: '29.99' }], + }), + { status: 200 }, + ), + ); + + await expect(service.listWithStock()).resolves.toEqual([ + expect.objectContaining({ available: false, price: '$29.99' }), + ]); + + fetchMock.mockRestore(); + }); +}); diff --git a/apps/api/src/products/products.service.ts b/apps/api/src/products/products.service.ts new file mode 100644 index 0000000..68ed99f --- /dev/null +++ b/apps/api/src/products/products.service.ts @@ -0,0 +1,116 @@ +import { + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { SupabaseService } from '../supabase/supabase.service'; +import type { ShopifyProductResponse } from './shopify-product.types'; + +type ProductRow = { + slug: string; + name: string; + store: string; + shopify_url: string; +}; + +type TrackedProduct = { + id: string; + name: string; + store: string; + shopifyUrl: string; +}; + +export type ProductStockDto = { + id: string; + name: string; + store: string; + price: string; + available: boolean; + imageUrl: string | null; + checkedAt: string; +}; + +const FETCH_TIMEOUT_MS = 8_000; + +function formatPrice(value: string | number): string { + const amount = typeof value === 'string' ? Number.parseFloat(value) : value; + return Number.isFinite(amount) ? `$${amount.toFixed(2)}` : ''; +} + +function normalizeImageUrl(src: string | null): string | null { + if (!src) return null; + return src.startsWith('//') ? `https:${src}` : src; +} + +@Injectable() +export class ProductsService { + private readonly logger = new Logger(ProductsService.name); + + constructor(private readonly supabase: SupabaseService) {} + + private get db() { + return this.supabase.admin; + } + + async listWithStock(): Promise { + const tracked = await this.listTrackedProducts(); + return Promise.all(tracked.map((p) => this.fetchStock(p))); + } + + async getStock(slug: string): Promise { + const tracked = await this.listTrackedProducts(); + const product = tracked.find((p) => p.id === slug); + if (!product) throw new NotFoundException(); + return this.fetchStock(product); + } + + private async listTrackedProducts(): Promise { + const { data, error } = await this.db + .from('products') + .select('slug, name, store, shopify_url') + .order('created_at', { ascending: true }); + + if (error) throw new InternalServerErrorException(); + + return (data as ProductRow[]).map((row) => ({ + id: row.slug, + name: row.name, + store: row.store, + shopifyUrl: row.shopify_url, + })); + } + + private async fetchStock(product: TrackedProduct): Promise { + try { + const res = await fetch(product.shopifyUrl, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!res.ok) throw new Error(`Shopify responded ${res.status}`); + + const data = (await res.json()) as ShopifyProductResponse; + const variant = data.variants?.[0]; + + return { + id: product.id, + name: product.name, + store: product.store, + price: formatPrice(variant?.price ?? data.price), + available: variant ? variant.available : data.available, + imageUrl: normalizeImageUrl(data.featured_image), + checkedAt: new Date().toISOString(), + }; + } catch (error) { + this.logger.warn(`Stock check failed for ${product.id}: ${error}`); + return { + id: product.id, + name: product.name, + store: product.store, + price: '', + available: false, + imageUrl: null, + checkedAt: new Date().toISOString(), + }; + } + } +} diff --git a/apps/api/src/products/shopify-product.types.ts b/apps/api/src/products/shopify-product.types.ts new file mode 100644 index 0000000..5059cd4 --- /dev/null +++ b/apps/api/src/products/shopify-product.types.ts @@ -0,0 +1,14 @@ +export type ShopifyVariant = { + id: number; + available: boolean; + price: string | number; +}; + +export type ShopifyProductResponse = { + id: number; + title: string; + available: boolean; + price: string | number; + featured_image: string | null; + variants: ShopifyVariant[]; +}; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index aba29b0..02a649b 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -13,7 +13,6 @@ "target": "ES2023", "sourceMap": true, "outDir": "./dist", - "baseUrl": "./", "incremental": true, "skipLibCheck": true, "strictNullChecks": true, diff --git a/apps/mobile/__tests__/home-screen.test.tsx b/apps/mobile/__tests__/home-screen.test.tsx index 2f3833f..d271429 100644 --- a/apps/mobile/__tests__/home-screen.test.tsx +++ b/apps/mobile/__tests__/home-screen.test.tsx @@ -8,9 +8,35 @@ jest.mock("../features/products/ProductCard", () => ({ }, })); -import { fireEvent, render } from "@testing-library/react-native"; +import { fireEvent, render, waitFor } from "@testing-library/react-native"; import { Text as MockText } from "react-native"; +jest.mock("../features/api/productsClient", () => ({ + loadProducts: jest.fn().mockResolvedValue({ + ok: true, + data: [ + { + id: "popular-1", + name: "Popular Product 1", + store: "Amazon", + price: "$19.99", + available: true, + imageUrl: null, + checkedAt: "2026-11-08T00:00:00.000Z", + }, + { + id: "popular-2", + name: "Popular Product 2", + store: "Target", + price: "$49.99", + available: false, + imageUrl: null, + checkedAt: "2026-11-08T00:00:00.000Z", + }, + ], + }), +})); + import { HomeScreen } from "../features/home/HomeScreen"; describe("HomeScreen", () => { @@ -19,7 +45,9 @@ describe("HomeScreen", () => { , ); - expect(screen.getAllByTestId("shared-product-card")).toHaveLength(4); + await waitFor(() => + expect(screen.getAllByTestId("shared-product-card")).toHaveLength(4), + ); }); it("opens Watched Items from the homepage section", async () => { diff --git a/apps/mobile/__tests__/popular-products-screen.test.tsx b/apps/mobile/__tests__/popular-products-screen.test.tsx index e107d3c..e4389a4 100644 --- a/apps/mobile/__tests__/popular-products-screen.test.tsx +++ b/apps/mobile/__tests__/popular-products-screen.test.tsx @@ -1,11 +1,49 @@ -import { fireEvent, render } from "@testing-library/react-native"; +import { fireEvent, render, waitFor } from "@testing-library/react-native"; + +jest.mock("../features/api/productsClient", () => ({ + loadProducts: jest.fn().mockResolvedValue({ + ok: true, + data: [ + { + id: "switch-2", + name: "Nintendo Switch 2", + store: "Target", + price: "$449.99", + available: true, + imageUrl: null, + checkedAt: "2026-11-08T00:00:00.000Z", + }, + { + id: "headphones", + name: "Sony WH-1000XM6", + store: "Amazon", + price: "$398.00", + available: true, + imageUrl: null, + checkedAt: "2026-11-08T00:00:00.000Z", + }, + { + id: "pokemon-etb", + name: "Pokémon TCG: Prismatic Evolutions ETB", + store: "Walmart", + price: "$54.97", + available: false, + imageUrl: null, + checkedAt: "2026-11-08T00:00:00.000Z", + }, + ], + }), +})); import { PopularProductsScreen } from "../features/popular-products/PopularProductsScreen"; describe("PopularProductsScreen", () => { - it("shows placeholder products with their stores and prices", async () => { + it("shows products with their stores and prices", async () => { const screen = await render(); + await waitFor(() => { + expect(screen.getByText("Nintendo Switch 2")).toBeTruthy(); + }); expect(screen.getByText("Nintendo Switch 2")).toBeTruthy(); expect(screen.getByText("Target")).toBeTruthy(); expect(screen.getByText("$449.99")).toBeTruthy(); @@ -14,6 +52,10 @@ describe("PopularProductsScreen", () => { it("filters products by search text", async () => { const screen = await render(); + await waitFor(() => + expect(screen.getByText("Sony WH-1000XM6")).toBeTruthy(), + ); + await fireEvent.changeText( screen.getByLabelText("Search popular products"), "sony", @@ -23,14 +65,12 @@ describe("PopularProductsScreen", () => { expect(screen.queryByText("Nintendo Switch 2")).toBeNull(); }); - it("filters products by category", async () => { + it("shows stock status for unavailable products", async () => { const screen = await render(); - await fireEvent.press(screen.getByRole("button", { name: "Cards" })); - - expect( - screen.getByText("Pokémon TCG: Prismatic Evolutions ETB"), - ).toBeTruthy(); - expect(screen.queryByText("Sony WH-1000XM6")).toBeNull(); + await waitFor(() => { + expect(screen.getByText("Out of Stock")).toBeTruthy(); + expect(screen.getByText("$54.97")).toBeTruthy(); + }); }); }); diff --git a/apps/mobile/app/(protected)/home/popular-products.tsx b/apps/mobile/app/(protected)/home/popular-products.tsx index d2a70c9..f3372dc 100644 --- a/apps/mobile/app/(protected)/home/popular-products.tsx +++ b/apps/mobile/app/(protected)/home/popular-products.tsx @@ -10,7 +10,7 @@ export default function PopularProductsRoute() { router.replace(route as never) }} onLogout={async () => { - await supabase.auth.signOut(); + if (supabase) await supabase.auth.signOut(); router.replace("/"); }} /> diff --git a/apps/mobile/app/(protected)/recommended.tsx b/apps/mobile/app/(protected)/recommended.tsx index 50eb2e2..63959e4 100644 --- a/apps/mobile/app/(protected)/recommended.tsx +++ b/apps/mobile/app/(protected)/recommended.tsx @@ -10,7 +10,7 @@ export default function RecommendedProductsRoute() { router.replace(route as never) }} onLogout={async () => { - await supabase.auth.signOut(); + if (supabase) await supabase.auth.signOut(); router.replace("/"); }} /> diff --git a/apps/mobile/app/(protected)/watched-items.tsx b/apps/mobile/app/(protected)/watched-items.tsx index 4d83d65..3a04fb6 100644 --- a/apps/mobile/app/(protected)/watched-items.tsx +++ b/apps/mobile/app/(protected)/watched-items.tsx @@ -10,7 +10,7 @@ export default function WatchedItemsRoute() { router.replace(route as never) }} onLogout={async () => { - await supabase.auth.signOut(); + if (supabase) await supabase.auth.signOut(); router.replace("/"); }} /> diff --git a/apps/mobile/assets/images/pokemon-30th-poster.webp b/apps/mobile/assets/images/pokemon-30th-poster.webp new file mode 100644 index 0000000..4ec7276 Binary files /dev/null and b/apps/mobile/assets/images/pokemon-30th-poster.webp differ diff --git a/apps/mobile/features/api/productsClient.ts b/apps/mobile/features/api/productsClient.ts new file mode 100644 index 0000000..0206b7b --- /dev/null +++ b/apps/mobile/features/api/productsClient.ts @@ -0,0 +1,54 @@ +import { readApiUrl } from "../../lib/apiConfig"; +import { supabase } from "../../lib/supabase"; +import { withTimeout } from "../auth/authService"; +import type { FailureKind, Result } from "./accountClient"; + +export type Product = { + id: string; + name: string; + store: string; + price: string; + available: boolean; + imageUrl: string | null; + checkedAt: string; +}; + +const REQUEST_TIMEOUT_MS = 10_000; + +async function request(path: string): Promise> { + if (!supabase) return { ok: false, failure: { kind: "configuration" } }; + + try { + const apiUrl = readApiUrl(); + const { data } = await withTimeout( + supabase.auth.getSession(), + REQUEST_TIMEOUT_MS, + "Session lookup timed out.", + ); + const token = data.session?.access_token; + if (!token) return { ok: false, failure: { kind: "unauthorized" } }; + + const res = await withTimeout( + fetch(`${apiUrl}${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }), + REQUEST_TIMEOUT_MS, + "Product request timed out.", + ); + if (res.ok) return { ok: true, data: (await res.json()) as T }; + + const kind: FailureKind = res.status === 401 ? "unauthorized" : "unknown"; + return { ok: false, failure: { kind } }; + } catch { + try { + readApiUrl(); + } catch { + return { ok: false, failure: { kind: "configuration" } }; + } + return { ok: false, failure: { kind: "network" } }; + } +} + +export async function loadProducts(): Promise> { + return request("/products"); +} diff --git a/apps/mobile/features/home/HomeScreen.tsx b/apps/mobile/features/home/HomeScreen.tsx index dcdad35..0974978 100644 --- a/apps/mobile/features/home/HomeScreen.tsx +++ b/apps/mobile/features/home/HomeScreen.tsx @@ -1,12 +1,17 @@ +import { useEffect, useState } from "react"; import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +import { loadProducts, type Product } from "../api/productsClient"; import { AuthenticatedHeader, type AuthenticatedNavigation, } from "../navigation/AuthenticatedHeader"; import { ProductCard } from "../products/ProductCard"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const POKEMON_POSTER_IMAGE = require("../../assets/images/pokemon-30th-poster.webp"); + type HomeScreenProps = { navigation?: AuthenticatedNavigation; onAccountInfo?: () => void; @@ -24,6 +29,20 @@ export function HomeScreen({ onOpenWatchedItems = () => undefined, onOpenPopularProducts = () => undefined, }: HomeScreenProps) { + const [popularProducts, setPopularProducts] = useState([]); + + useEffect(() => { + let cancelled = false; + + loadProducts().then((result) => { + if (!cancelled && result.ok) setPopularProducts(result.data); + }); + + return () => { + cancelled = true; + }; + }, []); + return ( @@ -95,19 +114,23 @@ export function HomeScreen({ - - - + {popularProducts.length === 0 ? ( + Loading products... + ) : ( + popularProducts + .slice(0, 2) + .map((product) => ( + + )) + )} diff --git a/apps/mobile/features/popular-products/PopularProductsScreen.tsx b/apps/mobile/features/popular-products/PopularProductsScreen.tsx index ed57256..adfe08e 100644 --- a/apps/mobile/features/popular-products/PopularProductsScreen.tsx +++ b/apps/mobile/features/popular-products/PopularProductsScreen.tsx @@ -1,7 +1,7 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { - Pressable, + ActivityIndicator, ScrollView, StyleSheet, Text, @@ -10,54 +10,15 @@ import { } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +import { loadProducts, type Product } from "../api/productsClient"; import { AuthenticatedHeader, type AuthenticatedNavigation, } from "../navigation/AuthenticatedHeader"; import { ProductCard } from "../products/ProductCard"; -type Category = "All" | "Electronics" | "Cards" | "Collectibles"; - -type PopularProduct = { - id: string; - name: string; - store: string; - price: string; - category: Exclude; -}; - -const categories: Category[] = ["All", "Electronics", "Cards", "Collectibles"]; - -const products: PopularProduct[] = [ - { - id: "switch-2", - name: "Nintendo Switch 2", - store: "Target", - price: "$449.99", - category: "Electronics", - }, - { - id: "headphones", - name: "Sony WH-1000XM6", - store: "Amazon", - price: "$398.00", - category: "Electronics", - }, - { - id: "pokemon-etb", - name: "Pokémon TCG: Prismatic Evolutions ETB", - store: "Walmart", - price: "$54.97", - category: "Cards", - }, - { - id: "lego-f1", - name: "LEGO Technic Ferrari SF-24 F1 Car", - store: "LEGO", - price: "$229.99", - category: "Collectibles", - }, -]; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const POKEMON_POSTER_IMAGE = require("../../assets/images/pokemon-30th-poster.webp"); type PopularProductsScreenProps = { navigation?: AuthenticatedNavigation; @@ -69,22 +30,43 @@ export function PopularProductsScreen({ onLogout, }: PopularProductsScreenProps) { const [search, setSearch] = useState(""); - const [selectedCategory, setSelectedCategory] = useState("All"); + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function fetchProducts() { + setLoading(true); + const result = await loadProducts(); + if (cancelled) return; + + if (result.ok) { + setProducts(result.data); + setError(null); + } else { + setError("Unable to load products right now."); + } + setLoading(false); + } + + fetchProducts(); + return () => { + cancelled = true; + }; + }, []); const filteredProducts = useMemo(() => { const searchTerm = search.trim().toLowerCase(); + if (!searchTerm) return products; - return products.filter((product) => { - const inCategory = - selectedCategory === "All" || product.category === selectedCategory; - const matchesSearch = - !searchTerm || + return products.filter( + (product) => product.name.toLowerCase().includes(searchTerm) || - product.store.toLowerCase().includes(searchTerm); - - return inCategory && matchesSearch; - }); - }, [search, selectedCategory]); + product.store.toLowerCase().includes(searchTerm), + ); + }, [search, products]); return ( @@ -94,7 +76,7 @@ export function PopularProductsScreen({ Popular Products - Browse placeholder deals from stores shoppers watch most. + Live stock status from tracked stores. - - {categories.map((category) => { - const selected = category === selectedCategory; - - return ( - setSelectedCategory(category)} - style={[ - styles.categoryButton, - selected && styles.categoryButtonSelected, - ]} - > - - {category} - - - ); - })} - - - {filteredProducts.length === 0 ? ( + {loading ? ( + + ) : error ? ( + + Something went wrong + {error} + + ) : filteredProducts.length === 0 ? ( No products found - - Try another search or category. - + Try another search. ) : ( filteredProducts.map((product) => ( - IMG + {imageSource ? ( + + ) : ( + IMG + )} {store} {price} + {available !== undefined && ( + + + {available ? "In Stock" : "Out of Stock"} + + + )} ); @@ -57,6 +82,7 @@ const styles = StyleSheet.create({ borderRadius: 8, height: 72, justifyContent: "center", + overflow: "hidden", width: 72, }, gridImagePlaceholder: { @@ -64,6 +90,7 @@ const styles = StyleSheet.create({ marginBottom: 12, width: "100%", }, + image: { height: "100%", width: "100%" }, imagePlaceholderText: { color: "#777", fontSize: 12, fontWeight: "600" }, productDetails: { flex: 1, marginLeft: 14 }, gridProductDetails: { marginLeft: 0 }, @@ -78,4 +105,14 @@ const styles = StyleSheet.create({ gridStore: { color: "#888", fontSize: 13, marginBottom: 6, marginTop: 0 }, price: { color: "#79DCA0", fontSize: 17, fontWeight: "700", marginTop: 7 }, gridPrice: { color: "#fff", marginTop: 0 }, + stockBadge: { + alignSelf: "flex-start", + borderRadius: 6, + marginTop: 7, + paddingHorizontal: 8, + paddingVertical: 3, + }, + inStockBadge: { backgroundColor: "#1d3a28" }, + outOfStockBadge: { backgroundColor: "#3a1d1d" }, + stockBadgeText: { color: "#fff", fontSize: 12, fontWeight: "600" }, }); diff --git a/apps/mobile/jest.setup.ts b/apps/mobile/jest.setup.ts new file mode 100644 index 0000000..eb09cd1 --- /dev/null +++ b/apps/mobile/jest.setup.ts @@ -0,0 +1,5 @@ +jest.mock("@react-native-async-storage/async-storage", () => + jest.requireActual( + "@react-native-async-storage/async-storage/jest/async-storage-mock", + ), +); diff --git a/apps/mobile/lib/secureStorage.ts b/apps/mobile/lib/secureStorage.ts index f0fd3aa..baa4a6e 100644 --- a/apps/mobile/lib/secureStorage.ts +++ b/apps/mobile/lib/secureStorage.ts @@ -1,12 +1,15 @@ -import * as SecureStore from "expo-secure-store"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { Platform } from "react-native"; +// Supabase's session payload (access token + refresh token + user metadata) +// routinely exceeds SecureStore's 2048-byte per-item limit, so the session +// itself is kept in AsyncStorage. Nothing else currently uses this module. export const secureStorage = { getItem: (key: string): Promise => { if (Platform.OS === "web") { return Promise.resolve(localStorage.getItem(key)); } - return SecureStore.getItemAsync(key); + return AsyncStorage.getItem(key); }, setItem: (key: string, value: string): Promise => { @@ -14,7 +17,7 @@ export const secureStorage = { localStorage.setItem(key, value); return Promise.resolve(); } - return SecureStore.setItemAsync(key, value); + return AsyncStorage.setItem(key, value); }, removeItem: (key: string): Promise => { @@ -22,6 +25,6 @@ export const secureStorage = { localStorage.removeItem(key); return Promise.resolve(); } - return SecureStore.deleteItemAsync(key); + return AsyncStorage.removeItem(key); }, }; diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index c9a42b9..7a9dff0 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@expo/metro-runtime": "~57.0.16", "@expo/ui": "~57.0.9", + "@react-native-async-storage/async-storage": "2.2.0", "@supabase/supabase-js": "^2.117.3", "expo": "~57.0.10", "expo-constants": "~57.0.9", @@ -2699,6 +2700,18 @@ } } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native-masked-view/masked-view": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", @@ -6077,7 +6090,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6601,6 +6613,15 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -8521,6 +8542,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5d3328c..aaf7048 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -17,6 +17,7 @@ "dependencies": { "@expo/metro-runtime": "~57.0.16", "@expo/ui": "~57.0.9", + "@react-native-async-storage/async-storage": "2.2.0", "@supabase/supabase-js": "^2.117.3", "expo": "~57.0.10", "expo-constants": "~57.0.9", @@ -55,6 +56,9 @@ }, "jest": { "preset": "jest-expo", + "setupFiles": [ + "/jest.setup.ts" + ], "testTimeout": 30000 } } diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 178b601..0e98340 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "expo/tsconfig.base", "compilerOptions": { + "jsx": "react-jsx", "strict": true, "types": ["jest"], "paths": { diff --git a/supabase/migrations/20261101000000_create_products.sql b/supabase/migrations/20261101000000_create_products.sql new file mode 100644 index 0000000..fc616b9 --- /dev/null +++ b/supabase/migrations/20261101000000_create_products.sql @@ -0,0 +1,31 @@ +create table if not exists public.products ( + id uuid primary key default gen_random_uuid(), + slug text not null unique, + name text not null, + store text not null, + shopify_url text not null, + created_at timestamptz not null default now() +); + +create index if not exists products_created_at_idx + on public.products (created_at desc); + +alter table public.products enable row level security; + +drop policy if exists "authenticated users can read products" +on public.products; + +create policy "authenticated users can read products" +on public.products +for select +to authenticated +using (true); + +insert into public.products (slug, name, store, shopify_url) +values ( + 'pokemon-30th-poster', + 'Pokemon TCG: 30th Celebration - Poster Collection', + 'Shop of Magic', + 'https://shop.shopofmagic.com/collections/pokemon-30th-anniversary-collection-1/products/pokemon-30th-celebration-poster-collection.js' +) +on conflict (slug) do nothing; \ No newline at end of file