├── .nvmrc ├── public ├── shopstr.ico ├── signup.png ├── github-mark.png ├── x-logo-black.png ├── x-logo-white.png ├── shopstr-144x144.png ├── shopstr-512x512.png ├── github-mark-white.png ├── listing-step-dark.png ├── listing-step-light.png ├── payment-confirmed.gif ├── payment-step-dark.png ├── payment-step-light.png ├── profile-step-dark.png ├── profile-step-light.png ├── shop-freely-dark.png ├── shop-freely-light.png ├── shopstr-2000x2000.png ├── sign-in-step-dark.png ├── sign-in-step-light.png ├── no-image-placeholder.png ├── shop-freely-dark-sm.png ├── shop-freely-light-sm.png ├── nostr-icon-black-transparent-256x256.png ├── nostr-icon-white-transparent-256x256.png ├── .well-known │ └── nostr.json ├── manifest.json └── service-worker.js ├── replit.nix ├── cache └── config.json ├── postcss.config.cjs ├── .gitattributes ├── .npmrc ├── .prettierignore ├── pages ├── api │ ├── health.ts │ └── db │ │ ├── fetch-reviews.ts │ │ ├── fetch-products.ts │ │ ├── fetch-profiles.ts │ │ ├── fetch-communities.ts │ │ ├── delete-events.ts │ │ ├── cache-event.ts │ │ ├── fetch-messages.ts │ │ ├── fetch-relays.ts │ │ ├── fetch-blossom.ts │ │ ├── fetch-wallet.ts │ │ ├── cache-events.ts │ │ ├── clear-failed-publish.ts │ │ ├── get-failed-publishes.ts │ │ ├── track-failed-publish.ts │ │ └── discount-codes.ts ├── my-listings │ └── index.tsx ├── orders │ └── index.tsx ├── _document.tsx ├── 404.tsx ├── marketplace │ └── [[...npub]].tsx ├── onboarding │ ├── user-profile.tsx │ └── shop-profile.tsx └── communities │ └── [naddr].tsx ├── env.example ├── .prettierrc ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── documentation_improvement.yml │ ├── feature_request.yml │ └── bug_report.yml ├── codeql │ └── codeql-config.yml ├── workflows │ ├── build.yaml │ ├── dependabot.yml │ ├── vercel-preview.yml │ ├── greeting.yml │ └── lint.yml └── PULL_REQUEST_TEMPLATE.md ├── components ├── utility-components │ ├── shopstr-spinner.tsx │ ├── shopstr-switch.tsx │ ├── __tests__ │ │ ├── shopstr-spinner.test.tsx │ │ ├── failure-modal.test.tsx │ │ ├── success-modal.test.tsx │ │ ├── shopstr-switch.test.tsx │ │ ├── compact-categories.test.tsx │ │ └── volume-selector.test.tsx │ ├── dropdowns │ │ ├── confirm-action-dropdown.tsx │ │ ├── country-dropdown.tsx │ │ └── __tests__ │ │ │ ├── confirm-action-dropdown.test.tsx │ │ │ ├── country-dropdown.test.tsx │ │ │ └── location-dropdown.test.tsx │ ├── failure-modal.tsx │ ├── success-modal.tsx │ ├── volume-selector.tsx │ ├── shopstr-slider.tsx │ ├── profile │ │ └── profile-avatar.tsx │ ├── compact-categories.tsx │ ├── display-monetary-info.tsx │ └── auth-challenge-modal.tsx ├── hooks │ ├── use-tabs.tsx │ ├── __tests__ │ │ ├── use-navigation.test.tsx │ │ └── use-tabs.test.tsx │ └── use-navigation.tsx ├── my-listings │ ├── my-listings-feed.tsx │ └── __tests__ │ │ └── my-listings-feed.test.tsx ├── settings │ ├── settings-bread-crumbs.tsx │ └── __tests__ │ │ └── settings-bread-crumbs.test.tsx ├── communities │ ├── CommunityCard.tsx │ ├── __tests__ │ │ └── CommunityCard.test.tsx │ └── CreateCommunityForm.tsx ├── home │ └── home-feed.tsx ├── messages │ ├── message-feed.tsx │ └── chat-button.tsx ├── framer.tsx └── wallet │ └── transactions.tsx ├── .gitignore ├── docker-compose.yml ├── utils ├── nostr │ ├── zap-validator.ts │ ├── signers │ │ ├── nostr-signer.ts │ │ └── nostr-nip07-signer.ts │ ├── retry-service.ts │ ├── __tests__ │ │ └── zap-validator.test.ts │ └── encryption-migration.ts ├── images.ts ├── keypress-handler.ts ├── parsers │ ├── review-parser-functions.ts │ ├── zapsnag-parser.ts │ ├── __tests__ │ │ ├── zapsnag-parser.test.ts │ │ └── review-parser-functions.test.ts │ └── community-parser-functions.ts ├── STATIC-VARIABLES.ts ├── timeout.ts ├── messages │ └── utils.ts ├── __tests__ │ ├── keypress-handler.test.ts │ ├── images.test.ts │ └── timeout.test.ts ├── db │ └── db-client.ts └── types │ └── types.ts ├── jest.config.cjs ├── .eslintrc.json ├── tsconfig.json ├── .replit ├── jest.setup.js ├── styles └── globals.css ├── middleware.ts ├── Dockerfile ├── README.md ├── .eslintrc.security.js ├── next.config.cjs ├── package.json └── tailwind.config.ts /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.17.0 2 | -------------------------------------------------------------------------------- /public/shopstr.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/shopstr.ico -------------------------------------------------------------------------------- /public/signup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/signup.png -------------------------------------------------------------------------------- /replit.nix: -------------------------------------------------------------------------------- 1 | { pkgs }: { 2 | deps = [ 3 | pkgs.nodejs-18_x 4 | ]; 5 | } 6 | -------------------------------------------------------------------------------- /public/github-mark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/github-mark.png -------------------------------------------------------------------------------- /public/x-logo-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/x-logo-black.png -------------------------------------------------------------------------------- /public/x-logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/x-logo-white.png -------------------------------------------------------------------------------- /public/shopstr-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/shopstr-144x144.png -------------------------------------------------------------------------------- /public/shopstr-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/shopstr-512x512.png -------------------------------------------------------------------------------- /public/github-mark-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/github-mark-white.png -------------------------------------------------------------------------------- /public/listing-step-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/listing-step-dark.png -------------------------------------------------------------------------------- /public/listing-step-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/listing-step-light.png -------------------------------------------------------------------------------- /public/payment-confirmed.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/payment-confirmed.gif -------------------------------------------------------------------------------- /public/payment-step-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/payment-step-dark.png -------------------------------------------------------------------------------- /public/payment-step-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/payment-step-light.png -------------------------------------------------------------------------------- /public/profile-step-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/profile-step-dark.png -------------------------------------------------------------------------------- /public/profile-step-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/profile-step-light.png -------------------------------------------------------------------------------- /public/shop-freely-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/shop-freely-dark.png -------------------------------------------------------------------------------- /public/shop-freely-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/shop-freely-light.png -------------------------------------------------------------------------------- /public/shopstr-2000x2000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/shopstr-2000x2000.png -------------------------------------------------------------------------------- /public/sign-in-step-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/sign-in-step-dark.png -------------------------------------------------------------------------------- /public/sign-in-step-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/sign-in-step-light.png -------------------------------------------------------------------------------- /public/no-image-placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/no-image-placeholder.png -------------------------------------------------------------------------------- /public/shop-freely-dark-sm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/shop-freely-dark-sm.png -------------------------------------------------------------------------------- /public/shop-freely-light-sm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/shop-freely-light-sm.png -------------------------------------------------------------------------------- /cache/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "telemetry": { 3 | "notifiedAt": "1696444145996", 4 | "enabled": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/nostr-icon-black-transparent-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/nostr-icon-black-transparent-256x256.png -------------------------------------------------------------------------------- /public/nostr-icon-white-transparent-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopstr-eng/shopstr/HEAD/public/nostr-icon-white-transparent-256x256.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf 4 | *.{png,jpg,jpeg,gif,webp,woff,woff2} binary 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | legacy-peer-deps=true 3 | engine-strict=true 4 | prefer-offline=true 5 | audit=true 6 | fund=false 7 | package-lock=true 8 | progress=false 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | node_modules 3 | public 4 | out 5 | build 6 | coverage 7 | .github 8 | .husky 9 | .vscode 10 | *.log 11 | .env* 12 | package-lock.json 13 | -------------------------------------------------------------------------------- /pages/api/health.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | export default function handler(_req: NextApiRequest, res: NextApiResponse) { 4 | res.status(200).json({ status: "ok", timestamp: new Date().toISOString() }); 5 | } 6 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | # Database connection string 2 | # For local development with docker-compose: 3 | DATABASE_URL=postgresql://shopstr:shopstr@localhost:5432/shopstr 4 | 5 | # For production, use your actual database URL: 6 | # DATABASE_URL=postgresql://user:password@host:port/database 7 | 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": false, 5 | "printWidth": 80, 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "bracketSpacing": true, 9 | "arrowParens": "always", 10 | "endOfLine": "lf", 11 | "plugins": ["prettier-plugin-tailwindcss"] 12 | } 13 | -------------------------------------------------------------------------------- /pages/my-listings/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import MyListingsFeed from "@/components/my-listings/my-listings-feed"; 3 | 4 | export default function ShopView() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: ❓ Questions & Help 4 | url: https://github.com/shopstr-eng/shopstr/discussions 5 | about: Please ask and answer questions here 6 | - name: 💬 Chat & Discussion 7 | url: https://github.com/shopstr-eng/shopstr/discussions 8 | about: Join the conversation 9 | -------------------------------------------------------------------------------- /components/utility-components/shopstr-spinner.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from "@nextui-org/react"; 2 | import { useTheme } from "next-themes"; 3 | 4 | export default function ShopstrSpinner() { 5 | const { theme } = useTheme(); 6 | return ( 7 | <> 8 | {theme === "dark" ? ( 9 | 10 | ) : ( 11 | 12 | )} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /public/.well-known/nostr.json: -------------------------------------------------------------------------------- 1 | { 2 | "names": { 3 | "shopstr": "a37118a4888e02d28e8767c08caaf73b49abdac391ad7ff18a304891e416dc33", 4 | "calvadev": "d36e8083fa7b36daee646cb8b3f99feaa3d89e5a396508741f003e21ac0b6bec", 5 | "TommySatoshi": "af2b1ccca618ae557f64392da4823bde7cc273fe36b5350bfa246988cd7123fe", 6 | "eric": "019bdf4169327bce27b5dd90092bfc1be4d09be1982e06e16efa97aedb83e3a2", 7 | "Welliv": "d2b4168467439f03eb7e0e30be9412d5a29d050aac77dc60de3dcb9f45f91b07" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | postgres: 5 | image: postgres:15-alpine 6 | container_name: shopstr-postgres 7 | environment: 8 | POSTGRES_USER: shopstr 9 | POSTGRES_PASSWORD: shopstr 10 | POSTGRES_DB: shopstr 11 | ports: 12 | - "5432:5432" 13 | volumes: 14 | - postgres_data:/var/lib/postgresql/data 15 | healthcheck: 16 | test: ["CMD-SHELL", "pg_isready -U shopstr"] 17 | interval: 10s 18 | timeout: 5s 19 | retries: 5 20 | 21 | volumes: 22 | postgres_data: 23 | 24 | -------------------------------------------------------------------------------- /pages/orders/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useRouter } from "next/router"; 3 | import MessageFeed from "@/components/messages/message-feed"; 4 | 5 | export default function MessageView() { 6 | const router = useRouter(); 7 | const { isInquiry } = router.query; 8 | 9 | return ( 10 |
11 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /pages/api/db/fetch-reviews.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { fetchCachedEvents } from "@/utils/db/db-service"; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | if (req.method !== "GET") { 9 | return res.status(405).json({ error: "Method not allowed" }); 10 | } 11 | 12 | try { 13 | const reviews = await fetchCachedEvents(31555); 14 | res.status(200).json(reviews); 15 | } catch (error) { 16 | console.error("Failed to fetch reviews from database:", error); 17 | res.status(500).json({ error: "Failed to fetch reviews" }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pages/api/db/fetch-products.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { fetchAllProductsFromDb } from "@/utils/db/db-service"; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | if (req.method !== "GET") { 9 | return res.status(405).json({ error: "Method not allowed" }); 10 | } 11 | 12 | try { 13 | const products = await fetchAllProductsFromDb(); 14 | res.status(200).json(products); 15 | } catch (error) { 16 | console.error("Failed to fetch products from database:", error); 17 | res.status(500).json({ error: "Failed to fetch products" }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pages/api/db/fetch-profiles.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { fetchAllProfilesFromDb } from "@/utils/db/db-service"; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | if (req.method !== "GET") { 9 | return res.status(405).json({ error: "Method not allowed" }); 10 | } 11 | 12 | try { 13 | const profiles = await fetchAllProfilesFromDb(); 14 | res.status(200).json(profiles); 15 | } catch (error) { 16 | console.error("Failed to fetch profiles from database:", error); 17 | res.status(500).json({ error: "Failed to fetch profiles" }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /utils/nostr/zap-validator.ts: -------------------------------------------------------------------------------- 1 | import { NostrManager } from "./nostr-manager"; 2 | 3 | export async function validateZapReceipt( 4 | nostr: NostrManager, 5 | productId: string, 6 | minTimestamp: number 7 | ): Promise { 8 | const filter = { 9 | kinds: [9735], 10 | "#e": [productId], 11 | since: minTimestamp 12 | }; 13 | 14 | const maxRetries = 5; 15 | const delay = 1000; 16 | 17 | for (let i = 0; i < maxRetries; i++) { 18 | const events = await nostr.fetch([filter]); 19 | if (events.length > 0) { 20 | return true; 21 | } 22 | await new Promise((resolve) => setTimeout(resolve, delay)); 23 | } 24 | 25 | return false; 26 | } -------------------------------------------------------------------------------- /pages/api/db/fetch-communities.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { fetchAllCommunitiesFromDb } from "@/utils/db/db-service"; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | if (req.method !== "GET") { 9 | return res.status(405).json({ error: "Method not allowed" }); 10 | } 11 | 12 | try { 13 | const communities = await fetchAllCommunitiesFromDb(); 14 | res.status(200).json(communities); 15 | } catch (error) { 16 | console.error("Failed to fetch communities from database:", error); 17 | res.status(500).json({ error: "Failed to fetch communities" }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pages/api/db/delete-events.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { deleteCachedEventsByIds } from "@/utils/db/db-service"; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | if (req.method !== "POST") { 9 | return res.status(405).json({ error: "Method not allowed" }); 10 | } 11 | 12 | try { 13 | const { eventIds } = req.body; 14 | await deleteCachedEventsByIds(eventIds); 15 | res.status(200).json({ success: true }); 16 | } catch (error) { 17 | console.error("Failed to delete cached events:", error); 18 | res.status(500).json({ error: "Failed to delete cached events" }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pages/api/db/cache-event.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { cacheEvent } from "@/utils/db/db-service"; 3 | import { NostrEvent } from "@/utils/types/types"; 4 | 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) { 9 | if (req.method !== "POST") { 10 | return res.status(405).json({ error: "Method not allowed" }); 11 | } 12 | 13 | try { 14 | const event: NostrEvent = req.body; 15 | await cacheEvent(event); 16 | res.status(200).json({ success: true }); 17 | } catch (error) { 18 | console.error("Failed to cache event:", error); 19 | res.status(500).json({ error: "Failed to cache event" }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /utils/nostr/signers/nostr-signer.ts: -------------------------------------------------------------------------------- 1 | import { NostrEventTemplate, NostrEvent } from "@/utils/nostr/nostr-manager"; 2 | 3 | export interface NostrSigner { 4 | connect(): Promise; 5 | getPubKey(): Promise; 6 | sign(event: NostrEventTemplate): Promise; 7 | encrypt(pubkey: string, plainText: string): Promise; 8 | decrypt(pubkey: string, cipherText: string): Promise; 9 | close(): Promise; 10 | toJSON(): { [key: string]: any }; 11 | } 12 | 13 | export type ChallengeHandler = ( 14 | type: string, 15 | challenge: string, 16 | abort: () => void, 17 | abortSignal: AbortSignal, 18 | lastError?: Error 19 | ) => Promise<{ 20 | res: string; 21 | remind: boolean; 22 | }>; 23 | -------------------------------------------------------------------------------- /.github/codeql/codeql-config.yml: -------------------------------------------------------------------------------- 1 | name: "Security and Quality CodeQL Configuration" 2 | 3 | disable-default-queries: false 4 | 5 | query-filters: 6 | - exclude: 7 | id: js/unused-local-variable 8 | 9 | queries: 10 | - name: security-extended 11 | uses: security-extended 12 | - name: security-and-quality 13 | uses: security-and-quality 14 | 15 | paths-ignore: 16 | - "node_modules/**" 17 | - "**/*.test.js" 18 | - "**/*.test.ts" 19 | - "**/*.test.tsx" 20 | - "**/*.spec.js" 21 | - "**/*.spec.ts" 22 | - "**/*.spec.tsx" 23 | - "jest.config.*" 24 | - "jest.setup.*" 25 | - ".next/**" 26 | - "coverage/**" 27 | 28 | paths: 29 | - "components/**" 30 | - "pages/**" 31 | - "utils/**" 32 | - "middleware.ts" -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | const nextJest = require("next/jest"); 2 | 3 | const createJestConfig = nextJest({ 4 | // Provide the path to our Next.js app to load next.config.js and .env files in our test environment 5 | dir: "./", 6 | }); 7 | 8 | const customJestConfig = { 9 | setupFilesAfterEnv: ["/jest.setup.js"], 10 | testEnvironment: "jest-environment-jsdom", 11 | moduleNameMapper: { 12 | // Handle module aliases 13 | "^@/(.*)$": "/$1", 14 | }, 15 | }; 16 | 17 | module.exports = async () => { 18 | const jestConfig = await createJestConfig(customJestConfig)(); 19 | jestConfig.transformIgnorePatterns = [ 20 | "/node_modules/(?!(dexie|nostr-tools|@getalby/lightning-tools|@cashu/cashu-ts)/)", 21 | "^.+\\.module\\.(css|sass|scss)$", 22 | ]; 23 | 24 | return jestConfig; 25 | }; 26 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "prettier", 5 | "plugin:@typescript-eslint/recommended" 6 | ], 7 | "plugins": ["@typescript-eslint"], 8 | "rules": { 9 | "@typescript-eslint/no-unused-vars": [ 10 | "error", 11 | { 12 | "argsIgnorePattern": "^_", 13 | "varsIgnorePattern": "^_" 14 | } 15 | ], 16 | "@typescript-eslint/no-explicit-any": "warn", 17 | "@typescript-eslint/explicit-function-return-type": "off", 18 | "@typescript-eslint/explicit-module-boundary-types": "off", 19 | "react-hooks/rules-of-hooks": "error", 20 | "react-hooks/exhaustive-deps": "warn", 21 | "no-console": ["warn", { "allow": ["warn", "error"] }] 22 | }, 23 | "ignorePatterns": ["node_modules/", ".next/", "out/", "public/", "**/*.js"] 24 | } 25 | -------------------------------------------------------------------------------- /utils/images.ts: -------------------------------------------------------------------------------- 1 | const hostToSrcSet = (url: URL) => { 2 | const host = url.host; 3 | 4 | // add all known image hosting providers here and configure responsive src formatting 5 | switch (host) { 6 | case "image.nostr.build": 7 | return ["240", "480", "720", "1080"] 8 | .map((size) => `${url.origin}/resp/${size}p${url.pathname} ${size}w`) 9 | .join(", "); 10 | case "i.nostr.build": 11 | return ["240", "480", "720", "1080"] 12 | .map((size) => `${url.origin}/resp/${size}p${url.pathname} ${size}w`) 13 | .join(", "); 14 | default: 15 | return url.toString(); 16 | } 17 | }; 18 | 19 | export const buildSrcSet = (image: string) => { 20 | try { 21 | const url = new URL(image); 22 | return hostToSrcSet(url); 23 | } catch (_) { 24 | return image; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /pages/api/db/fetch-messages.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { fetchCachedEvents } from "@/utils/db/db-service"; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | if (req.method !== "GET") { 9 | return res.status(405).json({ error: "Method not allowed" }); 10 | } 11 | 12 | try { 13 | const { pubkey } = req.query; 14 | if (typeof pubkey !== "string") { 15 | return res.status(400).json({ error: "Invalid pubkey parameter" }); 16 | } 17 | 18 | const messages = await fetchCachedEvents(1059, { pubkey }); 19 | res.status(200).json(messages); 20 | } catch (error) { 21 | console.error("Failed to fetch messages from database:", error); 22 | res.status(500).json({ error: "Failed to fetch messages" }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pages/api/db/fetch-relays.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { fetchCachedEvents } from "@/utils/db/db-service"; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | if (req.method !== "GET") { 9 | return res.status(405).json({ error: "Method not allowed" }); 10 | } 11 | 12 | try { 13 | const { pubkey } = req.query; 14 | if (typeof pubkey !== "string") { 15 | return res.status(400).json({ error: "Invalid pubkey parameter" }); 16 | } 17 | 18 | const relays = await fetchCachedEvents(10002, { pubkey }); 19 | res.status(200).json(relays); 20 | } catch (error) { 21 | console.error("Failed to fetch relay config from database:", error); 22 | res.status(500).json({ error: "Failed to fetch relay config" }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | # Trigger the workflow on push or pull request, 5 | # but only for the main branch 6 | push: 7 | branches: 8 | - main 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read 15 | pull-requests: write 16 | issues: write 17 | 18 | jobs: 19 | run-linters: 20 | name: Run build 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - name: Check out Git repository 25 | uses: actions/checkout@v4 26 | 27 | - name: Set up Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 20.x 31 | 32 | # ESLint and Prettier must be in `package.json` 33 | - name: Install Node.js dependencies 34 | run: npm ci 35 | 36 | - name: Run build 37 | run: npm run build 38 | -------------------------------------------------------------------------------- /components/hooks/use-tabs.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState } from "react"; 2 | 3 | export type Tab = { label: string; id: string; children: ReactNode }; 4 | 5 | export function useTabs({ 6 | tabs, 7 | initialTabId, 8 | onChange, 9 | }: { 10 | tabs: Tab[]; 11 | initialTabId: string; 12 | onChange?: (id: string) => void; 13 | }) { 14 | const [[selectedTabIndex, direction], setSelectedTab] = useState(() => { 15 | const indexOfInitialTab = tabs.findIndex((tab) => tab.id === initialTabId); 16 | return [indexOfInitialTab === -1 ? 0 : indexOfInitialTab, 0]; 17 | }); 18 | 19 | return { 20 | tabProps: { 21 | tabs, 22 | selectedTabIndex, 23 | onChange, 24 | setSelectedTab, 25 | }, 26 | selectedTab: tabs[selectedTabIndex], 27 | contentProps: { 28 | direction, 29 | selectedTabIndex, 30 | }, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /pages/api/db/fetch-blossom.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { fetchCachedEvents } from "@/utils/db/db-service"; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | if (req.method !== "GET") { 9 | return res.status(405).json({ error: "Method not allowed" }); 10 | } 11 | 12 | try { 13 | const { pubkey } = req.query; 14 | if (typeof pubkey !== "string") { 15 | return res.status(400).json({ error: "Invalid pubkey parameter" }); 16 | } 17 | 18 | const blossomServers = await fetchCachedEvents(10063, { pubkey }); 19 | res.status(200).json(blossomServers); 20 | } catch (error) { 21 | console.error("Failed to fetch blossom config from database:", error); 22 | res.status(500).json({ error: "Failed to fetch blossom config" }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pages/api/db/fetch-wallet.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { fetchCachedEvents } from "@/utils/db/db-service"; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | if (req.method !== "GET") { 9 | return res.status(405).json({ error: "Method not allowed" }); 10 | } 11 | 12 | try { 13 | const { pubkey } = req.query; 14 | if (typeof pubkey !== "string") { 15 | return res.status(400).json({ error: "Invalid pubkey parameter" }); 16 | } 17 | 18 | const walletEvents = await fetchCachedEvents(null as any, { pubkey }); 19 | res.status(200).json(walletEvents); 20 | } catch (error) { 21 | console.error("Failed to fetch wallet events from database:", error); 22 | res.status(500).json({ error: "Failed to fetch wallet events" }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "paths": { 18 | "@/*": ["./*"] 19 | }, 20 | "baseUrl": ".", 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noImplicitReturns": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "noUncheckedIndexedAccess": true 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 28 | "exclude": ["node_modules", ".next", "out"] 29 | } 30 | -------------------------------------------------------------------------------- /utils/keypress-handler.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useKeyPress = (targetKey: any) => { 4 | const [keyPressed, setKeyPressed] = useState(false); 5 | 6 | useEffect(() => { 7 | const downHandler = ({ key }: { key: any }) => { 8 | if (key === targetKey) { 9 | setKeyPressed(true); 10 | return; 11 | } 12 | }; 13 | 14 | const upHandler = ({ key }: { key: any }) => { 15 | if (key === targetKey) { 16 | setKeyPressed(false); 17 | return; 18 | } 19 | }; 20 | 21 | window.addEventListener("keydown", downHandler); 22 | window.addEventListener("keyup", upHandler); 23 | 24 | return () => { 25 | window.removeEventListener("keydown", downHandler); 26 | window.removeEventListener("keyup", upHandler); 27 | }; 28 | }, [targetKey]); 29 | 30 | return keyPressed; 31 | }; 32 | -------------------------------------------------------------------------------- /components/utility-components/shopstr-switch.tsx: -------------------------------------------------------------------------------- 1 | import { Switch } from "@nextui-org/react"; 2 | import { useRouter } from "next/router"; 3 | import { useTheme } from "next-themes"; 4 | 5 | const ShopstrSwitch = ({ 6 | wotFilter, 7 | setWotFilter, 8 | }: { 9 | wotFilter: boolean; 10 | setWotFilter: (value: boolean) => void; 11 | }) => { 12 | const router = useRouter(); 13 | const { theme } = useTheme(); 14 | 15 | const handleTrustClick = () => { 16 | router.push("/settings/preferences"); 17 | }; 18 | 19 | return ( 20 |
21 | { 25 | setWotFilter(!wotFilter); 26 | }} 27 | /> 28 | 29 |

33 | Trust 34 |

35 |
36 |
37 | ); 38 | }; 39 | 40 | export default ShopstrSwitch; 41 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Description 2 | Provide a detailed description of the changes and their impact. Use bullet points for clarity if making multiple changes. 3 | 4 | ### Resolved or fixed issue 5 | Add GitHub issue number in format `Fixes #0000` or `none` 6 | 7 | ### Screenshots (if applicable) 8 | Add screenshots or examples if this PR includes UI-related changes. 9 | 10 | ### Affirmation 11 | 12 | - [ ] My code follows the [CONTRIBUTING.md](https://github.com/hxrshxz/shopstr/blob/main/contributing.md) guidelines 13 | 14 | ### For more details on what to include, see: 15 | 16 | [Bug Report Template](https://github.com/shopstr-eng/shopstr/blob/main/.github/ISSUE_TEMPLATE/bug_report.md) 17 | [Feature Request Template](https://github.com/shopstr-eng/shopstr/blob/main/.github/ISSUE_TEMPLATE/feature_request.md) 18 | [Documentation Issue Template](https://github.com/shopstr-eng/shopstr/blob/main/.github/ISSUE_TEMPLATE/documentation_improvement.md) 19 | [Security Issue Template](https://github.com/shopstr-eng/shopstr/blob/main/.github/ISSUE_TEMPLATE/security_vulnerability.md) -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | target-branch: "main" 8 | open-pull-requests-limit: 10 9 | groups: 10 | minor-and-patch: 11 | patterns: 12 | - "*" 13 | update-types: 14 | - "minor" 15 | - "patch" 16 | major-updates: 17 | patterns: 18 | - "*" 19 | update-types: 20 | - "major" 21 | reviewers: 22 | - "calvadev" 23 | labels: 24 | - "dependencies" 25 | - "automated" 26 | commit-message: 27 | prefix: "deps" 28 | include: "scope" 29 | rebase-strategy: "auto" 30 | 31 | - package-ecosystem: "github-actions" 32 | directory: "/" 33 | schedule: 34 | interval: "monthly" 35 | target-branch: "main" 36 | open-pull-requests-limit: 5 37 | reviewers: 38 | - "calvadev" 39 | labels: 40 | - "github-actions" 41 | - "dependencies" 42 | - "automated" 43 | commit-message: 44 | prefix: "ci" 45 | include: "scope" -------------------------------------------------------------------------------- /utils/parsers/review-parser-functions.ts: -------------------------------------------------------------------------------- 1 | export const getRatingValue = (tags: string[][], type: string): number => { 2 | const ratingTag = tags.find((tag) => tag[0] === "rating" && tag[2] === type); 3 | return ratingTag ? parseFloat(ratingTag[1]!) : 0; 4 | }; 5 | 6 | export const calculateWeightedScore = (tags: string[][]): number => { 7 | // Thumb score is always 50% of total 8 | const thumbScore = getRatingValue(tags, "thumb") * 0.5; 9 | 10 | // Get all rating tags except thumb 11 | const ratingTags = tags 12 | .filter((tag) => tag[0] === "rating" && tag[2] !== "thumb") 13 | .map((tag) => tag[2]); 14 | 15 | // If no additional ratings, return just thumb score 16 | if (ratingTags.length === 0) return thumbScore; 17 | 18 | // Calculate weight for each remaining rating (dividing remaining 50% equally) 19 | const individualWeight = 0.5 / ratingTags.length; 20 | 21 | // Calculate score for remaining ratings 22 | const remainingScore = ratingTags.reduce((total, ratingType) => { 23 | return total + getRatingValue(tags, ratingType!) * individualWeight; 24 | }, 0); 25 | 26 | return thumbScore + remainingScore; 27 | }; 28 | -------------------------------------------------------------------------------- /.replit: -------------------------------------------------------------------------------- 1 | run = "npm run dev" 2 | modules = ["nodejs-18:v20-20231025-d04a94d", "postgresql-16"] 3 | 4 | [nix] 5 | channel = "stable-22_11" 6 | 7 | [deployment] 8 | run = ["npm", "start"] 9 | deploymentTarget = "autoscale" 10 | build = ["npm", "run", "build"] 11 | 12 | [[ports]] 13 | localPort = 3000 14 | externalPort = 80 15 | 16 | [[ports]] 17 | localPort = 3001 18 | externalPort = 3001 19 | 20 | [[ports]] 21 | localPort = 5000 22 | externalPort = 5000 23 | 24 | [workflows] 25 | runButton = "Project" 26 | 27 | [[workflows.workflow]] 28 | name = "Run" 29 | author = 20519411 30 | mode = "sequential" 31 | 32 | [[workflows.workflow.tasks]] 33 | task = "shell.exec" 34 | args = "npm run dev" 35 | 36 | [[workflows.workflow]] 37 | name = "Project" 38 | mode = "parallel" 39 | author = "agent" 40 | 41 | [[workflows.workflow.tasks]] 42 | task = "workflow.run" 43 | args = "Next.js Dev Server" 44 | 45 | [[workflows.workflow]] 46 | name = "Next.js Dev Server" 47 | author = "agent" 48 | 49 | [[workflows.workflow.tasks]] 50 | task = "shell.exec" 51 | args = "npm run dev" 52 | waitForPort = 5000 53 | 54 | [workflows.workflow.metadata] 55 | outputType = "webview" 56 | 57 | [agent] 58 | expertMode = true 59 | -------------------------------------------------------------------------------- /pages/api/db/cache-events.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { cacheEvents } from "@/utils/db/db-service"; 3 | import { NostrEvent } from "@/utils/types/types"; 4 | 5 | export default async function handler( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) { 9 | if (req.method !== "POST") { 10 | return res.status(405).json({ error: "Method not allowed" }); 11 | } 12 | 13 | try { 14 | const events: NostrEvent[] = req.body; 15 | if (!Array.isArray(events)) { 16 | return res 17 | .status(400) 18 | .json({ error: "Invalid request body: expected an array of events" }); 19 | } 20 | 21 | // Handle large batches by splitting them 22 | if (events.length > 100) { 23 | const chunks = []; 24 | for (let i = 0; i < events.length; i += 100) { 25 | chunks.push(events.slice(i, i + 100)); 26 | } 27 | 28 | for (const chunk of chunks) { 29 | await cacheEvents(chunk); 30 | } 31 | } else { 32 | await cacheEvents(events); 33 | } 34 | 35 | res.status(200).json({ success: true }); 36 | } catch (error) { 37 | console.error("Failed to cache events:", error); 38 | res.status(500).json({ error: "Failed to cache events" }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /components/utility-components/__tests__/shopstr-spinner.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | import "@testing-library/jest-dom"; 4 | import ShopstrSpinner from "../shopstr-spinner"; 5 | import { useTheme } from "next-themes"; 6 | 7 | jest.mock("next-themes", () => ({ 8 | useTheme: jest.fn(), 9 | })); 10 | 11 | jest.mock("@nextui-org/react", () => ({ 12 | Spinner: (props: { color: string; size: string }) => ( 13 |
14 | ), 15 | })); 16 | 17 | const mockedUseTheme = useTheme as jest.Mock; 18 | 19 | describe("ShopstrSpinner", () => { 20 | it('should render with the "warning" color in dark mode', () => { 21 | mockedUseTheme.mockReturnValue({ theme: "dark" }); 22 | 23 | render(); 24 | 25 | const spinner = screen.getByTestId("spinner"); 26 | expect(spinner).toHaveAttribute("data-color", "warning"); 27 | }); 28 | 29 | it('should render with the "secondary" color in light mode', () => { 30 | mockedUseTheme.mockReturnValue({ theme: "light" }); 31 | 32 | render(); 33 | 34 | const spinner = screen.getByTestId("spinner"); 35 | expect(spinner).toHaveAttribute("data-color", "secondary"); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /components/my-listings/my-listings-feed.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useContext, useEffect, useState } from "react"; 4 | import MyListingsPage from "./my-listings"; 5 | import ProductForm from "../product-form"; 6 | import { useRouter } from "next/router"; 7 | import { useSearchParams } from "next/navigation"; 8 | import { SignerContext } from "@/components/utility-components/nostr-context-provider"; 9 | 10 | const MyListingsFeed = () => { 11 | const router = useRouter(); 12 | const searchParams = useSearchParams(); 13 | 14 | const [showModal, setShowModal] = useState(false); 15 | const { isLoggedIn } = useContext(SignerContext); 16 | 17 | useEffect(() => { 18 | if (!searchParams || !isLoggedIn) return; 19 | setShowModal(searchParams.has("addNewListing")); 20 | }, [searchParams, isLoggedIn]); 21 | 22 | const handleProductModalToggle = () => { 23 | setShowModal(!showModal); 24 | router.push(""); 25 | }; 26 | 27 | return ( 28 |
29 |
30 | 31 |
32 | 33 | 37 | 38 |
39 | ); 40 | }; 41 | 42 | export default MyListingsFeed; 43 | -------------------------------------------------------------------------------- /components/utility-components/dropdowns/confirm-action-dropdown.tsx: -------------------------------------------------------------------------------- 1 | // TODO Fix this function to be more react like where children shouldn't be a prop, func should be renamed to something like "onConfirm", and the modal should be a child of this component instead of a sibling 2 | import React from "react"; 3 | import { 4 | Dropdown, 5 | DropdownTrigger, 6 | DropdownMenu, 7 | DropdownItem, 8 | DropdownSection, 9 | } from "@nextui-org/react"; 10 | 11 | type ConfirmActionDropdownProps = { 12 | helpText: string; 13 | buttonLabel: string; 14 | onConfirm: () => void; 15 | children: React.ReactNode; 16 | }; 17 | export default function ConfirmActionDropdown({ 18 | helpText, 19 | buttonLabel, 20 | onConfirm, 21 | children, 22 | }: ConfirmActionDropdownProps) { 23 | return ( 24 | 25 | {children} 26 | 27 | 28 | 34 | {buttonLabel} 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { TextEncoder, TextDecoder } from "util"; 3 | 4 | global.TextEncoder = TextEncoder; 5 | global.TextDecoder = TextDecoder; 6 | 7 | const originalWarn = console.warn; 8 | const originalError = console.error; 9 | 10 | const warnSpy = jest.spyOn(console, "warn").mockImplementation((...args) => { 11 | const warnString = args.toString(); 12 | if ( 13 | warnString.includes("IndexedDB is not available") || 14 | warnString.includes("Invoice check warning") 15 | ) { 16 | return; 17 | } 18 | originalWarn(...args); 19 | }); 20 | 21 | const errorSpy = jest.spyOn(console, "error").mockImplementation((...args) => { 22 | const errorString = args.toString(); 23 | if ( 24 | errorString.includes("validateDOMNesting") || 25 | errorString.includes("An update to") || 26 | errorString.includes("React does not recognize the") || 27 | errorString.includes("Received `false` for a non-boolean attribute") || 28 | errorString.includes("disableSkeleton") 29 | ) { 30 | return; 31 | } 32 | originalError(...args); 33 | }); 34 | 35 | afterAll(() => { 36 | warnSpy.mockRestore(); 37 | errorSpy.mockRestore(); 38 | }); 39 | 40 | jest.mock("@braintree/sanitize-url", () => ({ 41 | sanitizeUrl: jest.fn((url) => (typeof url === "string" ? url : "")), 42 | })); 43 | -------------------------------------------------------------------------------- /utils/STATIC-VARIABLES.ts: -------------------------------------------------------------------------------- 1 | export const CATEGORIES = [ 2 | "Digital", 3 | "Physical", 4 | "Services", 5 | "Resale", 6 | "Exchange", 7 | "Swap", 8 | "Clothing", 9 | "Shoes", 10 | "Accessories", 11 | "Electronics", 12 | "Collectibles", 13 | "Entertainment", 14 | "Books", 15 | "Pets", 16 | "Sports", 17 | "Tickets", 18 | "Fitness", 19 | "Art", 20 | "Crafts", 21 | "Home", 22 | "Office", 23 | "Food", 24 | "Miscellaneous", 25 | ]; 26 | 27 | export type ShippingOptionsType = 28 | | "N/A" 29 | | "Free" 30 | | "Pickup" 31 | | "Free/Pickup" 32 | | "Added Cost"; 33 | 34 | export const SHIPPING_OPTIONS = [ 35 | "N/A", 36 | "Free", // free shipping you are going to ship it 37 | "Pickup", // you are only going to have someone pick it up 38 | "Free/Pickup", // you are open to do either 39 | "Added Cost", // you are going to charge for shipping 40 | ]; 41 | 42 | export const SHOPSTRBUTTONCLASSNAMES = 43 | "text-dark-text dark:text-light-text shadow-lg bg-gradient-to-tr from-shopstr-purple via-shopstr-purple-light to-shopstr-purple min-w-fit dark:from-shopstr-yellow dark:via-shopstr-yellow-light dark:to-shopstr-yellow"; 44 | 45 | export const PREVNEXTBUTTONSTYLES = 46 | "absolute z-10 top-1/2 transform -translate-y-1/2 p-2 bg-white dark:bg-neutral-800 bg-opacity-60 rounded-full shadow-md hover:bg-opacity-90 transition duration-200"; 47 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 33, 33, 33; 7 | --background-start-rgb: 232, 232, 232; 8 | --background-end-rgb: 232, 232, 232; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 232, 232, 232; 14 | --background-start-rgb: 33, 33, 33; 15 | --background-end-rgb: 33, 33, 33; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } 28 | 29 | /* Chrome, Edge, and Safari */ 30 | *::-webkit-scrollbar { 31 | width: 7px; 32 | } 33 | 34 | *::-webkit-scrollbar-track { 35 | border-radius: 5px; 36 | } 37 | 38 | *::-webkit-scrollbar-thumb { 39 | border-radius: 2px; 40 | border: 1px solid; 41 | background-color: #686868; 42 | } 43 | 44 | html { 45 | height: 100%; 46 | overflow: hidden; 47 | position: relative; 48 | } 49 | 50 | body { 51 | height: 100%; 52 | overflow: auto; 53 | position: relative; 54 | } 55 | 56 | .text-xxs { 57 | font-size: 0.63rem; /* Example of a smaller font size than text-xs */ 58 | } 59 | 60 | .break-words-all { 61 | word-break: break-all; 62 | word-wrap: break-word; 63 | overflow-wrap: break-word; 64 | } 65 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import type { NextRequest } from "next/server"; 3 | import { nip19 } from "nostr-tools"; 4 | 5 | export function middleware(request: NextRequest) { 6 | const { pathname } = request.nextUrl; 7 | 8 | // Handle npub redirects, but ignore if already in marketplace page route 9 | if ( 10 | pathname.match(/^\/npub[a-zA-Z0-9]+$/) && 11 | !pathname.startsWith("/marketplace/") 12 | ) { 13 | const url = new URL(`/marketplace${pathname}`, request.url); 14 | return NextResponse.redirect(url); 15 | } 16 | 17 | // Handle naddr redirects, but ignore if already in listing page route 18 | if ( 19 | pathname.match(/^\/naddr[a-zA-Z0-9]+$/) && 20 | !pathname.startsWith("/listing/") 21 | ) { 22 | const url = new URL(`/listing${pathname}`, request.url); 23 | return NextResponse.redirect(url); 24 | } 25 | 26 | // Handle community naddr redirects 27 | if (pathname.startsWith("/naddr") && !pathname.startsWith("/communities/")) { 28 | try { 29 | const decoded = nip19.decode(pathname.substring(1)); 30 | if (decoded.type === "naddr" && decoded.data.kind === 34550) { 31 | return NextResponse.redirect( 32 | new URL(`/communities${pathname}`, request.url) 33 | ); 34 | } 35 | } catch (e) { 36 | /* ignore */ 37 | } 38 | } 39 | 40 | return NextResponse.next(); 41 | } 42 | -------------------------------------------------------------------------------- /pages/api/db/clear-failed-publish.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { getDbPool } from "@/utils/db/db-service"; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | if (req.method !== "POST") { 9 | return res.status(405).json({ error: "Method not allowed" }); 10 | } 11 | 12 | const dbPool = getDbPool(); 13 | let client; 14 | 15 | try { 16 | const { eventId, incrementRetry } = req.body; 17 | 18 | if (!eventId) { 19 | return res.status(400).json({ error: "Invalid request body" }); 20 | } 21 | 22 | client = await dbPool.connect(); 23 | 24 | if (incrementRetry) { 25 | // Increment retry count 26 | await client.query( 27 | `UPDATE failed_relay_publishes SET retry_count = retry_count + 1 WHERE event_id = $1`, 28 | [eventId] 29 | ); 30 | } else { 31 | // Remove successful publish 32 | await client.query( 33 | `DELETE FROM failed_relay_publishes WHERE event_id = $1`, 34 | [eventId] 35 | ); 36 | } 37 | 38 | return res.status(200).json({ success: true }); 39 | } catch (error) { 40 | console.error("Error clearing failed relay publish:", error); 41 | return res.status(500).json({ error: "Internal server error" }); 42 | } finally { 43 | if (client) { 44 | client.release(); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /components/utility-components/dropdowns/country-dropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { Select, SelectItem, SelectSection } from "@nextui-org/react"; 3 | import locations from "../../../public/locationSelection.json"; 4 | 5 | const CountryDropdown = ({ _value, ...props }: { [x: string]: any }) => { 6 | const countryOptions = useMemo(() => { 7 | const headingClasses = 8 | "flex w-full sticky top-1 z-20 py-1.5 px-2 dark:bg-dark-bg bg-light-bg shadow-small rounded-small"; 9 | 10 | const countryOptions = ( 11 | 18 | {locations.countries.map((country) => { 19 | return ( 20 | 27 | {country.country} 28 | 29 | ); 30 | })} 31 | 32 | ); 33 | return [countryOptions]; 34 | }, []); 35 | 36 | return ( 37 | 40 | ); 41 | }; 42 | 43 | export default CountryDropdown; 44 | -------------------------------------------------------------------------------- /pages/api/db/get-failed-publishes.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { getDbPool } from "@/utils/db/db-service"; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | if (req.method !== "GET") { 9 | return res.status(405).json({ error: "Method not allowed" }); 10 | } 11 | 12 | const dbPool = getDbPool(); 13 | let client; 14 | 15 | try { 16 | client = await dbPool.connect(); 17 | 18 | // Get all failed publishes with retry count < 5 (limit retries) 19 | const result = await client.query( 20 | `SELECT fp.event_id, fp.relays, fp.retry_count, e.event_data 21 | FROM failed_relay_publishes fp 22 | LEFT JOIN events e ON fp.event_id = e.id 23 | WHERE fp.retry_count < 5 24 | ORDER BY fp.created_at ASC 25 | LIMIT 50` 26 | ); 27 | 28 | const failedPublishes = result.rows 29 | .filter((row: any) => row.event_data) 30 | .map((row: any) => ({ 31 | eventId: row.event_id, 32 | relays: JSON.parse(row.relays), 33 | event: JSON.parse(row.event_data), 34 | retryCount: row.retry_count, 35 | })); 36 | 37 | return res.status(200).json(failedPublishes); 38 | } catch (error) { 39 | console.error("Error getting failed relay publishes:", error); 40 | return res.status(500).json({ error: "Internal server error" }); 41 | } finally { 42 | if (client) { 43 | client.release(); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine AS deps 2 | 3 | WORKDIR /app 4 | 5 | # Copy package files for dependency installation 6 | COPY package.json package-lock.json .npmrc ./ 7 | 8 | # Install dependencies with specific npm configuration 9 | RUN npm ci 10 | 11 | # Build stage 12 | FROM node:18-alpine AS builder 13 | 14 | WORKDIR /app 15 | 16 | # Copy dependencies from deps stage 17 | COPY --from=deps /app/node_modules ./node_modules 18 | COPY . . 19 | 20 | # Build the application 21 | RUN npm run build 22 | 23 | # Production stage 24 | FROM node:18-alpine AS runner 25 | 26 | WORKDIR /app 27 | 28 | # Set environment to production 29 | ENV NODE_ENV=production 30 | 31 | # Create a non-root user for security 32 | RUN addgroup --system --gid 1001 nodejs \ 33 | && adduser --system --uid 1001 nextjs 34 | 35 | # Copy necessary files from builder 36 | COPY --from=builder /app/next.config.cjs ./ 37 | COPY --from=builder /app/public ./public 38 | COPY --from=builder /app/.next ./.next 39 | COPY --from=builder /app/node_modules ./node_modules 40 | COPY --from=builder /app/package.json ./package.json 41 | 42 | # Set proper permissions 43 | RUN chown -R nextjs:nodejs /app 44 | 45 | # Use the non-root user 46 | USER nextjs 47 | 48 | # Expose the port the app runs on 49 | EXPOSE 3000 50 | 51 | # Add healthcheck 52 | HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ 53 | CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1 54 | 55 | # Start the application 56 | CMD ["npm", "run", "start"] 57 | -------------------------------------------------------------------------------- /.github/workflows/vercel-preview.yml: -------------------------------------------------------------------------------- 1 | name: Vercel Preview Deployment 2 | 3 | on: 4 | pull_request_target: 5 | types: [opened, synchronize, reopened] 6 | branches: [main] 7 | 8 | permissions: 9 | contents: read 10 | pull-requests: write 11 | 12 | jobs: 13 | deploy-preview: 14 | name: Deploy Preview to Vercel 15 | runs-on: ubuntu-latest 16 | 17 | # Add manual approval requirement for external PRs 18 | environment: 19 | name: preview-deployment 20 | 21 | steps: 22 | - name: Check out code 23 | uses: actions/checkout@v4 24 | with: 25 | # For pull_request_target, we need to checkout the PR branch 26 | ref: ${{ github.event.pull_request.head.sha }} 27 | token: ${{ secrets.GITHUB_TOKEN }} 28 | fetch-depth: 0 29 | 30 | - name: Set up Node.js 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version: 20.x 34 | cache: 'npm' 35 | 36 | - name: Install dependencies 37 | run: npm ci 38 | 39 | - name: Deploy to Vercel 40 | uses: amondnet/vercel-action@v25 41 | with: 42 | vercel-token: ${{ secrets.VERCEL_TOKEN }} 43 | vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} 44 | vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} 45 | github-token: ${{ secrets.GITHUB_TOKEN }} 46 | github-comment: true 47 | alias-domains: | 48 | shopstr-pr-${{ github.event.number }}.vercel.app -------------------------------------------------------------------------------- /components/hooks/__tests__/use-navigation.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react"; 2 | import { usePathname } from "next/navigation"; 3 | import useNavigation from "../use-navigation"; 4 | 5 | jest.mock("next/navigation", () => ({ 6 | usePathname: jest.fn(), 7 | })); 8 | 9 | const mockedUsePathname = usePathname as jest.Mock; 10 | 11 | describe("useNavigation Hook", () => { 12 | const testCases = [ 13 | { path: "/marketplace", activeFlag: "isHomeActive" }, 14 | { path: "/orders", activeFlag: "isMessagesActive" }, 15 | { path: "/wallet", activeFlag: "isWalletActive" }, 16 | { path: "/my-listings", activeFlag: "isMyListingsActive" }, 17 | { path: "/settings", activeFlag: "isProfileActive" }, 18 | { path: "/unknown-path", activeFlag: null }, // A case where no flag should be active 19 | ]; 20 | 21 | it.each(testCases)( 22 | "should set $activeFlag to true when path is $path", 23 | ({ path, activeFlag }) => { 24 | mockedUsePathname.mockReturnValue(path); 25 | 26 | const { result } = renderHook(() => useNavigation()); 27 | 28 | // Check every flag 29 | Object.keys(result.current).forEach((key) => { 30 | if (key === activeFlag) { 31 | // The expected active flag should be true 32 | expect(result.current[key as keyof typeof result.current]).toBe(true); 33 | } else { 34 | // All other flags should be false 35 | expect(result.current[key as keyof typeof result.current]).toBe( 36 | false 37 | ); 38 | } 39 | }); 40 | } 41 | ); 42 | }); 43 | -------------------------------------------------------------------------------- /components/settings/settings-bread-crumbs.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Breadcrumbs, BreadcrumbItem, Divider } from "@nextui-org/react"; 3 | import { useRouter } from "next/router"; 4 | 5 | const pathMap: { [key: string]: string } = { 6 | settings: "Settings", 7 | "user-profile": "User Profile", 8 | preferences: "Preferences", 9 | "shop-profile": "Shop Profile", 10 | community: "Community Management", 11 | }; 12 | 13 | export const SettingsBreadCrumbs = () => { 14 | const router = useRouter(); 15 | const path = router.pathname.split("/").splice(1); 16 | 17 | return ( 18 | <> 19 | 26 | {path.map((p, i) => { 27 | const itemClassName = 28 | "ml-2 text-light-text dark:text-dark-text text-2xl font-bold" + 29 | (i !== path.length - 1 ? " opacity-50 hover:opacity-100" : ""); 30 | return ( 31 | { 34 | router.push(`/${p}`); 35 | }} 36 | classNames={{ 37 | item: itemClassName, 38 | separator: 39 | "text-shopstr-purple-light dark:text-shopstr-yellow-light text-2xl", 40 | }} 41 | > 42 | {pathMap[p]} 43 | 44 | ); 45 | })} 46 | 47 | 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /components/utility-components/failure-modal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, ModalContent, ModalHeader, ModalBody } from "@nextui-org/react"; 2 | import { XCircleIcon } from "@heroicons/react/24/outline"; 3 | 4 | export default function FailureModal({ 5 | bodyText, 6 | isOpen, 7 | onClose, 8 | }: { 9 | bodyText: string; 10 | isOpen: boolean; 11 | onClose: () => void; 12 | }) { 13 | return ( 14 | <> 15 | 32 | 33 | 34 | 35 |
Error
36 |
37 | 38 |
{bodyText}
39 |
40 |
41 |
42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /components/utility-components/success-modal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, ModalContent, ModalHeader, ModalBody } from "@nextui-org/react"; 2 | import { CheckCircleIcon } from "@heroicons/react/24/outline"; 3 | 4 | export default function FailureModal({ 5 | bodyText, 6 | isOpen, 7 | onClose, 8 | }: { 9 | bodyText: string; 10 | isOpen: boolean; 11 | onClose: () => void; 12 | }) { 13 | return ( 14 | <> 15 | 32 | 33 | 34 | 35 |
Success
36 |
37 | 38 |
{bodyText}
39 |
40 |
41 |
42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /components/communities/CommunityCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Card, CardHeader, CardBody, Image, Button } from "@nextui-org/react"; 3 | import { Community } from "@/utils/types/types"; 4 | import { useRouter } from "next/router"; 5 | import { nip19 } from "nostr-tools"; 6 | import { sanitizeUrl } from "@braintree/sanitize-url"; 7 | 8 | interface CommunityCardProps { 9 | community: Community; 10 | } 11 | 12 | const CommunityCard: React.FC = ({ community }) => { 13 | const router = useRouter(); 14 | 15 | const handleVisit = () => { 16 | const naddr = nip19.naddrEncode({ 17 | identifier: community.d, 18 | pubkey: community.pubkey, 19 | kind: 34550, 20 | }); 21 | router.push(`/communities/${naddr}`); 22 | }; 23 | 24 | return ( 25 | 26 | 27 |

Community

28 |

{community.name}

29 |
30 | 31 | {community.name} 37 |

38 | {community.description} 39 |

40 | 43 |
44 |
45 | ); 46 | }; 47 | 48 | export default CommunityCard; 49 | -------------------------------------------------------------------------------- /utils/timeout.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The callback function that will be called when the promise is created 3 | * @param resolve - The function that will be called to resolve the promise 4 | * @param reject - The function that will be called to reject the promise 5 | * @param abortSignal - The AbortSignal that will be triggered when the promise is aborted 6 | */ 7 | export type PromiseWithTimeoutCallback = ( 8 | resolve: (val: T) => void, 9 | reject: (err: Error) => void, 10 | abortSignal: AbortSignal 11 | ) => any; 12 | 13 | /** 14 | * Create a new promise that will be rejected after a timeout 15 | * @param callback - The function that will be called with the resolve and reject functions of the promise and an AbortSignal 16 | * @param param1 17 | * @returns 18 | */ 19 | export async function newPromiseWithTimeout( 20 | callback: PromiseWithTimeoutCallback, 21 | { timeout = 60000 }: { timeout?: number } = {} 22 | ): Promise { 23 | return await new Promise( 24 | (resolve: (val: T) => void, reject: (err: Error) => void) => { 25 | const abortController = new AbortController(); 26 | const abortSignal = abortController.signal; 27 | 28 | const timeoutId = setTimeout(() => { 29 | abortController.abort(); 30 | reject(new Error("Timeout")); 31 | }, timeout); 32 | 33 | function wrap(f: (val: X) => void): (val: X) => void { 34 | return (val: X) => { 35 | clearTimeout(timeoutId); 36 | f(val); 37 | }; 38 | } 39 | const p = callback(wrap(resolve), wrap(reject), abortSignal); 40 | if (p && p instanceof Promise) { 41 | p.catch((err) => wrap(reject)(err)); 42 | } 43 | } 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /pages/api/db/track-failed-publish.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { getDbPool } from "@/utils/db/db-service"; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | if (req.method !== "POST") { 9 | return res.status(405).json({ error: "Method not allowed" }); 10 | } 11 | 12 | const dbPool = getDbPool(); 13 | let client; 14 | 15 | try { 16 | const { eventId, relays } = req.body; 17 | 18 | if (!eventId || !relays || !Array.isArray(relays)) { 19 | return res.status(400).json({ error: "Invalid request body" }); 20 | } 21 | 22 | client = await dbPool.connect(); 23 | 24 | // Create table if it doesn't exist 25 | await client.query(` 26 | CREATE TABLE IF NOT EXISTS failed_relay_publishes ( 27 | event_id TEXT PRIMARY KEY, 28 | relays TEXT NOT NULL, 29 | created_at BIGINT NOT NULL, 30 | retry_count INTEGER DEFAULT 0 31 | ) 32 | `); 33 | 34 | // Insert or update the failed publish record 35 | await client.query( 36 | `INSERT INTO failed_relay_publishes (event_id, relays, created_at, retry_count) 37 | VALUES ($1, $2, $3, 0) 38 | ON CONFLICT (event_id) DO UPDATE SET 39 | relays = EXCLUDED.relays, 40 | created_at = EXCLUDED.created_at`, 41 | [eventId, JSON.stringify(relays), Math.floor(Date.now() / 1000)] 42 | ); 43 | 44 | return res.status(200).json({ success: true }); 45 | } catch (error) { 46 | console.error("Error tracking failed relay publish:", error); 47 | return res.status(500).json({ error: "Internal server error" }); 48 | } finally { 49 | if (client) { 50 | client.release(); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /components/hooks/use-navigation.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | import { usePathname } from "next/navigation"; 6 | 7 | const useNavigation = () => { 8 | const pathname = usePathname(); 9 | const [isHomeActive, setIsHomeActive] = useState(false); 10 | const [isMessagesActive, setIsMessagesActive] = useState(false); 11 | const [isWalletActive, setIsWalletActive] = useState(false); 12 | const [isMyListingsActive, setIsMyListingsActive] = useState(false); 13 | const [isProfileActive, setIsProfileActive] = useState(false); 14 | const [isCommunitiesActive, setIsCommunitiesActive] = useState(false); 15 | 16 | useEffect(() => { 17 | if (!pathname) return; 18 | setIsHomeActive(false); 19 | setIsMessagesActive(false); 20 | setIsWalletActive(false); 21 | setIsMyListingsActive(false); 22 | setIsProfileActive(false); 23 | setIsCommunitiesActive(false); 24 | 25 | if (pathname.startsWith("/communities")) { 26 | setIsCommunitiesActive(true); 27 | } else { 28 | switch (pathname) { 29 | case "/marketplace": 30 | setIsHomeActive(true); 31 | break; 32 | case "/orders": 33 | setIsMessagesActive(true); 34 | break; 35 | case "/wallet": 36 | setIsWalletActive(true); 37 | break; 38 | case "/my-listings": 39 | setIsMyListingsActive(true); 40 | break; 41 | case "/settings": 42 | setIsProfileActive(true); 43 | break; 44 | } 45 | } 46 | }, [pathname]); 47 | 48 | return { 49 | isHomeActive, 50 | isMessagesActive, 51 | isWalletActive, 52 | isMyListingsActive, 53 | isProfileActive, 54 | isCommunitiesActive, 55 | }; 56 | }; 57 | 58 | export default useNavigation; 59 | -------------------------------------------------------------------------------- /utils/nostr/signers/nostr-nip07-signer.ts: -------------------------------------------------------------------------------- 1 | import { NostrEvent } from "nostr-tools"; 2 | import { NostrEventTemplate } from "@/utils/nostr/nostr-manager"; 3 | import { 4 | NostrSigner, 5 | ChallengeHandler, 6 | } from "@/utils/nostr/signers/nostr-signer"; 7 | 8 | export class NostrNIP07Signer implements NostrSigner { 9 | constructor({}) { 10 | this.checkExtension(); 11 | } 12 | 13 | public toJSON(): { [key: string]: any } { 14 | return { 15 | type: "nip07", 16 | }; 17 | } 18 | 19 | private checkExtension(): any { 20 | if (!window?.nostr) throw new Error("Nostr extension not found"); 21 | if (!window?.nostr?.nip44) { 22 | throw new Error( 23 | "Please use a NIP-44 compatible extension like Alby or nos2x" 24 | ); 25 | } 26 | } 27 | 28 | public static fromJSON( 29 | json: { [key: string]: any }, 30 | _challengeHandler: ChallengeHandler 31 | ): NostrNIP07Signer | undefined { 32 | if (json.type !== "nip07") return undefined; 33 | return new NostrNIP07Signer({}); 34 | } 35 | 36 | public async connect(): Promise { 37 | return "connected"; 38 | } 39 | 40 | public async getPubKey(): Promise { 41 | const pubkey = await window.nostr.getPublicKey(); 42 | return pubkey; 43 | } 44 | 45 | public async sign(event: NostrEventTemplate): Promise { 46 | return await window.nostr.signEvent(event); 47 | } 48 | 49 | public async encrypt(pubkey: string, plainText: string): Promise { 50 | return await window.nostr.nip44.encrypt(pubkey, plainText); 51 | } 52 | 53 | public async decrypt(pubkey: string, cipherText: string): Promise { 54 | return await window.nostr.nip44.decrypt(pubkey, cipherText); 55 | } 56 | 57 | public async close(): Promise { 58 | return; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /components/utility-components/volume-selector.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Select, SelectItem, SelectSection } from "@nextui-org/react"; 3 | 4 | interface VolumeSelectorProps { 5 | volumes: string[]; 6 | volumePrices: Map; 7 | currency: string; 8 | selectedVolume: string; 9 | onVolumeChange: (volume: string) => void; 10 | isRequired?: boolean; 11 | } 12 | 13 | export default function VolumeSelector({ 14 | volumes, 15 | volumePrices, 16 | currency, 17 | selectedVolume, 18 | onVolumeChange, 19 | isRequired = false, 20 | }: VolumeSelectorProps) { 21 | if (!volumes || volumes.length === 0) return null; 22 | 23 | return ( 24 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shopstr 2 | 3 | A global, permissionless Nostr marketplace for Bitcoin commerce. 4 | 5 | # Supported NIPs 6 | 7 | - [x] NIP-02: Follow List 8 | - [x] NIP-05: Mapping Nostr keys to DNS-based internet identifiers 9 | - [x] NIP-07: window.nostr capability for web browsers 10 | - [x] NIP-09: Event Deletion 11 | - [ ] NIP-15: Nostr Marketplace (for resilient marketplaces) 12 | - [x] NIP-17: Private Direct Messages 13 | - [x] NIP-19: bech32-encoded entities 14 | - [x] NIP-24: Extra metadata fields and tags 15 | - [x] NIP-31: Dealing with Unknown Events 16 | - [x] NIP-36: Sensitive Content 17 | - [x] NIP-40: Expiration Timestamp 18 | - [ ] NIP-42: Authentication of clients to relays 19 | - [x] NIP-46: Nostr Remote Signing 20 | - [x] NIP-47: Wallet Connect 21 | - [x] NIP-49: Private Key Encryption 22 | - [ ] NIP-50: Search Capability 23 | - [x] NIP-51: Lists 24 | - [ ] NIP-56: Reporting 25 | - [x] NIP-57: Lightning Zaps 26 | - [ ] NIP-58: Badges 27 | - [x] NIP-60: Cashu Wallet 28 | - [ ] NIP-61: Nutzaps 29 | - [x] NIP-65: Relay List Metadata 30 | - [x] NIP-72: Moderated Communities 31 | - [x] NIP-85: Reviews 32 | - [x] NIP-89: Recommended Application Handlers 33 | - [x] NIP-99: Classified Listings 34 | - [x] NIP-B7: Blossom Media 35 | 36 | # Authors 37 | 38 | - [calvadev](nostr:npub16dhgpql60vmd4mnydjut87vla23a38j689jssaqlqqlzrtqtd0kqex0nkq) 39 | - npub16dhgpql60vmd4mnydjut87vla23a38j689jssaqlqqlzrtqtd0kqex0nkq 40 | - [thomasyeung687](nostr:npub14u43en9xrzh92lmy8yk6fq3mme7vyul7x66n2zl6y35c3nt3y0lqhs3g74) 41 | - npub14u43en9xrzh92lmy8yk6fq3mme7vyul7x66n2zl6y35c3nt3y0lqhs3g74 42 | - [ericspaghetti](nostr:npub1qxda7stfxfauufa4mkgqj2lur0jdpxlpnqhqdctwl2t6akuruw3qjdkkn0) 43 | - npub1qxda7stfxfauufa4mkgqj2lur0jdpxlpnqhqdctwl2t6akuruw3qjdkkn0 44 | 45 | [![Run on Repl.it](https://replit.com/badge/github/calvadev/shopstr)](https://replit.com/new/github/calvadev/shopstr) 46 | -------------------------------------------------------------------------------- /utils/parsers/zapsnag-parser.ts: -------------------------------------------------------------------------------- 1 | import { NostrEvent } from "@/utils/types/types"; 2 | import { ProductData } from "./product-parser-functions"; 3 | 4 | export const parseZapsnagNote = (event: NostrEvent): ProductData => { 5 | const content = event.content; 6 | 7 | const priceRegex = /(?:price|cost|⚡)\s*[:=-]?\s*(\d+[\d,]*)\s*(sats?|satoshis?|usd|eur)?/i; 8 | const match = content.match(priceRegex); 9 | 10 | let price = 0; 11 | let currency = "sats"; 12 | 13 | if (match && match[1]) { 14 | price = parseInt(match[1].replace(/,/g, '')); 15 | if (match[2]) { 16 | const curr = match[2].toLowerCase(); 17 | if (curr.includes("usd")) currency = "USD"; 18 | else if (curr.includes("eur")) currency = "EUR"; 19 | } 20 | } 21 | 22 | const imageRegex = /(https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp))/i; 23 | const imageMatch = content.match(imageRegex); 24 | let image = imageMatch ? imageMatch[0] : `https://robohash.org/${event.id}`; 25 | 26 | if (!image.startsWith("http")) { 27 | image = `https://robohash.org/${event.id}`; 28 | } 29 | 30 | let cleanContent = content 31 | .replace(priceRegex, "") 32 | .replace(/#zapsnag/gi, "") 33 | .replace(imageRegex, "") 34 | .trim(); 35 | 36 | const title = cleanContent.length > 0 37 | ? (cleanContent.length > 50 ? cleanContent.substring(0, 50) + "..." : cleanContent) 38 | : "Flash Sale Item"; 39 | 40 | return { 41 | id: event.id, 42 | pubkey: event.pubkey, 43 | createdAt: event.created_at, 44 | title: title, 45 | summary: content, 46 | publishedAt: String(event.created_at), 47 | images: [image], 48 | categories: ["zapsnag"], 49 | location: "Global", 50 | price: price, 51 | currency: currency, 52 | totalCost: price, 53 | shippingType: "Free", 54 | d: "zapsnag", 55 | status: "active" 56 | }; 57 | }; -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button } from "@nextui-org/react"; 3 | import { ArrowLongLeftIcon } from "@heroicons/react/24/outline"; 4 | import { useRouter } from "next/router"; 5 | import Link from "next/link"; 6 | import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES"; 7 | 8 | export default function Custom404() { 9 | const router = useRouter(); 10 | 11 | return ( 12 |
13 |
14 |

15 | 404 16 |

17 |

18 | Page Not Found 19 |

20 |

21 | The page you're looking for doesn't exist or has been moved. 22 |

23 |
24 | 31 | 32 | 35 | 36 | 37 | 40 | 41 | 42 | 43 | 44 |
45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /utils/nostr/retry-service.ts: -------------------------------------------------------------------------------- 1 | import { NostrManager } from "./nostr-manager"; 2 | import { 3 | getFailedRelayPublishes, 4 | clearFailedRelayPublish, 5 | } from "@/utils/db/db-client"; 6 | import { newPromiseWithTimeout } from "@/utils/timeout"; 7 | 8 | export async function retryFailedRelayPublishes( 9 | nostr: NostrManager 10 | ): Promise { 11 | try { 12 | const failedPublishes = await getFailedRelayPublishes(); 13 | 14 | if (failedPublishes.length === 0) { 15 | return; 16 | } 17 | 18 | console.log(`Retrying ${failedPublishes.length} failed relay publishes...`); 19 | 20 | for (const { eventId, relays, event, retryCount } of failedPublishes) { 21 | try { 22 | // Exponential backoff delay based on retry count 23 | const delayMs = Math.min(1000 * Math.pow(2, retryCount), 30000); 24 | await new Promise((resolve) => setTimeout(resolve, delayMs)); 25 | 26 | // Attempt to publish with timeout 27 | await newPromiseWithTimeout( 28 | async (resolve, reject) => { 29 | try { 30 | await nostr.publish(event, relays); 31 | resolve(undefined); 32 | } catch (err) { 33 | reject(err as Error); 34 | } 35 | }, 36 | { timeout: 21000 } 37 | ); 38 | 39 | // Success - clear the failed publish record 40 | await clearFailedRelayPublish(eventId); 41 | console.log(`Successfully republished event ${eventId}`); 42 | } catch (error) { 43 | // Still failed - increment retry count 44 | console.warn(`Retry failed for event ${eventId}:`, error); 45 | await fetch("/api/db/clear-failed-publish", { 46 | method: "POST", 47 | headers: { "Content-Type": "application/json" }, 48 | body: JSON.stringify({ eventId, incrementRetry: true }), 49 | }).catch(console.error); 50 | } 51 | } 52 | } catch (error) { 53 | console.error("Error in retry service:", error); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.eslintrc.security.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | "eslint:recommended", 4 | "@typescript-eslint/recommended", 5 | "plugin:security/recommended", 6 | "plugin:react-hooks/recommended", 7 | ], 8 | plugins: ["security", "@typescript-eslint", "react-hooks"], 9 | parser: "@typescript-eslint/parser", 10 | parserOptions: { 11 | ecmaVersion: 2022, 12 | sourceType: "module", 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | env: { 18 | browser: true, 19 | node: true, 20 | es6: true, 21 | }, 22 | rules: { 23 | // Security-focused rules 24 | "security/detect-object-injection": "error", 25 | "security/detect-non-literal-regexp": "error", 26 | "security/detect-unsafe-regex": "error", 27 | "security/detect-buffer-noassert": "error", 28 | "security/detect-child-process": "error", 29 | "security/detect-disable-mustache-escape": "error", 30 | "security/detect-eval-with-expression": "error", 31 | "security/detect-no-csrf-before-method-override": "error", 32 | "security/detect-non-literal-fs-filename": "error", 33 | "security/detect-non-literal-require": "error", 34 | "security/detect-possible-timing-attacks": "error", 35 | "security/detect-pseudoRandomBytes": "error", 36 | 37 | // Additional security practices 38 | "no-eval": "error", 39 | "no-implied-eval": "error", 40 | "no-new-func": "error", 41 | "no-script-url": "error", 42 | "no-unsafe-innerHTML": "off", // Handled by React 43 | 44 | // TypeScript security 45 | "@typescript-eslint/no-explicit-any": "warn", 46 | "@typescript-eslint/no-unsafe-assignment": "warn", 47 | "@typescript-eslint/no-unsafe-call": "warn", 48 | "@typescript-eslint/no-unsafe-member-access": "warn", 49 | "@typescript-eslint/no-unsafe-return": "warn", 50 | }, 51 | overrides: [ 52 | { 53 | files: ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"], 54 | rules: { 55 | "security/detect-object-injection": "off", 56 | }, 57 | }, 58 | ], 59 | }; 60 | -------------------------------------------------------------------------------- /utils/messages/utils.ts: -------------------------------------------------------------------------------- 1 | import { fetchChatMessagesFromCache } from "@/utils/nostr/cache-service"; 2 | import { ChatsMap } from "../context/context"; 3 | import { NostrMessageEvent } from "../types/types"; 4 | 5 | export const timeSinceMessageDisplayText = ( 6 | timeSent: number 7 | ): { long: string; short: string; dateTime: string } => { 8 | // Calculate the time difference in milliseconds 9 | const timeDifference = new Date().getTime() - timeSent * 1000; 10 | // Convert milliseconds to minutes 11 | const minutes = Math.floor(timeDifference / (1000 * 60)); 12 | 13 | // Convert minutes to hours, days, or weeks as needed 14 | const hours = Math.floor(minutes / 60); 15 | const days = Math.floor(hours / 24); 16 | const weeks = Math.floor(days / 7); 17 | 18 | const dateTimeText = new Date(timeSent * 1000).toLocaleString(); 19 | 20 | // Output the result 21 | if (weeks > 0) { 22 | return { 23 | long: `${weeks} weeks ago`, 24 | short: `${weeks}w`, 25 | dateTime: dateTimeText, 26 | }; 27 | } else if (days > 0) { 28 | return { 29 | long: `${days} days ago`, 30 | short: `${days}d`, 31 | dateTime: dateTimeText, 32 | }; 33 | } else if (hours > 0) { 34 | return { 35 | long: `${hours} hours ago`, 36 | short: `${hours}h`, 37 | dateTime: dateTimeText, 38 | }; 39 | } else { 40 | return { 41 | long: `${minutes} minutes ago`, 42 | short: `${minutes}m`, 43 | dateTime: dateTimeText, 44 | }; 45 | } 46 | }; 47 | 48 | export const countNumberOfUnreadMessagesFromChatsContext = async ( 49 | chatsMap: ChatsMap 50 | ) => { 51 | const chatMessagesFromCache: Map = 52 | await fetchChatMessagesFromCache(); 53 | let numberOfUnread = 0; 54 | for (const entry of chatsMap) { 55 | const chat = entry[1] as NostrMessageEvent[]; 56 | chat.forEach((messageEvent: NostrMessageEvent) => { 57 | if (chatMessagesFromCache.get(messageEvent.id)?.read === false) { 58 | numberOfUnread++; 59 | } 60 | }); 61 | } 62 | return numberOfUnread; 63 | }; 64 | -------------------------------------------------------------------------------- /pages/marketplace/[[...npub]].tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | 3 | import React from "react"; 4 | import HomeFeed from "@/components/home/home-feed"; 5 | 6 | export default function SellerView({ 7 | focusedPubkey, 8 | setFocusedPubkey, 9 | selectedSection, 10 | setSelectedSection, 11 | }: { 12 | focusedPubkey: string; 13 | setFocusedPubkey: (value: string) => void; 14 | selectedSection: string; 15 | setSelectedSection: (value: string) => void; 16 | }) { 17 | return ( 18 | <> 19 | {!focusedPubkey && ( 20 |
21 | Shopstr Banner 26 | Shopstr Banner 31 | Shopstr Banner 36 | Shopstr Banner 41 |
42 | )} 43 |
48 | 54 |
55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /components/utility-components/__tests__/failure-modal.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | import "@testing-library/jest-dom"; 4 | import FailureModal from "../failure-modal"; 5 | 6 | jest.mock("@heroicons/react/24/outline", () => ({ 7 | XCircleIcon: () =>
, 8 | })); 9 | 10 | jest.mock("@nextui-org/react", () => ({ 11 | Modal: ({ 12 | isOpen, 13 | children, 14 | }: { 15 | isOpen: boolean; 16 | children: React.ReactNode; 17 | }) => (isOpen ?
{children}
: null), 18 | ModalContent: ({ children }: { children: React.ReactNode }) => ( 19 |
{children}
20 | ), 21 | ModalHeader: ({ children }: { children: React.ReactNode }) => ( 22 |
{children}
23 | ), 24 | ModalBody: ({ children }: { children: React.ReactNode }) => ( 25 |
{children}
26 | ), 27 | })); 28 | 29 | describe("FailureModal", () => { 30 | const mockOnClose = jest.fn(); 31 | const defaultProps = { 32 | isOpen: true, 33 | onClose: mockOnClose, 34 | bodyText: "This is a test failure message.", 35 | }; 36 | 37 | beforeEach(() => { 38 | mockOnClose.mockClear(); 39 | }); 40 | 41 | it("should not render when isOpen is false", () => { 42 | render(); 43 | expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); 44 | }); 45 | 46 | it("should render correctly when isOpen is true", () => { 47 | render(); 48 | expect(screen.getByRole("dialog")).toBeInTheDocument(); 49 | }); 50 | 51 | it("should display the static error header and icon", () => { 52 | render(); 53 | expect(screen.getByText("Error")).toBeInTheDocument(); 54 | expect(screen.getByTestId("x-circle-icon")).toBeInTheDocument(); 55 | }); 56 | 57 | it("should display the provided bodyText", () => { 58 | render(); 59 | expect(screen.getByText(defaultProps.bodyText)).toBeInTheDocument(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /next.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | const withPWA = require("next-pwa")({ 4 | dest: "public", 5 | register: true, 6 | skipWaiting: true, 7 | sw: { 8 | swSrc: "./public/service-worker.js", 9 | swDest: "service-worker.js", 10 | }, 11 | }); 12 | 13 | const nextConfig = { 14 | output: "standalone", 15 | reactStrictMode: true, 16 | pwa: { 17 | dest: "public", 18 | disable: process.env.NODE_ENV === "development", 19 | runtimeCaching: [ 20 | { 21 | // Cache static assets 22 | urlPattern: /^https:\/\/.*\.(png|jpg|jpeg|svg|gif|ico|css|js)$/, 23 | handler: "CacheFirst", 24 | options: { 25 | cacheName: "static-assets", 26 | expiration: { 27 | maxEntries: 200, 28 | maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week 29 | }, 30 | }, 31 | }, 32 | { 33 | // Cache API responses 34 | urlPattern: /^https:\/\/.*\/api\/.*/, 35 | handler: "NetworkFirst", 36 | options: { 37 | cacheName: "api-cache", 38 | networkTimeoutSeconds: 10, 39 | expiration: { 40 | maxEntries: 50, 41 | maxAgeSeconds: 24 * 60 * 60, // 1 day 42 | }, 43 | }, 44 | }, 45 | { 46 | // Cache other requests 47 | urlPattern: /^https?.*/, 48 | handler: "NetworkFirst", 49 | options: { 50 | cacheName: "general-cache", 51 | networkTimeoutSeconds: 15, 52 | expiration: { 53 | maxEntries: 100, 54 | maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week 55 | }, 56 | }, 57 | }, 58 | ], 59 | }, 60 | images: { 61 | domains: [ 62 | "www.google.com", 63 | "www.facebook.com", 64 | "www.twitter.com", 65 | "www.instagram.com", 66 | "duckduckgo.com", 67 | "www.youtube.com", 68 | "www.pinterest.com", 69 | "www.linkedin.com", 70 | "www.reddit.com", 71 | "www.quora.com", 72 | "www.wikipedia.org", 73 | ], 74 | }, 75 | }; 76 | 77 | module.exports = withPWA(nextConfig); 78 | -------------------------------------------------------------------------------- /components/utility-components/__tests__/success-modal.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | import "@testing-library/jest-dom"; 4 | import SuccessModal from "../success-modal"; 5 | 6 | jest.mock("@heroicons/react/24/outline", () => ({ 7 | CheckCircleIcon: () =>
, 8 | })); 9 | 10 | jest.mock("@nextui-org/react", () => ({ 11 | Modal: ({ 12 | isOpen, 13 | children, 14 | }: { 15 | isOpen: boolean; 16 | children: React.ReactNode; 17 | }) => (isOpen ?
{children}
: null), 18 | ModalContent: ({ children }: { children: React.ReactNode }) => ( 19 |
{children}
20 | ), 21 | ModalHeader: ({ children }: { children: React.ReactNode }) => ( 22 |
{children}
23 | ), 24 | ModalBody: ({ children }: { children: React.ReactNode }) => ( 25 |
{children}
26 | ), 27 | })); 28 | 29 | describe("SuccessModal", () => { 30 | const mockOnClose = jest.fn(); 31 | const defaultProps = { 32 | isOpen: true, 33 | onClose: mockOnClose, 34 | bodyText: "Your operation was successful.", 35 | }; 36 | 37 | beforeEach(() => { 38 | mockOnClose.mockClear(); 39 | }); 40 | 41 | it("should not render when isOpen is false", () => { 42 | render(); 43 | expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); 44 | }); 45 | 46 | it("should render correctly when isOpen is true", () => { 47 | render(); 48 | expect(screen.getByRole("dialog")).toBeInTheDocument(); 49 | }); 50 | 51 | it("should display the static success header and icon", () => { 52 | render(); 53 | expect(screen.getByText("Success")).toBeInTheDocument(); 54 | expect(screen.getByTestId("check-circle-icon")).toBeInTheDocument(); 55 | }); 56 | 57 | it("should display the provided bodyText", () => { 58 | render(); 59 | expect(screen.getByText(defaultProps.bodyText)).toBeInTheDocument(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /pages/onboarding/user-profile.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useRouter } from "next/router"; 3 | import { Card, CardBody, Button, Image } from "@nextui-org/react"; 4 | import { ArrowLongRightIcon } from "@heroicons/react/24/outline"; 5 | import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES"; 6 | import UserProfileForm from "@/components/settings/user-profile-form"; 7 | 8 | const OnboardingUserProfile = () => { 9 | const router = useRouter(); 10 | 11 | const handleNext = () => { 12 | router.push("/onboarding/wallet"); 13 | }; 14 | 15 | return ( 16 |
17 |
18 | 19 | 20 |
21 | Shopstr logo 28 |

29 | Shopstr 30 |

31 |
32 |
33 |

34 | Step 2: Setup Your Profile 35 |

36 |

37 | Set up your user profile or skip this step to continue. 38 |

39 |
40 | 41 | 42 | 43 |
44 | 47 |
48 |
49 |
50 |
51 |
52 | ); 53 | }; 54 | 55 | export default OnboardingUserProfile; 56 | -------------------------------------------------------------------------------- /utils/__tests__/keypress-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook, act, fireEvent } from "@testing-library/react"; 2 | import { useKeyPress } from "../keypress-handler"; 3 | 4 | describe("useKeyPress Hook", () => { 5 | it("should return false initially before any key is pressed", () => { 6 | const { result } = renderHook(() => useKeyPress("Enter")); 7 | expect(result.current).toBe(false); 8 | }); 9 | 10 | it("should return true when the target key is pressed down", () => { 11 | const { result } = renderHook(() => useKeyPress("a")); 12 | 13 | act(() => { 14 | fireEvent.keyDown(window, { key: "a" }); 15 | }); 16 | 17 | expect(result.current).toBe(true); 18 | }); 19 | 20 | it("should return to false when the target key is released", () => { 21 | const { result } = renderHook(() => useKeyPress("Shift")); 22 | 23 | act(() => { 24 | fireEvent.keyDown(window, { key: "Shift" }); 25 | }); 26 | expect(result.current).toBe(true); 27 | 28 | act(() => { 29 | fireEvent.keyUp(window, { key: "Shift" }); 30 | }); 31 | expect(result.current).toBe(false); 32 | }); 33 | 34 | it("should not change state when a non-target key is pressed", () => { 35 | const { result } = renderHook(() => useKeyPress("Enter")); 36 | 37 | act(() => { 38 | fireEvent.keyDown(window, { key: "Escape" }); 39 | }); 40 | 41 | expect(result.current).toBe(false); 42 | }); 43 | 44 | it("should correctly add and remove event listeners", () => { 45 | const addEventSpy = jest.spyOn(window, "addEventListener"); 46 | const removeEventSpy = jest.spyOn(window, "removeEventListener"); 47 | 48 | const { unmount } = renderHook(() => useKeyPress("anyKey")); 49 | 50 | expect(addEventSpy).toHaveBeenCalledWith("keydown", expect.any(Function)); 51 | expect(addEventSpy).toHaveBeenCalledWith("keyup", expect.any(Function)); 52 | 53 | unmount(); 54 | 55 | expect(removeEventSpy).toHaveBeenCalledWith( 56 | "keydown", 57 | expect.any(Function) 58 | ); 59 | expect(removeEventSpy).toHaveBeenCalledWith("keyup", expect.any(Function)); 60 | 61 | addEventSpy.mockRestore(); 62 | removeEventSpy.mockRestore(); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /components/utility-components/shopstr-slider.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useContext } from "react"; 2 | import { Button } from "@nextui-org/react"; 3 | import { Slider } from "@nextui-org/react"; 4 | import { useTheme } from "next-themes"; 5 | import { FollowsContext } from "../../utils/context/context"; 6 | import { getLocalStorageData } from "@/utils/nostr/nostr-helper-functions"; 7 | import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES"; 8 | 9 | const ShopstrSlider = () => { 10 | const { theme } = useTheme(); 11 | 12 | const followsContext = useContext(FollowsContext); 13 | 14 | const [wot, setWot] = useState(getLocalStorageData().wot); 15 | const [wotIsChanged, setWotIsChanged] = useState(false); 16 | 17 | useEffect(() => { 18 | localStorage.setItem("wot", String(wot)); 19 | }, [wot]); 20 | 21 | const refreshPage = () => { 22 | window.location.reload(); 23 | setWotIsChanged(false); 24 | }; 25 | 26 | return ( 27 | <> 28 |
29 | { 44 | if (Array.isArray(value)) { 45 | setWot(value[0]!); 46 | } else { 47 | setWot(value); 48 | } 49 | setWotIsChanged(true); 50 | }} 51 | /> 52 |
53 | {wotIsChanged && ( 54 |
55 | 58 |
59 | )} 60 | 61 | ); 62 | }; 63 | 64 | export default ShopstrSlider; 65 | -------------------------------------------------------------------------------- /utils/__tests__/images.test.ts: -------------------------------------------------------------------------------- 1 | import { buildSrcSet } from "../images"; 2 | 3 | describe("buildSrcSet", () => { 4 | it("should return a formatted srcset for image.nostr.build URLs", () => { 5 | const imageUrl = 6 | "https://image.nostr.build/d8d59524f2f49a2a72a15c54f4a9b3c3e2b2b1a8f9a2a72a15c54f4a9b3c3e2b.jpg"; 7 | const expectedSrcSet = 8 | "https://image.nostr.build/resp/240p/d8d59524f2f49a2a72a15c54f4a9b3c3e2b2b1a8f9a2a72a15c54f4a9b3c3e2b.jpg 240w, " + 9 | "https://image.nostr.build/resp/480p/d8d59524f2f49a2a72a15c54f4a9b3c3e2b2b1a8f9a2a72a15c54f4a9b3c3e2b.jpg 480w, " + 10 | "https://image.nostr.build/resp/720p/d8d59524f2f49a2a72a15c54f4a9b3c3e2b2b1a8f9a2a72a15c54f4a9b3c3e2b.jpg 720w, " + 11 | "https://image.nostr.build/resp/1080p/d8d59524f2f49a2a72a15c54f4a9b3c3e2b2b1a8f9a2a72a15c54f4a9b3c3e2b.jpg 1080w"; 12 | 13 | expect(buildSrcSet(imageUrl)).toBe(expectedSrcSet); 14 | }); 15 | 16 | it("should return a formatted srcset for i.nostr.build URLs", () => { 17 | const imageUrl = "https://i.nostr.build/another-image.png"; 18 | const expectedSrcSet = 19 | "https://i.nostr.build/resp/240p/another-image.png 240w, " + 20 | "https://i.nostr.build/resp/480p/another-image.png 480w, " + 21 | "https://i.nostr.build/resp/720p/another-image.png 720w, " + 22 | "https://i.nostr.build/resp/1080p/another-image.png 1080w"; 23 | 24 | expect(buildSrcSet(imageUrl)).toBe(expectedSrcSet); 25 | }); 26 | 27 | it("should return the original URL for an unknown but valid host", () => { 28 | const imageUrl = "https://example.com/images/photo.gif"; 29 | expect(buildSrcSet(imageUrl)).toBe(imageUrl); 30 | }); 31 | 32 | it("should return the original string if it is not a valid URL", () => { 33 | const invalidUrl = "not-a-url"; 34 | expect(buildSrcSet(invalidUrl)).toBe(invalidUrl); 35 | }); 36 | 37 | it("should return the original string for a local path", () => { 38 | const localPath = "/images/local-image.jpg"; 39 | expect(buildSrcSet(localPath)).toBe(localPath); 40 | }); 41 | 42 | it("should return an empty string if the input is empty", () => { 43 | const emptyString = ""; 44 | expect(buildSrcSet(emptyString)).toBe(emptyString); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /components/utility-components/profile/profile-avatar.tsx: -------------------------------------------------------------------------------- 1 | import { ProfileMapContext } from "@/utils/context/context"; 2 | import { User } from "@nextui-org/react"; 3 | import { nip19 } from "nostr-tools"; 4 | import { useContext, useEffect, useState } from "react"; 5 | 6 | export const ProfileAvatar = ({ 7 | pubkey, 8 | description, 9 | baseClassname, 10 | descriptionClassname, 11 | wrapperClassname, 12 | }: { 13 | pubkey: string; 14 | description?: string; 15 | descriptionClassname?: string; 16 | baseClassname?: string; 17 | wrapperClassname?: string; 18 | }) => { 19 | const [pfp, setPfp] = useState(""); 20 | const [displayName, setDisplayName] = useState(""); 21 | const [isNip05Verified, setIsNip05Verified] = useState(false); 22 | const profileContext = useContext(ProfileMapContext); 23 | const npub = pubkey ? nip19.npubEncode(pubkey) : ""; 24 | useEffect(() => { 25 | const profileMap = profileContext.profileData; 26 | const profile = profileMap.has(pubkey) ? profileMap.get(pubkey) : undefined; 27 | setDisplayName(() => { 28 | let name = profile && profile.content.name ? profile.content.name : npub; 29 | if (profile?.content?.nip05 && profile.nip05Verified) { 30 | name = profile.content.nip05; 31 | } 32 | name = name.length > 20 ? name.slice(0, 20) + "..." : name; 33 | return name; 34 | }); 35 | 36 | setPfp( 37 | profile && profile.content.picture 38 | ? profile.content.picture 39 | : `https://robohash.org/${pubkey}` 40 | ); 41 | setIsNip05Verified(profile?.nip05Verified || false); 42 | }, [profileContext, pubkey, npub]); 43 | 44 | return ( 45 |
24 | } 25 | isOpen={isOpen} 26 | onOpenChange={(open) => setIsOpen(open)} 27 | placement="bottom" 28 | offset={-32} 29 | motionProps={{ 30 | variants: { 31 | exit: { 32 | opacity: 0, 33 | transition: { 34 | duration: 0.075, 35 | ease: "easeIn", 36 | }, 37 | }, 38 | enter: { 39 | opacity: 1, 40 | transition: { 41 | duration: 0.1, 42 | ease: "easeOut", 43 | }, 44 | }, 45 | }, 46 | }} 47 | isDisabled={categoryChips.length <= 1} 48 | classNames={{ 49 | base: "bg-transparent border-none shadow-none", 50 | }} 51 | > 52 |
{ 55 | setIsOpen(true); 56 | }} 57 | > 58 | {isOpen ? ( 59 | {validCategories[0]} 60 | ) : ( 61 | 62 | {validCategories[0]} 63 | {categoryChips.length > 1 ? , ... : null} 64 | 65 | )} 66 |
67 | 68 | )} 69 | 70 | ); 71 | }; 72 | 73 | export default CompactCategories; 74 | -------------------------------------------------------------------------------- /utils/nostr/encryption-migration.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getLocalStorageData, 3 | setLocalStorageDataOnSignIn, 4 | } from "./nostr-helper-functions"; 5 | import { NostrNSecSigner } from "./signers/nostr-nsec-signer"; 6 | 7 | let migrationAttempted = false; 8 | 9 | function findEncryptedKey() { 10 | const storedData = getLocalStorageData(); 11 | 12 | if (storedData.encryptedPrivateKey) { 13 | return { 14 | key: storedData.encryptedPrivateKey, 15 | inSigner: false, 16 | }; 17 | } 18 | 19 | if ( 20 | storedData.signer?.type === "nsec" && 21 | storedData.signer.encryptedPrivKey 22 | ) { 23 | return { 24 | key: storedData.signer.encryptedPrivKey, 25 | inSigner: true, 26 | signer: storedData.signer, 27 | }; 28 | } 29 | 30 | return { key: null, inSigner: false }; 31 | } 32 | 33 | export function needsMigration(): boolean { 34 | if (getLocalStorageData().migrationComplete === true) { 35 | return false; 36 | } 37 | const { key } = findEncryptedKey(); 38 | 39 | return !!(key && typeof key === "string" && !key.startsWith("ncryptsec")); 40 | } 41 | 42 | export async function migrateToNip49(passphrase: string): Promise { 43 | if (migrationAttempted) return true; 44 | 45 | try { 46 | const { key, inSigner, signer } = findEncryptedKey(); 47 | 48 | if (!key || typeof key !== "string" || key.startsWith("ncryptsec")) { 49 | migrationAttempted = true; 50 | return true; 51 | } 52 | 53 | const tempSigner = new NostrNSecSigner( 54 | { 55 | encryptedPrivKey: key, 56 | passphrase: passphrase, 57 | }, 58 | () => Promise.resolve({ res: "", remind: false }) 59 | ); 60 | 61 | const privateKeyBytes = await tempSigner._getPrivKey(); 62 | const { encryptedPrivKey } = NostrNSecSigner.getEncryptedNSEC( 63 | privateKeyBytes, 64 | passphrase 65 | ); 66 | 67 | if (inSigner && signer) { 68 | setLocalStorageDataOnSignIn({ 69 | signer: { ...signer, encryptedPrivKey } as any, 70 | migrationComplete: true, 71 | }); 72 | } else { 73 | setLocalStorageDataOnSignIn({ 74 | encryptedPrivateKey: encryptedPrivKey, 75 | migrationComplete: true, 76 | }); 77 | } 78 | 79 | migrationAttempted = true; 80 | return true; 81 | } catch (error) { 82 | migrationAttempted = true; 83 | return false; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /components/hooks/__tests__/use-tabs.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from "@testing-library/react"; 2 | import React from "react"; 3 | import { useTabs, Tab } from "../use-tabs"; 4 | 5 | const mockTabs: Tab[] = [ 6 | { id: "profile", label: "Profile", children:
Profile Content
}, 7 | { id: "settings", label: "Settings", children:
Settings Content
}, 8 | { id: "billing", label: "Billing", children:
Billing Content
}, 9 | ]; 10 | 11 | describe("useTabs Hook", () => { 12 | it("should initialize with the correct tab from initialTabId", () => { 13 | const { result } = renderHook(() => 14 | useTabs({ tabs: mockTabs, initialTabId: "settings" }) 15 | ); 16 | 17 | expect(result.current.selectedTab!.id).toBe("settings"); 18 | expect(result.current.tabProps.selectedTabIndex).toBe(1); 19 | }); 20 | 21 | it("should default to the first tab if initialTabId is not found", () => { 22 | const { result } = renderHook(() => 23 | useTabs({ tabs: mockTabs, initialTabId: "non-existent-id" }) 24 | ); 25 | 26 | expect(result.current.selectedTab!.id).toBe("profile"); 27 | expect(result.current.tabProps.selectedTabIndex).toBe(0); 28 | }); 29 | 30 | it("should update the selected tab and direction when setSelectedTab is called", () => { 31 | const { result } = renderHook(() => 32 | useTabs({ tabs: mockTabs, initialTabId: "profile" }) 33 | ); 34 | 35 | expect(result.current.tabProps.selectedTabIndex).toBe(0); 36 | 37 | act(() => { 38 | // Simulate changing to the third tab (index 2) with a forward direction (1) 39 | result.current.tabProps.setSelectedTab([2, 1]); 40 | }); 41 | 42 | expect(result.current.selectedTab!.id).toBe("billing"); 43 | expect(result.current.tabProps.selectedTabIndex).toBe(2); 44 | expect(result.current.contentProps.direction).toBe(1); 45 | }); 46 | 47 | it("should pass the onChange function through its props", () => { 48 | const mockOnChange = jest.fn(); 49 | 50 | const { result } = renderHook(() => 51 | useTabs({ 52 | tabs: mockTabs, 53 | initialTabId: "profile", 54 | onChange: mockOnChange, 55 | }) 56 | ); 57 | 58 | // The hook doesn't call onChange itself, but it should pass it along 59 | // for the component to use. We verify it's the same function. 60 | expect(result.current.tabProps.onChange).toBe(mockOnChange); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /components/home/home-feed.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | 3 | "use client"; 4 | 5 | import React, { useContext, useEffect, useState } from "react"; 6 | import { ShopMapContext } from "@/utils/context/context"; 7 | import { ShopProfile } from "../../utils/types/types"; 8 | import { sanitizeUrl } from "@braintree/sanitize-url"; 9 | import { useRouter } from "next/router"; 10 | 11 | import MarketplacePage from "./marketplace"; 12 | 13 | const HomeFeed = ({ 14 | focusedPubkey, 15 | setFocusedPubkey, 16 | selectedSection, 17 | setSelectedSection, 18 | }: { 19 | focusedPubkey: string; 20 | setFocusedPubkey: (value: string) => void; 21 | selectedSection: string; 22 | setSelectedSection: (value: string) => void; 23 | }) => { 24 | const router = useRouter(); 25 | 26 | const [shopBannerURL, setShopBannerURL] = useState(""); 27 | const [isFetchingShop, setIsFetchingShop] = useState(false); 28 | 29 | const shopMapContext = useContext(ShopMapContext); 30 | 31 | useEffect(() => { 32 | setIsFetchingShop(true); 33 | if ( 34 | focusedPubkey && 35 | shopMapContext.shopData.has(focusedPubkey) && 36 | typeof shopMapContext.shopData.get(focusedPubkey) != "undefined" 37 | ) { 38 | const shopProfile: ShopProfile | undefined = 39 | shopMapContext.shopData.get(focusedPubkey); 40 | if (shopProfile) { 41 | setShopBannerURL(shopProfile.content.ui.banner); 42 | } 43 | } 44 | setIsFetchingShop(false); 45 | }, [focusedPubkey, shopMapContext, shopBannerURL, router.pathname]); 46 | 47 | return ( 48 | <> 49 | {focusedPubkey && shopBannerURL && !isFetchingShop && ( 50 |
51 | Shop Banner 56 |
57 | )} 58 |
59 |
60 | 66 |
67 |
68 | 69 | ); 70 | }; 71 | 72 | export default HomeFeed; 73 | -------------------------------------------------------------------------------- /components/messages/message-feed.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useEffect, useState } from "react"; 4 | import { useTabs } from "@/components/hooks/use-tabs"; 5 | import { Framer } from "@/components/framer"; 6 | import Messages from "./messages"; 7 | import { useRouter } from "next/router"; 8 | 9 | const MessageFeed = ({ isInquiry = false }) => { 10 | const router = useRouter(); 11 | const [showSpinner, setShowSpinner] = useState(false); 12 | 13 | const [hookProps] = useState({ 14 | tabs: [ 15 | { 16 | label: "Orders", 17 | children: , 18 | id: "orders", 19 | }, 20 | { 21 | label: "Inquiries", 22 | children: , 23 | id: "inquiries", 24 | }, 25 | ], 26 | initialTabId: "orders", 27 | }); 28 | 29 | const framer = useTabs({ 30 | tabs: hookProps.tabs, 31 | initialTabId: isInquiry ? "inquiries" : "orders", 32 | }); 33 | 34 | useEffect(() => { 35 | setShowSpinner(true); 36 | const timeout = setTimeout(() => { 37 | setShowSpinner(false); 38 | }, 1); 39 | return () => clearTimeout(timeout); 40 | }, [framer.selectedTab]); 41 | 42 | useEffect(() => { 43 | const handleRouteChange = (url: string) => { 44 | const isInquiryTab = url.includes("isInquiry=true"); 45 | const newTab = isInquiryTab ? "inquiries" : "orders"; 46 | 47 | const newIndex = hookProps.tabs.findIndex((tab) => tab.id === newTab); 48 | if (newIndex !== -1 && framer.tabProps.selectedTabIndex !== newIndex) { 49 | framer.tabProps.setSelectedTab([newIndex, 0]); 50 | } 51 | }; 52 | 53 | router.events.on("routeChangeComplete", handleRouteChange); 54 | 55 | return () => { 56 | router.events.off("routeChangeComplete", handleRouteChange); 57 | }; 58 | }, [router, framer]); 59 | 60 | return ( 61 |
62 |
63 |
64 | 65 |
66 |
67 | 68 |
69 | {showSpinner ? null : framer.selectedTab!.children} 70 |
71 |
72 | ); 73 | }; 74 | 75 | export default MessageFeed; 76 | -------------------------------------------------------------------------------- /components/messages/chat-button.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { ChatObject } from "../../utils/types/types"; 3 | import { timeSinceMessageDisplayText } from "../../utils/messages/utils"; 4 | import { ProfileAvatar } from "@/components/utility-components/profile/profile-avatar"; 5 | 6 | const ChatButton = ({ 7 | pubkeyOfChat, 8 | chatObject, 9 | openedChatPubkey, 10 | handleClickChat, 11 | }: { 12 | pubkeyOfChat: string; 13 | chatObject: ChatObject; 14 | openedChatPubkey: string; 15 | handleClickChat: (pubkey: string) => void; 16 | }) => { 17 | const messages = chatObject?.decryptedChat; 18 | const lastMessage = 19 | messages && messages.length > 0 && messages[messages.length - 1]; 20 | const unreadCount = chatObject?.unreadCount; 21 | 22 | const divRef = useRef(null); 23 | 24 | useEffect(() => { 25 | if (pubkeyOfChat === openedChatPubkey) { 26 | divRef.current?.scrollIntoView({ behavior: "smooth" }); 27 | } 28 | }, [openedChatPubkey]); 29 | 30 | return ( 31 |
handleClickChat(pubkeyOfChat)} 37 | ref={divRef} 38 | > 39 | 46 |
47 |
48 | {unreadCount > 0 ? ( 49 | 50 | {unreadCount} 51 | 52 | ) : ( 53 |
{/* spacer */}
54 | )} 55 |
56 |
57 | 58 | {lastMessage 59 | ? timeSinceMessageDisplayText(lastMessage.created_at).short 60 | : ""} 61 | 62 |
63 |
64 |
65 | ); 66 | }; 67 | 68 | export default ChatButton; 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shopstr", 3 | "version": "0.10.3", 4 | "type": "module", 5 | "private": true, 6 | "scripts": { 7 | "dev": "next dev -p 5000", 8 | "build": "next build", 9 | "start": "next start -H 0.0.0.0 -p ${PORT:-3000}", 10 | "lint": "eslint . --ext .ts,.tsx", 11 | "type-check": "tsc --noEmit", 12 | "lint-all": "npm run type-check && npm run lint", 13 | "test": "jest", 14 | "test:watch": "jest --watch" 15 | }, 16 | "dependencies": { 17 | "@braintree/sanitize-url": "7.1.0", 18 | "@cashu/cashu-ts": "2.1.0", 19 | "@getalby/lightning-tools": "5.0.1", 20 | "@getalby/sdk": "5.1.2", 21 | "@heroicons/react": "2.1.1", 22 | "@nextui-org/react": "2.2.9", 23 | "autoprefixer": "10.4.14", 24 | "crypto-js": "4.2.0", 25 | "dexie": "3.2.4", 26 | "dexie-react-hooks": "1.1.7", 27 | "eslint": "8.38.0", 28 | "eslint-config-next": "13.3.0", 29 | "framer-motion": "10.16.4", 30 | "next": "14.2.32", 31 | "next-pwa": "5.6.0", 32 | "next-themes": "0.2.1", 33 | "nostr-tools": "2.7.1", 34 | "pg": "8.11.5", 35 | "postcss": "8.4.31", 36 | "qrcode": "1.5.3", 37 | "react": "18.2.0", 38 | "react-dom": "18.2.0", 39 | "react-hook-form": "7.47.0", 40 | "react-responsive-carousel": "3.2.23", 41 | "tailwindcss": "3.3.1", 42 | "uuid": "9.0.0" 43 | }, 44 | "devDependencies": { 45 | "@testing-library/dom": "10.4.0", 46 | "@testing-library/jest-dom": "6.6.3", 47 | "@testing-library/react": "16.3.0", 48 | "@testing-library/user-event": "14.6.1", 49 | "@types/crypto-js": "4.2.2", 50 | "@types/jest": "29.5.14", 51 | "@types/node": "20.7.0", 52 | "@types/pg": "8.11.4", 53 | "@types/qrcode": "1.5.5", 54 | "@types/react": "18.2.22", 55 | "@types/react-dom": "18.0.11", 56 | "@types/uuid": "9.0.4", 57 | "@typescript-eslint/eslint-plugin": "6.21.0", 58 | "@typescript-eslint/parser": "6.21.0", 59 | "babel-jest": "30.0.4", 60 | "eslint-config-prettier": "9.1.0", 61 | "jest": "29.7.0", 62 | "jest-environment-jsdom": "29.7.0", 63 | "prettier": "3.1.0", 64 | "prettier-plugin-tailwindcss": "0.5.7", 65 | "typescript": "5.2.2" 66 | }, 67 | "resolutions": { 68 | "@types/react": "18.2.22", 69 | "@types/react-dom": "18.0.11" 70 | }, 71 | "engines": { 72 | "node": ">=18.17.0", 73 | "npm": ">=9.6.7" 74 | }, 75 | "browserslist": { 76 | "production": [ 77 | ">0.2%", 78 | "not dead", 79 | "not op_mini all" 80 | ], 81 | "development": [ 82 | "last 1 chrome version", 83 | "last 1 firefox version", 84 | "last 1 safari version" 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /components/utility-components/dropdowns/__tests__/confirm-action-dropdown.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | import userEvent from "@testing-library/user-event"; 4 | import ConfirmActionDropdown from "../confirm-action-dropdown"; 5 | 6 | jest.mock("@nextui-org/react", () => { 7 | const originalModule = jest.requireActual("@nextui-org/react"); 8 | 9 | return { 10 | ...originalModule, 11 | Dropdown: ({ children }: { children: React.ReactNode }) => ( 12 |
{children}
13 | ), 14 | DropdownTrigger: ({ children }: { children: React.ReactNode }) => ( 15 | <>{children} 16 | ), 17 | DropdownMenu: ({ children }: { children: React.ReactNode }) => ( 18 |
{children}
19 | ), 20 | DropdownSection: ({ 21 | title, 22 | children, 23 | }: { 24 | title: string; 25 | children: React.ReactNode; 26 | }) => ( 27 |
28 |

{title}

29 | {children} 30 |
31 | ), 32 | DropdownItem: ({ 33 | children, 34 | onClick, 35 | }: { 36 | children: React.ReactNode; 37 | onClick: () => void; 38 | }) => ( 39 | 42 | ), 43 | }; 44 | }); 45 | 46 | describe("ConfirmActionDropdown", () => { 47 | const mockOnConfirm = jest.fn(); 48 | const props = { 49 | helpText: "Are you sure you want to proceed?", 50 | buttonLabel: "Yes, Confirm", 51 | onConfirm: mockOnConfirm, 52 | children: , 53 | }; 54 | 55 | beforeEach(() => { 56 | mockOnConfirm.mockClear(); 57 | }); 58 | 59 | it("renders the trigger component correctly", () => { 60 | render(); 61 | 62 | expect( 63 | screen.getByRole("button", { name: /actions/i }) 64 | ).toBeInTheDocument(); 65 | }); 66 | 67 | it("renders the help text and confirmation button", () => { 68 | render(); 69 | 70 | expect( 71 | screen.getByText("Are you sure you want to proceed?") 72 | ).toBeInTheDocument(); 73 | expect( 74 | screen.getByRole("menuitem", { name: /yes, confirm/i }) 75 | ).toBeInTheDocument(); 76 | }); 77 | 78 | it("calls the onConfirm callback when the confirmation item is clicked", async () => { 79 | const user = userEvent.setup(); 80 | render(); 81 | 82 | const confirmItem = screen.getByRole("menuitem", { name: /yes, confirm/i }); 83 | 84 | await user.click(confirmItem); 85 | 86 | expect(mockOnConfirm).toHaveBeenCalledTimes(1); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /components/utility-components/dropdowns/__tests__/country-dropdown.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | import CountryDropdown from "../country-dropdown"; 4 | import { Select, SelectItem } from "@nextui-org/react"; 5 | 6 | jest.mock("../../../../public/locationSelection.json", () => ({ 7 | countries: [ 8 | { country: "India" }, 9 | { country: "United States" }, 10 | { country: "Canada" }, 11 | ], 12 | })); 13 | 14 | jest.mock("@nextui-org/react", () => { 15 | const originalModule = jest.requireActual("@nextui-org/react"); 16 | return { 17 | ...originalModule, 18 | Select: jest.fn(({ children, _classNames, ...props }) => ( 19 |
{children}
20 | )), 21 | SelectSection: jest.fn(({ children, _classNames, ...props }) => ( 22 |
{children}
23 | )), 24 | SelectItem: jest.fn(({ children, _classNames, ...props }) => ( 25 |
{children}
26 | )), 27 | }; 28 | }); 29 | 30 | const MockSelect = Select as jest.Mock; 31 | const MockSelectItem = SelectItem as jest.Mock; 32 | 33 | describe("CountryDropdown", () => { 34 | beforeEach(() => { 35 | jest.clearAllMocks(); 36 | }); 37 | 38 | it("should render the select component with all passed props", () => { 39 | render(); 40 | 41 | expect(MockSelect).toHaveBeenCalledWith( 42 | expect.objectContaining({ 43 | label: "Country", 44 | placeholder: "Select a country", 45 | }), 46 | expect.anything() 47 | ); 48 | }); 49 | 50 | it("should render a list of countries from the mocked JSON file", () => { 51 | render(); 52 | 53 | expect(MockSelectItem).toHaveBeenCalledTimes(3); 54 | expect(screen.getByText("India")).toBeInTheDocument(); 55 | expect(screen.getByText("United States")).toBeInTheDocument(); 56 | expect(screen.getByText("Canada")).toBeInTheDocument(); 57 | }); 58 | 59 | it("should pass the correct value and children props to each SelectItem", () => { 60 | render(); 61 | 62 | expect(MockSelectItem).toHaveBeenCalledWith( 63 | expect.objectContaining({ 64 | value: "India", 65 | children: "India", 66 | }), 67 | expect.anything() 68 | ); 69 | 70 | expect(MockSelectItem).toHaveBeenCalledWith( 71 | expect.objectContaining({ 72 | value: "United States", 73 | children: "United States", 74 | }), 75 | expect.anything() 76 | ); 77 | 78 | expect(MockSelectItem).toHaveBeenCalledWith( 79 | expect.objectContaining({ 80 | value: "Canada", 81 | children: "Canada", 82 | }), 83 | expect.anything() 84 | ); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /.github/workflows/greeting.yml: -------------------------------------------------------------------------------- 1 | name: Greetings 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | pull_request: 7 | types: [opened] 8 | 9 | jobs: 10 | greeting: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | issues: write 14 | pull-requests: write 15 | steps: 16 | - name: Welcome new contributors for issues 17 | if: github.event_name == 'issues' 18 | uses: actions/first-interaction@v1 19 | with: 20 | repo-token: ${{ secrets.GITHUB_TOKEN }} 21 | issue-message: | 22 | 👋 Welcome to Shopstr! Thank you for opening your first issue. 23 | 24 | 🛒⚡ We're excited to have you contribute to our global, permissionless Nostr marketplace for Bitcoin commerce. 25 | 26 | 📋 **What happens next?** 27 | - Our team will review your issue and provide feedback 28 | - If it's a bug report, please ensure you've included steps to reproduce 29 | - If it's a feature request, we'll discuss the proposal with the community 30 | 31 | 📚 **Resources to help you:** 32 | - Check out our [Contributing Guide](contributing.md) for development setup 33 | - Join our community discussions for questions and help 34 | - Review our [Code of Conduct] to ensure a welcoming environment 35 | 36 | 🚀 We appreciate your contribution to building the future of decentralized Bitcoin commerce! 37 | 38 | - name: Welcome new contributors for pull requests 39 | if: github.event_name == 'pull_request' 40 | uses: actions/first-interaction@v1 41 | with: 42 | repo-token: ${{ secrets.GITHUB_TOKEN }} 43 | pr-message: | 44 | 🎉 Congratulations on your first pull request to Shopstr! 45 | 46 | 🛒⚡ Thank you for contributing to our global, permissionless Nostr marketplace for Bitcoin commerce. 47 | 48 | 🔍 **What happens next?** 49 | - Our automated checks will run to ensure code quality 50 | - A maintainer will review your changes and provide feedback 51 | - Make sure all tests pass and follow our coding standards 52 | 53 | ✅ **Before we merge:** 54 | - [ ] All tests are passing 55 | - [ ] Code follows our linting standards 56 | - [ ] Changes are documented if needed 57 | - [ ] Breaking changes are clearly marked 58 | 59 | 📚 **Need help?** 60 | - Check our [Contributing Guide](contributing.md) for best practices 61 | - Join our community discussions for questions 62 | - Review existing PRs for examples 63 | 64 | 🚀 We're excited to see your contribution shape the future of decentralized commerce! -------------------------------------------------------------------------------- /utils/parsers/__tests__/zapsnag-parser.test.ts: -------------------------------------------------------------------------------- 1 | import { parseZapsnagNote } from "@/utils/parsers/zapsnag-parser"; 2 | import { NostrEvent } from "@/utils/types/types"; 3 | 4 | const createEvent = (content: string, id = "event-123"): NostrEvent => ({ 5 | id, 6 | pubkey: "pubkey-123", 7 | created_at: 1620000000, 8 | kind: 1, 9 | tags: [], 10 | content, 11 | sig: "sig", 12 | }); 13 | 14 | describe("parseZapsnagNote", () => { 15 | it("extracts price and default currency (sats) correctly", () => { 16 | const event = createEvent("Selling a cool hat price: 5000 sats #zapsnag"); 17 | const result = parseZapsnagNote(event); 18 | 19 | expect(result.price).toBe(5000); 20 | expect(result.currency).toBe("sats"); 21 | }); 22 | 23 | it("extracts USD currency correctly", () => { 24 | const event = createEvent("Consultation cost: 50 USD"); 25 | const result = parseZapsnagNote(event); 26 | 27 | expect(result.price).toBe(50); 28 | expect(result.currency).toBe("USD"); 29 | }); 30 | 31 | it("handles prices with commas", () => { 32 | const event = createEvent("Lambo price: 1,000,000 sats"); 33 | const result = parseZapsnagNote(event); 34 | 35 | expect(result.price).toBe(1000000); 36 | }); 37 | 38 | it("extracts the first image URL found", () => { 39 | const event = createEvent("Check this https://example.com/image.jpg and https://example.com/other.png"); 40 | const result = parseZapsnagNote(event); 41 | 42 | expect(result.images[0]).toBe("https://example.com/image.jpg"); 43 | }); 44 | 45 | it("uses RoboHash fallback when no image is present", () => { 46 | const event = createEvent("Just text, no images", "unique-id-999"); 47 | const result = parseZapsnagNote(event); 48 | 49 | expect(result.images[0]).toBe("https://robohash.org/unique-id-999"); 50 | }); 51 | 52 | it("generates a clean title by removing price, tags, and URLs", () => { 53 | const content = "Super Cool Item price: 100 https://img.com/a.jpg #zapsnag"; 54 | const event = createEvent(content); 55 | const result = parseZapsnagNote(event); 56 | 57 | expect(result.title).toBe("Super Cool Item"); 58 | }); 59 | 60 | it("truncates very long titles", () => { 61 | const longText = "This is a very very very long description that should be truncated because it is used as a title and is definitely over fifty characters long"; 62 | const event = createEvent(longText); 63 | const result = parseZapsnagNote(event); 64 | 65 | expect(result.title.length).toBeLessThanOrEqual(53); 66 | expect(result.title).toContain("..."); 67 | }); 68 | 69 | it("defaults to 'Flash Sale Item' if content is empty after cleaning", () => { 70 | const event = createEvent("https://img.com/a.jpg #zapsnag"); 71 | const result = parseZapsnagNote(event); 72 | 73 | expect(result.title).toBe("Flash Sale Item"); 74 | }); 75 | }); -------------------------------------------------------------------------------- /utils/parsers/community-parser-functions.ts: -------------------------------------------------------------------------------- 1 | import { NostrEvent } from "nostr-tools"; 2 | import { Community, CommunityRelays } from "../types/types"; 3 | 4 | // Helper: push into categorized relays 5 | function addRelayToMap( 6 | map: Record, 7 | url: string, 8 | type?: string 9 | ) { 10 | if (!url) return; 11 | map.all = map.all || []; 12 | if (!map.all.includes(url)) map.all.push(url); 13 | if (!type) { 14 | // no type -> treat as request relay by default (backwards compat) 15 | map.requests = map.requests || []; 16 | if (!map.requests.includes(url)) map.requests.push(url); 17 | return; 18 | } 19 | const key = type.toLowerCase(); 20 | map[key] = map[key] || []; 21 | const list = map[key]; 22 | if (list && !list.includes(url)) list.push(url); 23 | } 24 | 25 | export const parseCommunityEvent = (event: NostrEvent): Community | null => { 26 | if (event.kind !== 34550) return null; 27 | 28 | const dTag = event.tags.find((tag) => tag[0] === "d")?.[1]; 29 | if (!dTag) return null; // d tag is required by NIP-72 30 | 31 | const nameTag = event.tags.find((tag) => tag[0] === "name")?.[1]; 32 | const descriptionTag = event.tags.find( 33 | (tag) => tag[0] === "description" 34 | )?.[1]; 35 | const imageTag = event.tags.find((tag) => tag[0] === "image")?.[1]; 36 | 37 | // moderators: p tags that optionally use the 4th element as role marker "moderator" 38 | const moderators = event.tags 39 | .filter( 40 | (tag) => tag[0] === "p" && (tag[3] === "moderator" || tag.length >= 2) 41 | ) 42 | .map((tag) => tag[1]) 43 | .filter((pubkey): pubkey is string => !!pubkey); 44 | 45 | // parse relay tags: ["relay", "", ""] where type may be "approvals", "requests", "metadata" 46 | const relayMap: Record = { 47 | approvals: [], 48 | requests: [], 49 | metadata: [], 50 | all: [], 51 | }; 52 | const relayTags = event.tags.filter((tag) => tag[0] === "relay"); 53 | for (const r of relayTags) { 54 | const url = r[1]; 55 | if (url) { 56 | const type = r.length >= 3 ? r[2] : undefined; 57 | addRelayToMap(relayMap, url, type); 58 | } 59 | } 60 | 61 | // fallback: if no relays declared at all, leave all empty 62 | const relays: CommunityRelays = { 63 | approvals: relayMap.approvals || [], 64 | requests: relayMap.requests || [], 65 | metadata: relayMap.metadata || [], 66 | all: relayMap.all || [], 67 | }; 68 | 69 | return { 70 | id: event.id, 71 | kind: event.kind, 72 | pubkey: event.pubkey, 73 | createdAt: event.created_at, 74 | d: dTag, 75 | name: nameTag || dTag, 76 | description: descriptionTag || "", 77 | image: imageTag || `https://robohash.org/${event.id}`, 78 | moderators: Array.from(new Set([event.pubkey, ...moderators])), 79 | relays, 80 | relaysList: relays.all.length ? relays.all : undefined, 81 | }; 82 | }; 83 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | import { nextui } from "@nextui-org/react"; 3 | 4 | const config: Config = { 5 | content: [ 6 | "./pages/**/*.{js,ts,jsx,tsx}", 7 | "./components/**/*.{js,ts,jsx,tsx}", 8 | "./utils/**/*.{js,ts,jsx,tsx}", 9 | "./app/**/*.{js,ts,jsx,tsx}", 10 | "./lib/**/*.{js,ts,jsx,tsx}", 11 | "./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}", 12 | ], 13 | theme: { 14 | extend: { 15 | backgroundImage: { 16 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 17 | "gradient-conic": 18 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 19 | }, 20 | colors: { 21 | "dark-bg": "#212121", 22 | "dark-fg": "#4d4c4e", // dark foreground 23 | "light-bg": "#e8e8e8", 24 | "light-fg": "#f5f5f5", 25 | "shopstr-purple": "#a438ba", 26 | "shopstr-purple-light": "#a655f7", 27 | "shopstr-yellow": "#fcd34d", 28 | "shopstr-yellow-light": "#fef08a", 29 | "dark-text": "#e8e8e8", 30 | "accent-dark-text": "#fef08a", // shopstr yellow 31 | "light-text": "#212121", 32 | "accent-light-text": "#a438ba", // shopstr purple 33 | }, 34 | }, 35 | }, 36 | safelist: [ 37 | { 38 | pattern: 39 | /^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 40 | variants: ["hover", "ui-selected"], 41 | }, 42 | { 43 | pattern: 44 | /^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 45 | variants: ["hover", "ui-selected"], 46 | }, 47 | { 48 | pattern: 49 | /^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 50 | variants: ["hover", "ui-selected"], 51 | }, 52 | { 53 | pattern: 54 | /^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 55 | }, 56 | { 57 | pattern: 58 | /^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 59 | }, 60 | { 61 | pattern: 62 | /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, 63 | }, 64 | ], 65 | darkMode: "class", 66 | plugins: [nextui()], 67 | }; 68 | 69 | export default config; 70 | -------------------------------------------------------------------------------- /components/communities/__tests__/CommunityCard.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen, fireEvent } from "@testing-library/react"; 3 | import "@testing-library/jest-dom"; 4 | import CommunityCard from "../CommunityCard"; 5 | import { Community } from "@/utils/types/types"; 6 | import { nip19 } from "nostr-tools"; 7 | import { sanitizeUrl } from "@braintree/sanitize-url"; 8 | 9 | const mockPush = jest.fn(); 10 | jest.mock("next/router", () => ({ 11 | useRouter() { 12 | return { 13 | push: mockPush, 14 | }; 15 | }, 16 | })); 17 | 18 | jest.mock("nostr-tools", () => ({ 19 | nip19: { 20 | naddrEncode: jest.fn(), 21 | }, 22 | })); 23 | 24 | jest.mock("@braintree/sanitize-url", () => ({ 25 | sanitizeUrl: jest.fn((url) => url), 26 | })); 27 | 28 | const mockedNip19 = nip19 as jest.Mocked; 29 | const mockedSanitizeUrl = sanitizeUrl as jest.Mocked; 30 | 31 | describe("CommunityCard", () => { 32 | const mockCommunity: Community = { 33 | id: "test-event-id-123", 34 | kind: 34550, 35 | createdAt: Math.floor(Date.now() / 1000), 36 | name: "Test Community", 37 | pubkey: "test-pubkey-123", 38 | d: "test-d-identifier-456", 39 | description: "This is a fantastic community for testing purposes.", 40 | image: "https://example.com/test-image.jpg", 41 | moderators: [], 42 | relays: { 43 | approvals: [], 44 | requests: [], 45 | metadata: [], 46 | all: [], 47 | }, 48 | }; 49 | 50 | beforeEach(() => { 51 | jest.clearAllMocks(); 52 | }); 53 | 54 | it("renders community information correctly", () => { 55 | render(); 56 | 57 | expect(screen.getByText("Community")).toBeInTheDocument(); 58 | expect( 59 | screen.getByRole("heading", { name: /Test Community/i }) 60 | ).toBeInTheDocument(); 61 | expect( 62 | screen.getByText("This is a fantastic community for testing purposes.") 63 | ).toBeInTheDocument(); 64 | 65 | const image = screen.getByRole("img", { name: /Test Community/i }); 66 | expect(image).toBeInTheDocument(); 67 | expect(image).toHaveAttribute("src", mockCommunity.image); 68 | 69 | expect(mockedSanitizeUrl).toHaveBeenCalledWith(mockCommunity.image); 70 | }); 71 | 72 | it('navigates to the correct community page on "Visit" button click', () => { 73 | const mockNaddr = "naddr1mockencodedstring"; 74 | mockedNip19.naddrEncode.mockReturnValue(mockNaddr); 75 | 76 | render(); 77 | 78 | const visitButton = screen.getByRole("button", { name: /visit/i }); 79 | expect(visitButton).toBeInTheDocument(); 80 | 81 | fireEvent.click(visitButton); 82 | 83 | expect(mockedNip19.naddrEncode).toHaveBeenCalledWith({ 84 | identifier: mockCommunity.d, 85 | pubkey: mockCommunity.pubkey, 86 | kind: 34550, 87 | }); 88 | 89 | expect(mockPush).toHaveBeenCalledWith(`/communities/${mockNaddr}`); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /components/utility-components/display-monetary-info.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ShippingOptionsType } from "@/utils/STATIC-VARIABLES"; 3 | 4 | type ProductMonetaryInfo = { 5 | shippingType?: ShippingOptionsType; 6 | shippingCost?: number; 7 | price: number; 8 | currency: string; 9 | }; 10 | 11 | export default function CompactPriceDisplay({ 12 | monetaryInfo, 13 | }: { 14 | monetaryInfo: ProductMonetaryInfo; 15 | }) { 16 | const { shippingType, shippingCost, price, currency } = monetaryInfo; 17 | 18 | const getShippingLabel = () => { 19 | if (shippingType === "Added Cost") 20 | return `+ ${formatter.format(Number(shippingCost))} ${currency} Shipping`; 21 | else if (shippingType === "Free") return "- Free Shipping"; 22 | else if (shippingType === "Pickup") return "- Pickup Only"; 23 | else if (shippingType == "Free/Pickup") return "- Free / Pickup"; 24 | else return ""; 25 | }; 26 | 27 | const formatter = new Intl.NumberFormat("en-GB", { 28 | notation: "compact", 29 | compactDisplay: "short", 30 | }); 31 | return ( 32 |
33 | 34 | {formatter.format(Number(price))} {currency}{" "} 35 | {monetaryInfo.shippingType ? getShippingLabel() : ""}{" "} 36 | 37 |
38 | ); 39 | } 40 | 41 | export function DisplayCheckoutCost({ 42 | monetaryInfo, 43 | }: { 44 | monetaryInfo: ProductMonetaryInfo; 45 | }) { 46 | const { shippingType, price, currency } = monetaryInfo; 47 | 48 | const formattedPrice = formatWithCommas(price, currency); 49 | 50 | return ( 51 |
52 |

53 | {formattedPrice} 54 |

55 | {shippingType && ( 56 |

57 | Shipping: {shippingType} 58 |

59 | )} 60 |
61 | ); 62 | } 63 | 64 | export const calculateTotalCost = ( 65 | productMonetaryInfo: ProductMonetaryInfo 66 | ) => { 67 | const { price, shippingCost } = productMonetaryInfo; 68 | let total = price; 69 | total += shippingCost ? shippingCost : 0; 70 | return total; 71 | }; 72 | 73 | export function formatWithCommas(amount: number, currency: string) { 74 | if (!amount || amount === 0) { 75 | // If the amount is 0, directly return "0" followed by the currency 76 | return `0 ${currency}`; 77 | } 78 | const [integerPart, fractionalPart] = amount.toString().split("."); 79 | // Add commas to the integer part 80 | const integerWithCommas = integerPart!.replace(/\B(?=(\d{3})+(?!\d))/g, ","); 81 | // Concatenate the fractional part if it exists 82 | const formattedAmount = fractionalPart 83 | ? `${integerWithCommas}.${fractionalPart}` 84 | : integerWithCommas; 85 | return `${formattedAmount} ${currency}`; 86 | } 87 | -------------------------------------------------------------------------------- /pages/communities/[naddr].tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from "react"; 2 | import { useRouter } from "next/router"; 3 | import { nip19 } from "nostr-tools"; 4 | import { CommunityContext } from "@/utils/context/context"; 5 | import { Community } from "@/utils/types/types"; 6 | import { Spinner } from "@nextui-org/react"; 7 | import CommunityFeed from "@/components/communities/CommunityFeed"; 8 | import { sanitizeUrl } from "@braintree/sanitize-url"; 9 | 10 | const SingleCommunityPage = () => { 11 | const router = useRouter(); 12 | const { naddr } = router.query; 13 | const { communities } = useContext(CommunityContext); 14 | const [community, setCommunity] = useState(null); 15 | const [isLoading, setIsLoading] = useState(true); 16 | 17 | useEffect(() => { 18 | if (naddr && typeof naddr === "string" && communities.size > 0) { 19 | try { 20 | const decoded = nip19.decode(naddr); 21 | if (decoded.type === "naddr") { 22 | const { pubkey, identifier } = decoded.data; 23 | // Find the community by pubkey and d-tag 24 | for (const c of communities.values()) { 25 | if (c.pubkey === pubkey && c.d === identifier) { 26 | setCommunity(c); 27 | break; 28 | } 29 | } 30 | } 31 | } catch (e) { 32 | console.error("Failed to decode naddr:", e); 33 | } finally { 34 | setIsLoading(false); 35 | } 36 | } else if (communities.size > 0) { 37 | setIsLoading(false); 38 | } 39 | }, [naddr, communities]); 40 | 41 | if (isLoading) { 42 | return ( 43 |
44 | 45 |
46 | ); 47 | } 48 | 49 | if (!community) { 50 | return ( 51 |
52 |

Community not found.

53 |
54 | ); 55 | } 56 | 57 | return ( 58 |
59 |
60 | {/* Community Header */} 61 |
65 |
66 |
67 |

68 | {community.name} 69 |

70 |

71 | {community.description} 72 |

73 | 74 | {/* Community Feed */} 75 |
76 | 77 |
78 |
79 |
80 | ); 81 | }; 82 | 83 | export default SingleCommunityPage; 84 | -------------------------------------------------------------------------------- /utils/db/db-client.ts: -------------------------------------------------------------------------------- 1 | import { NostrEvent } from "@/utils/types/types"; 2 | 3 | export async function cacheEventToDatabase(event: NostrEvent): Promise { 4 | try { 5 | const response = await fetch("/api/db/cache-event", { 6 | method: "POST", 7 | headers: { "Content-Type": "application/json" }, 8 | body: JSON.stringify(event), 9 | }); 10 | if (!response.ok) { 11 | throw new Error("Failed to cache event to database"); 12 | } 13 | } catch (error) { 14 | console.error("Failed to cache event to database:", error); 15 | throw error; 16 | } 17 | } 18 | 19 | export async function cacheEventsToDatabase( 20 | events: NostrEvent[] 21 | ): Promise { 22 | try { 23 | const response = await fetch("/api/db/cache-events", { 24 | method: "POST", 25 | headers: { "Content-Type": "application/json" }, 26 | body: JSON.stringify(events), 27 | }); 28 | if (!response.ok) { 29 | throw new Error("Failed to cache events to database"); 30 | } 31 | } catch (error) { 32 | console.error("Failed to cache events to database:", error); 33 | throw error; 34 | } 35 | } 36 | 37 | export async function deleteEventsFromDatabase( 38 | eventIds: string[] 39 | ): Promise { 40 | if (eventIds.length === 0) return; 41 | 42 | try { 43 | await fetch("/api/db/delete-events", { 44 | method: "POST", 45 | headers: { "Content-Type": "application/json" }, 46 | body: JSON.stringify({ eventIds }), 47 | }); 48 | } catch (error) { 49 | console.error("Failed to delete events from database:", error); 50 | } 51 | } 52 | 53 | export async function trackFailedRelayPublish( 54 | eventId: string, 55 | relays: string[] 56 | ): Promise { 57 | try { 58 | await fetch("/api/db/track-failed-publish", { 59 | method: "POST", 60 | headers: { "Content-Type": "application/json" }, 61 | body: JSON.stringify({ eventId, relays }), 62 | }); 63 | } catch (error) { 64 | console.error("Failed to track failed relay publish:", error); 65 | } 66 | } 67 | 68 | export async function getFailedRelayPublishes(): Promise< 69 | Array<{ 70 | eventId: string; 71 | relays: string[]; 72 | event: NostrEvent; 73 | retryCount: number; 74 | }> 75 | > { 76 | try { 77 | const response = await fetch("/api/db/get-failed-publishes"); 78 | if (!response.ok) { 79 | throw new Error("Failed to fetch failed relay publishes"); 80 | } 81 | return await response.json(); 82 | } catch (error) { 83 | console.error("Failed to get failed relay publishes:", error); 84 | return []; 85 | } 86 | } 87 | 88 | export async function clearFailedRelayPublish(eventId: string): Promise { 89 | try { 90 | await fetch("/api/db/clear-failed-publish", { 91 | method: "POST", 92 | headers: { "Content-Type": "application/json" }, 93 | body: JSON.stringify({ eventId }), 94 | }); 95 | } catch (error) { 96 | console.error("Failed to clear failed relay publish:", error); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /utils/parsers/__tests__/review-parser-functions.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getRatingValue, 3 | calculateWeightedScore, 4 | } from "../review-parser-functions"; 5 | 6 | describe("getRatingValue", () => { 7 | const sampleTags: string[][] = [ 8 | ["rating", "4.5", "thumb"], 9 | ["rating", "5", "shipping"], 10 | ["p", "some_pubkey_here"], 11 | ["e", "some_event_id_here"], 12 | ]; 13 | 14 | it("should return the numeric value of an existing rating tag", () => { 15 | expect(getRatingValue(sampleTags, "thumb")).toBe(4.5); 16 | expect(getRatingValue(sampleTags, "shipping")).toBe(5); 17 | }); 18 | 19 | it("should return 0 if the rating tag does not exist", () => { 20 | expect(getRatingValue(sampleTags, "quality")).toBe(0); 21 | }); 22 | 23 | it("should return 0 for an empty tags array", () => { 24 | expect(getRatingValue([], "thumb")).toBe(0); 25 | }); 26 | }); 27 | 28 | describe("calculateWeightedScore", () => { 29 | it("should calculate the score correctly with only a thumb rating", () => { 30 | const tags: string[][] = [["rating", "4", "thumb"]]; 31 | // Expected score: 4 * 0.5 = 2 32 | expect(calculateWeightedScore(tags)).toBe(2); 33 | }); 34 | 35 | it("should calculate the score correctly with a thumb and one other rating", () => { 36 | const tags: string[][] = [ 37 | ["rating", "4", "thumb"], 38 | ["rating", "5", "shipping"], 39 | ]; 40 | // Expected score: (4 * 0.5) + (5 * 0.5) = 2 + 2.5 = 4.5 41 | expect(calculateWeightedScore(tags)).toBe(4.5); 42 | }); 43 | 44 | it("should calculate the score correctly with a thumb and multiple other ratings", () => { 45 | const tags: string[][] = [ 46 | ["rating", "4", "thumb"], 47 | ["rating", "5", "shipping"], 48 | ["rating", "3", "quality"], 49 | ]; 50 | // Individual weight for non-thumb ratings: 0.5 / 2 = 0.25 51 | // Expected score: (4 * 0.5) + (5 * 0.25) + (3 * 0.25) = 2 + 1.25 + 0.75 = 4 52 | expect(calculateWeightedScore(tags)).toBe(4); 53 | }); 54 | 55 | it("should return 0 if there are no rating tags at all", () => { 56 | const tags: string[][] = [ 57 | ["p", "some_pubkey_here"], 58 | ["e", "some_event_id_here"], 59 | ]; 60 | expect(calculateWeightedScore(tags)).toBe(0); 61 | }); 62 | 63 | it("should calculate score correctly with other ratings but no thumb rating", () => { 64 | const tags: string[][] = [ 65 | ["rating", "5", "shipping"], 66 | ["rating", "3", "quality"], 67 | ]; 68 | // Thumb score is 0. Individual weight: 0.5 / 2 = 0.25 69 | // Expected score: (0 * 0.5) + (5 * 0.25) + (3 * 0.25) = 0 + 1.25 + 0.75 = 2 70 | expect(calculateWeightedScore(tags)).toBe(2); 71 | }); 72 | 73 | it("should handle floating point values precisely", () => { 74 | const tags: string[][] = [ 75 | ["rating", "3.5", "thumb"], 76 | ["rating", "4.2", "quality"], 77 | ]; 78 | // Expected score: (3.5 * 0.5) + (4.2 * 0.5) = 1.75 + 2.1 = 3.85 79 | expect(calculateWeightedScore(tags)).toBeCloseTo(3.85); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /components/settings/__tests__/settings-bread-crumbs.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen, fireEvent } from "@testing-library/react"; 3 | import "@testing-library/jest-dom"; 4 | import { useRouter } from "next/router"; 5 | import { SettingsBreadCrumbs } from "../settings-bread-crumbs"; 6 | 7 | const mockRouterPush = jest.fn(); 8 | jest.mock("next/router", () => ({ 9 | useRouter: jest.fn(() => ({ 10 | pathname: "", 11 | push: mockRouterPush, 12 | })), 13 | })); 14 | 15 | const mockedUseRouter = useRouter as jest.Mock; 16 | 17 | describe("SettingsBreadCrumbs", () => { 18 | beforeEach(() => { 19 | mockRouterPush.mockClear(); 20 | }); 21 | 22 | test("renders correctly for a nested path", () => { 23 | mockedUseRouter.mockReturnValue({ 24 | pathname: "/settings/user-profile", 25 | push: mockRouterPush, 26 | }); 27 | 28 | render(); 29 | 30 | expect(screen.getByText("Settings")).toBeInTheDocument(); 31 | expect(screen.getByText("User Profile")).toBeInTheDocument(); 32 | }); 33 | 34 | test("applies correct opacity styles for active and inactive items", () => { 35 | mockedUseRouter.mockReturnValue({ 36 | pathname: "/settings/shop-profile", 37 | push: mockRouterPush, 38 | }); 39 | 40 | render(); 41 | 42 | const settingsItem = screen.getByText("Settings"); 43 | const shopProfileItem = screen.getByText("Shop Profile"); 44 | 45 | expect(settingsItem).toHaveClass("opacity-50"); 46 | 47 | expect(shopProfileItem).not.toHaveClass("opacity-50"); 48 | }); 49 | 50 | test("handles a single-level path correctly", () => { 51 | mockedUseRouter.mockReturnValue({ 52 | pathname: "/settings", 53 | push: mockRouterPush, 54 | }); 55 | 56 | render(); 57 | 58 | const settingsItem = screen.getByText("Settings"); 59 | 60 | expect(settingsItem).toBeInTheDocument(); 61 | expect(screen.queryByText("User Profile")).not.toBeInTheDocument(); 62 | 63 | expect(settingsItem).not.toHaveClass("opacity-50"); 64 | }); 65 | 66 | test("calls router.push with the correct path when a breadcrumb is clicked", () => { 67 | mockedUseRouter.mockReturnValue({ 68 | pathname: "/settings/preferences", 69 | push: mockRouterPush, 70 | }); 71 | 72 | render(); 73 | 74 | const settingsItem = screen.getByText("Settings"); 75 | fireEvent.click(settingsItem); 76 | 77 | expect(mockRouterPush).toHaveBeenCalledTimes(1); 78 | expect(mockRouterPush).toHaveBeenCalledWith("/settings"); 79 | }); 80 | 81 | test("renders an empty item for path segments not in pathMap", () => { 82 | mockedUseRouter.mockReturnValue({ 83 | pathname: "/settings/an-unknown-page", 84 | push: mockRouterPush, 85 | }); 86 | 87 | render(); 88 | 89 | const settingsItem = screen.getByText("Settings"); 90 | 91 | expect(settingsItem).toBeInTheDocument(); 92 | 93 | expect(settingsItem).toHaveClass("opacity-50"); 94 | expect(screen.queryByText("an-unknown-page")).not.toBeInTheDocument(); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: ESLint & Prettier Check 2 | 3 | on: 4 | pull_request_target: 5 | branches: [main] 6 | 7 | permissions: 8 | contents: read 9 | pull-requests: write 10 | issues: write 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Check out code 18 | uses: actions/checkout@v4 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | fetch-depth: 0 22 | 23 | - name: Use Node.js 20 & cache deps 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: "20.x" 27 | cache: "npm" 28 | 29 | - name: Install dependencies 30 | run: npm ci 31 | 32 | - name: Run ESLint 33 | id: eslint 34 | run: | 35 | npx eslint "pages/**/*.{js,ts,tsx}" "components/**/*.{js,ts,tsx}" --format json > eslint-report.json || true 36 | count=$(jq '[.[].messages[] | select(.severity == 2)] | length' eslint-report.json) 37 | echo "eslint_errors=$count" >> $GITHUB_OUTPUT 38 | 39 | - name: Run Prettier 40 | id: prettier 41 | run: | 42 | if npx prettier --check .; then 43 | echo "prettier_ok=true" >> $GITHUB_OUTPUT 44 | else 45 | echo "prettier_ok=false" >> $GITHUB_OUTPUT 46 | fi 47 | 48 | - name: Comment on PR with lint results 49 | if: steps.eslint.outputs.eslint_errors != '0' || steps.prettier.outputs.prettier_ok == 'false' 50 | uses: actions/github-script@v6 51 | with: 52 | github-token: ${{ secrets.GITHUB_TOKEN }} 53 | script: | 54 | const fs = require('fs'); 55 | const errs = parseInt(process.env.ESLINT_ERRORS, 10); 56 | let body = '## 🚨 Linting & Formatting Report\n\n'; 57 | if (errs > 0) { 58 | body += `**ESLint** found **${errs}** issue(s). Run \`npx eslint --fix\` locally.\n\n`; 59 | const report = JSON.parse(fs.readFileSync('eslint-report.json', 'utf8')); 60 | const errors = []; 61 | report.forEach(r => r.messages.forEach(m => { 62 | const file = r.filePath.replace(process.cwd() + '/', ''); 63 | errors.push({ file, line: m.line, column: m.column, message: m.message, ruleId: m.ruleId }); 64 | })); 65 | errors.slice(0, 3).forEach(e => { 66 | body += `- [${e.file}:${e.line}:${e.column}] ${e.message} (**${e.ruleId}**)\n`; 67 | }); 68 | if (errs > 3) body += `\n…and ${errs - 3} more.\n\n`; 69 | } 70 | if (process.env.PRETTIER_OK === 'false') { 71 | body += '**Prettier** found formatting issues. Run `npx prettier --write .` locally.\n\n'; 72 | } 73 | await github.rest.issues.createComment({ 74 | owner: context.repo.owner, 75 | repo: context.repo.repo, 76 | issue_number: context.issue.number, 77 | body: body + '🔍 Please fix these before merging.' 78 | }); 79 | env: 80 | ESLINT_ERRORS: ${{ steps.eslint.outputs.eslint_errors }} 81 | PRETTIER_OK: ${{ steps.prettier.outputs.prettier_ok }} 82 | -------------------------------------------------------------------------------- /components/utility-components/__tests__/compact-categories.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen, fireEvent } from "@testing-library/react"; 3 | import "@testing-library/jest-dom"; 4 | import CompactCategories from "../compact-categories"; 5 | 6 | jest.mock("@/utils/STATIC-VARIABLES", () => ({ 7 | CATEGORIES: ["Electronics", "Books", "Home & Kitchen", "Art"], 8 | })); 9 | 10 | jest.mock("@nextui-org/react", () => ({ 11 | Chip: ({ children }: { children: React.ReactNode }) => ( 12 |
{children}
13 | ), 14 | Tooltip: ({ 15 | children, 16 | isDisabled, 17 | }: { 18 | children: React.ReactNode; 19 | isDisabled?: boolean; 20 | }) => ( 21 |
22 | {children} 23 |
24 | ), 25 | })); 26 | 27 | describe("CompactCategories", () => { 28 | it("renders null if no categories are provided", () => { 29 | const { container } = render(); 30 | expect(container.firstChild).toBeNull(); 31 | }); 32 | 33 | it("renders null if all provided categories are invalid", () => { 34 | const { container } = render( 35 | 36 | ); 37 | expect(container.firstChild).toBeNull(); 38 | }); 39 | 40 | it("renders only a single chip if one valid category is provided", () => { 41 | render(); 42 | const chip = screen.getByTestId("chip"); 43 | expect(chip).toHaveTextContent("Books"); 44 | expect(screen.queryByText(/,\s*\.\.\./)).not.toBeInTheDocument(); 45 | }); 46 | 47 | it("disables the tooltip when only one category is present", () => { 48 | render(); 49 | const tooltip = screen.getByTestId("tooltip"); 50 | expect(tooltip).toHaveAttribute("data-disabled", "true"); 51 | }); 52 | 53 | it("renders the longest category first with ellipsis for multiple categories", () => { 54 | render(); 55 | const chip = screen.getByTestId("chip"); 56 | expect(chip).toHaveTextContent("Electronics, ..."); 57 | }); 58 | 59 | it("enables the tooltip when multiple categories are present", () => { 60 | render(); 61 | const tooltip = screen.getByTestId("tooltip"); 62 | expect(tooltip).toHaveAttribute("data-disabled", "false"); 63 | }); 64 | 65 | it("filters out invalid categories and renders only the valid ones", () => { 66 | render(); 67 | const chip = screen.getByTestId("chip"); 68 | expect(chip).toHaveTextContent("Books, ..."); 69 | }); 70 | 71 | it("updates the display on click to show only the primary category", () => { 72 | render(); 73 | 74 | expect(screen.getByText(/,\s*\.\.\./)).toBeInTheDocument(); 75 | 76 | const trigger = screen.getByTestId("chip").parentElement; 77 | expect(trigger).toBeInTheDocument(); 78 | 79 | fireEvent.click(trigger!); 80 | 81 | expect(screen.queryByText(/,\s*\.\.\./)).not.toBeInTheDocument(); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /components/utility-components/auth-challenge-modal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Modal, 4 | ModalContent, 5 | ModalHeader, 6 | ModalBody, 7 | ModalFooter, 8 | Button, 9 | } from "@nextui-org/react"; 10 | import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES"; 11 | import { useRouter } from "next/router"; 12 | 13 | function sanitizeURL(s: string) { 14 | try { 15 | const url = new URL(s); 16 | if (url.protocol !== "https:" && url.protocol !== "http:") 17 | throw new Error("invalid protocol"); 18 | return url.href; 19 | } catch (e) { 20 | return null; 21 | } 22 | } 23 | 24 | export default function AuthChallengeModal({ 25 | actionOnCancel, 26 | isOpen, 27 | setIsOpen, 28 | challenge, 29 | onCancelRouteTo, 30 | error, 31 | }: { 32 | actionOnCancel?: () => void; 33 | isOpen: boolean; 34 | setIsOpen: (value: boolean) => void; 35 | challenge: string; 36 | onCancelRouteTo?: string; // route to go to on cancel 37 | error?: Error; 38 | }) { 39 | const router = useRouter(); 40 | 41 | const challengeUrl = sanitizeURL(challenge); 42 | 43 | const onCancel = () => { 44 | if (actionOnCancel) actionOnCancel(); 45 | setIsOpen(false); 46 | onCancelRouteTo 47 | ? router.push(onCancelRouteTo) 48 | : router.push("/marketplace"); 49 | }; 50 | 51 | return ( 52 | 68 | 69 | 70 | Waiting for confirmation 71 | 72 | 73 |
74 | {challengeUrl 75 | ? "Please confirm this action on your remote signer" 76 | : challenge} 77 |
78 | 79 | {error && ( 80 |
{error.message}
81 | )} 82 |
83 | 84 | 85 | 88 | {challengeUrl && ( 89 | 101 | )} 102 | 103 |
104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Shopstr", 3 | "short_name": "Shopstr", 4 | "description": "Shop freely.", 5 | "start_url": "/", 6 | "display": "standalone", 7 | "background_color": "#E8E8E8", 8 | "theme_color": "#E8E8E8", 9 | "orientation": "any", 10 | "scope": "/", 11 | "categories": ["shopping", "marketplace", "ecommerce"], 12 | "icons": [ 13 | { 14 | "src": "/shopstr-144x144.png", 15 | "sizes": "144x144", 16 | "type": "image/png", 17 | "purpose": "any" 18 | }, 19 | { 20 | "src": "/shopstr-512x512.png", 21 | "sizes": "512x512", 22 | "type": "image/png", 23 | "purpose": "maskable" 24 | }, 25 | { 26 | "src": "/shopstr-2000x2000.png", 27 | "sizes": "2000x2000", 28 | "type": "image/png", 29 | "purpose": "any" 30 | } 31 | ], 32 | "shortcuts": [ 33 | { 34 | "name": "Landing Page", 35 | "short_name": "Landing", 36 | "description": "View the landing page", 37 | "url": "/", 38 | "icons": [{ "src": "/shopstr-144x144.png", "sizes": "144x144" }] 39 | }, 40 | { 41 | "name": "Marketplace", 42 | "short_name": "Market", 43 | "description": "Browse the marketplace", 44 | "url": "/marketplace", 45 | "icons": [{ "src": "/shopstr-144x144.png", "sizes": "144x144" }] 46 | }, 47 | { 48 | "name": "Orders", 49 | "short_name": "Orders", 50 | "description": "View your orders", 51 | "url": "/orders", 52 | "icons": [{ "src": "/shopstr-144x144.png", "sizes": "144x144" }] 53 | }, 54 | { 55 | "name": "My Listings", 56 | "short_name": "Listings", 57 | "description": "Manage your shop listings", 58 | "url": "/my-listings", 59 | "icons": [{ "src": "/shopstr-144x144.png", "sizes": "144x144" }] 60 | }, 61 | { 62 | "name": "Settings", 63 | "short_name": "Settings", 64 | "description": "Manage your site settings", 65 | "url": "/settings", 66 | "icons": [{ "src": "/shopstr-144x144.png", "sizes": "144x144" }] 67 | }, 68 | { 69 | "name": "User Profile", 70 | "short_name": "Profile", 71 | "description": "Manage your profile settings", 72 | "url": "/settings/user-profile", 73 | "icons": [{ "src": "/shopstr-144x144.png", "sizes": "144x144" }] 74 | }, 75 | { 76 | "name": "Shop Profile", 77 | "short_name": "Shop", 78 | "description": "Manage your shop profile", 79 | "url": "/settings/shop-profile", 80 | "icons": [{ "src": "/shopstr-144x144.png", "sizes": "144x144" }] 81 | }, 82 | { 83 | "name": "Wallet", 84 | "short_name": "Wallet", 85 | "description": "Manage your wallet", 86 | "url": "/wallet", 87 | "icons": [{ "src": "/shopstr-144x144.png", "sizes": "144x144" }] 88 | }, 89 | { 90 | "name": "Shopping Cart", 91 | "short_name": "Cart", 92 | "description": "View your shopping cart", 93 | "url": "/cart", 94 | "icons": [{ "src": "/shopstr-144x144.png", "sizes": "144x144" }] 95 | }, 96 | { 97 | "name": "FAQ", 98 | "short_name": "FAQ", 99 | "description": "View frequently asked questions", 100 | "url": "/faq", 101 | "icons": [{ "src": "/shopstr-144x144.png", "sizes": "144x144" }] 102 | } 103 | ] 104 | } 105 | -------------------------------------------------------------------------------- /utils/__tests__/timeout.test.ts: -------------------------------------------------------------------------------- 1 | import { newPromiseWithTimeout } from "../timeout"; 2 | 3 | jest.useFakeTimers(); 4 | 5 | describe("newPromiseWithTimeout", () => { 6 | beforeEach(() => { 7 | jest.clearAllTimers(); 8 | }); 9 | 10 | it("should resolve successfully when the callback resolves before the timeout", async () => { 11 | const promise = newPromiseWithTimeout( 12 | (resolve) => { 13 | resolve("success"); 14 | }, 15 | { timeout: 1000 } 16 | ); 17 | 18 | await expect(promise).resolves.toBe("success"); 19 | }); 20 | 21 | it("should reject with a Timeout error if the callback does not resolve in time", async () => { 22 | const promise = newPromiseWithTimeout(() => {}, { timeout: 500 }); 23 | 24 | jest.runAllTimers(); 25 | 26 | await expect(promise).rejects.toThrow("Timeout"); 27 | }); 28 | 29 | it("should reject successfully when the callback rejects before the timeout", async () => { 30 | const customError = new Error("Custom rejection"); 31 | const promise = newPromiseWithTimeout( 32 | (resolve, reject) => { 33 | reject(customError); 34 | }, 35 | { timeout: 1000 } 36 | ); 37 | 38 | await expect(promise).rejects.toThrow("Custom rejection"); 39 | }); 40 | 41 | it("should clear the timeout when the promise resolves", () => { 42 | const clearTimeoutSpy = jest.spyOn(global, "clearTimeout"); 43 | 44 | newPromiseWithTimeout((resolve) => { 45 | resolve(); 46 | }); 47 | 48 | expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); 49 | clearTimeoutSpy.mockRestore(); 50 | }); 51 | 52 | it("should clear the timeout when the promise rejects", () => { 53 | const clearTimeoutSpy = jest.spyOn(global, "clearTimeout"); 54 | 55 | const promise = newPromiseWithTimeout((resolve, reject) => { 56 | reject(new Error()); 57 | }); 58 | 59 | expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); 60 | 61 | promise.catch(() => {}); 62 | clearTimeoutSpy.mockRestore(); 63 | }); 64 | 65 | it("should abort the AbortSignal on timeout", async () => { 66 | const abortListener = jest.fn(); 67 | 68 | const promise = newPromiseWithTimeout( 69 | (resolve, reject, abortSignal) => { 70 | abortSignal.addEventListener("abort", abortListener); 71 | }, 72 | { timeout: 500 } 73 | ); 74 | 75 | jest.runAllTimers(); 76 | 77 | expect(abortListener).toHaveBeenCalledTimes(1); 78 | 79 | await promise.catch(() => {}); 80 | }); 81 | 82 | it("should handle a callback that returns a resolving promise", async () => { 83 | const promise = newPromiseWithTimeout( 84 | (resolve) => { 85 | setTimeout(() => resolve("inner success"), 500); 86 | }, 87 | { timeout: 1000 } 88 | ); 89 | 90 | jest.advanceTimersByTime(500); 91 | await expect(promise).resolves.toBe("inner success"); 92 | }, 30000); 93 | 94 | it("should handle a callback that returns a rejecting promise", async () => { 95 | const innerError = new Error("inner rejection"); 96 | const promise = newPromiseWithTimeout( 97 | () => { 98 | return Promise.reject(innerError); 99 | }, 100 | { timeout: 1000 } 101 | ); 102 | 103 | await expect(promise).rejects.toThrow("inner rejection"); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /components/utility-components/__tests__/volume-selector.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | import "@testing-library/jest-dom"; 4 | import VolumeSelector from "../volume-selector"; 5 | 6 | const mockOnSelectionChange = jest.fn(); 7 | jest.mock("@nextui-org/react", () => ({ 8 | Select: (props: any) => { 9 | mockOnSelectionChange.mockImplementation((keys) => 10 | props.onSelectionChange(keys) 11 | ); 12 | return ( 13 |
17 | {props.children} 18 |
19 | ); 20 | }, 21 | SelectSection: ({ children }: { children: React.ReactNode }) => ( 22 |
{children}
23 | ), 24 | SelectItem: ({ 25 | children, 26 | textValue, 27 | }: { 28 | children: React.ReactNode; 29 | textValue: string; 30 | }) =>
{textValue || children}
, 31 | })); 32 | 33 | describe("VolumeSelector", () => { 34 | const mockOnVolumeChange = jest.fn(); 35 | const defaultProps = { 36 | volumes: ["Small", "Medium", "Large"], 37 | volumePrices: new Map([ 38 | ["Small", 100], 39 | ["Medium", 200], 40 | ["Large", 300], 41 | ]), 42 | currency: "SATS", 43 | selectedVolume: "Medium", 44 | onVolumeChange: mockOnVolumeChange, 45 | }; 46 | 47 | beforeEach(() => { 48 | jest.clearAllMocks(); 49 | }); 50 | 51 | it("should render null if no volumes are provided", () => { 52 | const { container } = render( 53 | 54 | ); 55 | expect(container.firstChild).toBeNull(); 56 | }); 57 | 58 | it("should render all volumes with their correct prices and currency", () => { 59 | render(); 60 | expect(screen.getByText("Small - 100 SATS")).toBeInTheDocument(); 61 | expect(screen.getByText("Medium - 200 SATS")).toBeInTheDocument(); 62 | expect(screen.getByText("Large - 300 SATS")).toBeInTheDocument(); 63 | }); 64 | 65 | it("should correctly display the selected volume", () => { 66 | render(); 67 | const select = screen.getByTestId("select"); 68 | const selectedKeys = JSON.parse(select.getAttribute("data-selected-keys")!); 69 | expect(selectedKeys).toEqual(["Large"]); 70 | }); 71 | 72 | it("should render a price of 0 if a volume is not in the prices map", () => { 73 | const propsWithMissingPrice = { 74 | ...defaultProps, 75 | volumes: ["Small", "Extra Large"], 76 | volumePrices: new Map([["Small", 100]]), 77 | }; 78 | render(); 79 | expect(screen.getByText("Extra Large - 0 SATS")).toBeInTheDocument(); 80 | }); 81 | 82 | it("should call onVolumeChange with the new volume when a selection is made", () => { 83 | render(); 84 | 85 | mockOnSelectionChange(new Set(["Large"])); 86 | 87 | expect(mockOnVolumeChange).toHaveBeenCalledWith("Large"); 88 | expect(mockOnVolumeChange).toHaveBeenCalledTimes(1); 89 | }); 90 | 91 | it("should handle an empty selection without calling onVolumeChange", () => { 92 | render(); 93 | 94 | mockOnSelectionChange(new Set()); 95 | 96 | expect(mockOnVolumeChange).not.toHaveBeenCalled(); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /components/framer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | 3 | import { Tab } from "@/components/hooks/use-tabs"; 4 | import classNames from "classnames"; 5 | import { motion } from "framer-motion"; 6 | 7 | const transition = { 8 | type: "tween", 9 | ease: "easeOut", 10 | duration: 0.15, 11 | }; 12 | 13 | type Props = { 14 | selectedTabIndex: number; 15 | tabs: Tab[]; 16 | setSelectedTab: (input: [number, number]) => void; 17 | }; 18 | 19 | const Tabs = ({ 20 | tabs, 21 | selectedTabIndex, 22 | setSelectedTab, 23 | }: Props): JSX.Element => { 24 | const [buttonRefs, setButtonRefs] = useState>( 25 | [] 26 | ); 27 | 28 | useEffect(() => { 29 | setButtonRefs((prev) => prev.slice(0, tabs.length)); 30 | }, [tabs.length]); 31 | 32 | const navRef = useRef(null); 33 | // const navRect = navRef.current?.getBoundingClientRect(); 34 | 35 | // const selectedRect = buttonRefs[selectedTabIndex]?.getBoundingClientRect(); 36 | const [selectedRect, setSelectedRect] = useState(null); 37 | const [navRect, setNavRect] = useState(null); 38 | 39 | useEffect(() => { 40 | const updateRects = () => { 41 | const newSelectedRect = 42 | buttonRefs[selectedTabIndex]?.getBoundingClientRect() || null; 43 | const newNavRect = navRef.current?.getBoundingClientRect() || null; 44 | setSelectedRect(newSelectedRect); 45 | setNavRect(newNavRect); 46 | }; 47 | 48 | updateRects(); 49 | 50 | window.addEventListener("resize", updateRects); 51 | 52 | return () => { 53 | window.removeEventListener("resize", updateRects); 54 | }; 55 | }, [buttonRefs, selectedTabIndex]); 56 | 57 | return ( 58 | 99 | ); 100 | }; 101 | 102 | export const Framer = { Tabs }; 103 | -------------------------------------------------------------------------------- /components/utility-components/dropdowns/__tests__/location-dropdown.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | import LocationDropdown, { locationAvatar } from "../location-dropdown"; 4 | import locations from "../../../../public/locationSelection.json"; 5 | 6 | jest.mock("@nextui-org/react", () => ({ 7 | Select: ({ children, startContent, ...props }: any) => ( 8 |
9 | {startContent} 10 | {children} 11 |
12 | ), 13 | SelectSection: ({ children, title, ...props }: any) => ( 14 |
15 | {children} 16 |
17 | ), 18 | SelectItem: ({ children, ...props }: any) => ( 19 |
20 | {children} 21 |
22 | ), 23 | Avatar: ({ alt, src, className }: any) => ( 24 | {alt} 25 | ), 26 | })); 27 | 28 | describe("locationAvatar()", () => { 29 | it("renders an for a valid country", () => { 30 | const { country, iso3166 } = locations.countries[0]; 31 | render(locationAvatar(country)); 32 | const avatar = screen.getByTestId("avatar"); 33 | expect(avatar).toHaveAttribute( 34 | "src", 35 | `https://flagcdn.com/16x12/${iso3166}.png` 36 | ); 37 | expect(avatar).toHaveAttribute("alt", country); 38 | }); 39 | 40 | it("renders an for a valid state", () => { 41 | const { state, iso3166 } = locations.states[0]; 42 | render(locationAvatar(state)); 43 | const avatar = screen.getByTestId("avatar"); 44 | expect(avatar).toHaveAttribute( 45 | "src", 46 | `https://flagcdn.com/16x12/${iso3166}.png` 47 | ); 48 | expect(avatar).toHaveAttribute("alt", state); 49 | }); 50 | 51 | it("renders null for an unknown location", () => { 52 | const { container } = render(locationAvatar("NotALocation")); 53 | // container.firstChild is null if nothing is rendered 54 | expect(container.firstChild).toBeNull(); 55 | }); 56 | }); 57 | 58 | describe("", () => { 59 | const someCountry = locations.countries[1].country; 60 | const someCountryIso = locations.countries[1].iso3166; 61 | 62 | beforeEach(() => { 63 | render(); 64 | }); 65 | 66 | it("renders a Select with three sections: Regional, Countries, U.S. States", () => { 67 | const sections = screen.getAllByTestId("select-section"); 68 | // expect exactly 3 sections in the order defined in useMemo 69 | expect(sections).toHaveLength(3); 70 | expect(sections[0]).toHaveAttribute("data-title", "Regional"); 71 | expect(sections[1]).toHaveAttribute("data-title", "Countries"); 72 | expect(sections[2]).toHaveAttribute("data-title", "U.S. States"); 73 | }); 74 | 75 | it("passes the locationAvatar as startContent on the Select", () => { 76 | const avatar = screen.getByTestId("avatar"); 77 | expect(avatar).toHaveAttribute( 78 | "src", 79 | `https://flagcdn.com/16x12/${someCountryIso}.png` 80 | ); 81 | }); 82 | 83 | it("renders the correct total number of elements", () => { 84 | const items = screen.getAllByTestId("select-item"); 85 | const expectedCount = 86 | /* regional */ 4 + 87 | /* countries */ locations.countries.length + 88 | /* states */ locations.states.length; 89 | expect(items.length).toBe(expectedCount); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation_improvement.yml: -------------------------------------------------------------------------------- 1 | name: 📚 Documentation Improvement 2 | description: Suggest an improvement to our documentation 3 | title: "[DOCS] " 4 | labels: ["documentation"] 5 | assignees: [] 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thanks for helping us improve our documentation! Clear and comprehensive documentation benefits everyone in the community. 11 | 12 | - type: dropdown 13 | id: documentation-area 14 | attributes: 15 | label: Documentation Area 16 | description: Specify which documentation needs improvement 17 | options: 18 | - README.md 19 | - Inline code comments 20 | - API documentation 21 | - Setup/Installation guide 22 | - Contributing guidelines 23 | - User guide 24 | - Developer documentation 25 | - Other 26 | validations: 27 | required: true 28 | 29 | - type: textarea 30 | id: current-issue 31 | attributes: 32 | label: Current Documentation Issue 33 | description: Describe what's currently unclear, missing, or incorrect in the documentation. 34 | placeholder: What's wrong with the current documentation? 35 | validations: 36 | required: true 37 | 38 | - type: textarea 39 | id: suggested-improvement 40 | attributes: 41 | label: Suggested Improvement 42 | description: Provide a clear description of how the documentation could be improved. 43 | placeholder: How should the documentation be improved? 44 | validations: 45 | required: true 46 | 47 | - type: textarea 48 | id: why-matters 49 | attributes: 50 | label: Why This Matters 51 | description: Explain why this documentation improvement would be valuable to users or developers. 52 | placeholder: Why is this improvement important? 53 | validations: 54 | required: true 55 | 56 | - type: dropdown 57 | id: experience-level 58 | attributes: 59 | label: Your Experience Level 60 | description: Let us know your familiarity with Nostr and Bitcoin technologies to help us tailor the documentation appropriately. 61 | options: 62 | - Beginner with Nostr/Bitcoin 63 | - Intermediate knowledge 64 | - Advanced user 65 | - Developer working with these technologies 66 | validations: 67 | required: false 68 | 69 | - type: textarea 70 | id: related-nips 71 | attributes: 72 | label: Related NIPs 73 | description: If this documentation relates to specific Nostr Implementation Possibilities (NIPs), list them here. 74 | placeholder: "e.g. NIP-01, NIP-04, etc." 75 | 76 | - type: textarea 77 | id: additional-resources 78 | attributes: 79 | label: Additional Resources 80 | description: If applicable, provide links to external resources, screenshots, or examples that could help with this documentation improvement. 81 | placeholder: Links, screenshots, or examples... 82 | 83 | - type: textarea 84 | id: implementation-suggestion 85 | attributes: 86 | label: Implementation Suggestion 87 | description: If you have specific wording or content suggestions, please provide them here. 88 | placeholder: Specific content or wording suggestions... 89 | 90 | - type: checkboxes 91 | id: terms 92 | attributes: 93 | label: Code of Conduct 94 | description: By submitting this issue, you agree to follow our Code of Conduct 95 | options: 96 | - label: I agree to follow this project's Code of Conduct 97 | required: true -------------------------------------------------------------------------------- /public/service-worker.js: -------------------------------------------------------------------------------- 1 | const CACHE_NAME = "shopstr-cache-v1"; 2 | 3 | const STATIC_ASSETS = [ 4 | "/", 5 | "/shopstr.ico", 6 | "/manifest.json", 7 | "/shopstr-144x144.png", 8 | "/shopstr-512x512.png", 9 | "/shopstr-2000x2000.png", 10 | ]; 11 | 12 | // Install event - cache static assets 13 | self.addEventListener("install", (event) => { 14 | event.waitUntil( 15 | caches 16 | .open(CACHE_NAME) 17 | .then((cache) => { 18 | return cache.addAll(STATIC_ASSETS); 19 | }) 20 | .then(() => self.skipWaiting()) 21 | ); 22 | }); 23 | 24 | // Activate event - clean up old caches 25 | self.addEventListener("activate", (event) => { 26 | event.waitUntil( 27 | caches.keys().then((cacheNames) => { 28 | return Promise.all( 29 | cacheNames.map((cache) => { 30 | if (cache !== CACHE_NAME) { 31 | return caches.delete(cache); 32 | } 33 | }) 34 | ); 35 | }) 36 | ); 37 | return self.clients.claim(); 38 | }); 39 | 40 | // Intercept fetch requests 41 | self.addEventListener("fetch", (event) => { 42 | // Skip _next resources to avoid caching development resources 43 | if (event.request.url.includes("/_next/")) { 44 | return; 45 | } 46 | 47 | // Cache strategy - network first, fallback to cache 48 | event.respondWith( 49 | fetch(event.request) 50 | .then((response) => { 51 | // Check if we received a valid response 52 | if (!response || response.status !== 200 || response.type !== "basic") { 53 | return response; 54 | } 55 | 56 | // Clone the response 57 | const responseToCache = response.clone(); 58 | 59 | // Open cache and store response 60 | caches.open(CACHE_NAME).then((cache) => { 61 | cache.put(event.request, responseToCache); 62 | }); 63 | 64 | return response; 65 | }) 66 | .catch(() => { 67 | // If network fails, try to serve from cache 68 | return caches.match(event.request); 69 | }) 70 | ); 71 | }); 72 | 73 | // Push notification handler 74 | self.addEventListener("push", function (event) { 75 | if (!event.data) return; 76 | 77 | try { 78 | const data = JSON.parse(event.data.text()); 79 | event.waitUntil( 80 | self.registration.showNotification(data.title, { 81 | body: data.message, 82 | icon: "/shopstr-144x144.png", 83 | data: { 84 | url: data.url ?? "/", 85 | }, 86 | }) 87 | ); 88 | } catch (error) { 89 | console.error("Error processing push notification:", error); 90 | } 91 | }); 92 | 93 | // Notification click handler 94 | self.addEventListener("notificationclick", function (event) { 95 | event.notification.close(); 96 | 97 | event.waitUntil( 98 | clients 99 | .matchAll({ type: "window", includeUncontrolled: true }) 100 | .then(function (clientList) { 101 | if (clientList.length > 0) { 102 | let client = clientList[0]; 103 | for (let i = 0; i < clientList.length; i++) { 104 | if (clientList[i].focused) { 105 | client = clientList[i]; 106 | } 107 | } 108 | if (event.notification.data?.url) { 109 | return client.navigate(event.notification.data.url); 110 | } 111 | return client.focus(); 112 | } 113 | 114 | if (event.notification.data?.url) { 115 | return clients.openWindow(event.notification.data.url); 116 | } 117 | return clients.openWindow("/"); 118 | }) 119 | ); 120 | }); 121 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: ✨ Feature Request 2 | description: Suggest a feature for us to implement 3 | title: "[FEATURE] " 4 | labels: ["enhancement"] 5 | assignees: [] 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thanks for taking the time to suggest a new feature! Please provide as much detail as possible to help us understand your request. 11 | 12 | - type: textarea 13 | id: description 14 | attributes: 15 | label: Description 16 | description: A clear and concise description of what the problem is. 17 | placeholder: "Ex. I'm always frustrated when [...]" 18 | validations: 19 | required: true 20 | 21 | - type: textarea 22 | id: feature-description 23 | attributes: 24 | label: Feature Description 25 | description: A clear and concise description of the feature you're requesting. 26 | placeholder: Describe the feature you'd like to see... 27 | validations: 28 | required: true 29 | 30 | - type: textarea 31 | id: problem-solved 32 | attributes: 33 | label: Problem This Feature Solves 34 | description: Describe the problem or limitation you're facing that this feature would address. 35 | placeholder: What problem would this feature solve? 36 | validations: 37 | required: true 38 | 39 | - type: textarea 40 | id: proposed-solution 41 | attributes: 42 | label: Proposed Solution 43 | description: A clear and concise description of what you want to happen. 44 | placeholder: Describe your proposed solution... 45 | validations: 46 | required: true 47 | 48 | - type: textarea 49 | id: alternative-solutions 50 | attributes: 51 | label: Alternative Solutions 52 | description: A clear and concise description of any alternative solutions or features you've considered. 53 | placeholder: Describe any alternatives you've considered... 54 | 55 | - type: textarea 56 | id: user-impact 57 | attributes: 58 | label: User Impact 59 | description: Explain how this feature would benefit users of Shopstr. 60 | placeholder: How would this feature benefit users? 61 | validations: 62 | required: true 63 | 64 | - type: textarea 65 | id: relevant-nips 66 | attributes: 67 | label: Relevant NIPs 68 | description: If this feature relates to specific Nostr Implementation Possibilities (NIPs), list them here. 69 | placeholder: "e.g. NIP-01, NIP-04, etc." 70 | 71 | - type: textarea 72 | id: implementation-ideas 73 | attributes: 74 | label: Implementation Ideas (Optional) 75 | description: If you have ideas about how to implement this feature, share them here. 76 | placeholder: Share any implementation ideas you might have... 77 | 78 | - type: textarea 79 | id: screenshots 80 | attributes: 81 | label: Screenshots or Mockups 82 | description: If applicable, add screenshots, diagrams, or mockups to help explain your feature request. 83 | placeholder: Drag and drop your images here or click to upload... 84 | 85 | - type: textarea 86 | id: additional-context 87 | attributes: 88 | label: Additional Context 89 | description: Add any other context or information about the feature request here. 90 | placeholder: Any additional context that might be helpful... 91 | 92 | - type: checkboxes 93 | id: terms 94 | attributes: 95 | label: Code of Conduct 96 | description: By submitting this issue, you agree to follow our Code of Conduct 97 | options: 98 | - label: I agree to follow this project's Code of Conduct 99 | required: true -------------------------------------------------------------------------------- /components/my-listings/__tests__/my-listings-feed.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen, fireEvent, act } from "@testing-library/react"; 3 | import "@testing-library/jest-dom"; 4 | import MyListingsFeed from "../my-listings-feed"; 5 | import { SignerContext } from "@/components/utility-components/nostr-context-provider"; 6 | 7 | jest.mock("../my-listings", () => { 8 | const MockMyListingsPage = () =>
; 9 | MockMyListingsPage.displayName = "MyListingsPage"; 10 | return MockMyListingsPage; 11 | }); 12 | 13 | jest.mock("../../product-form", () => { 14 | const MockProductForm = ({ 15 | showModal, 16 | handleModalToggle, 17 | }: { 18 | showModal: boolean; 19 | handleModalToggle: () => void; 20 | }) => ( 21 |
22 | {showModal &&
Modal is Open
} 23 | 24 |
25 | ); 26 | MockProductForm.displayName = "ProductForm"; 27 | return MockProductForm; 28 | }); 29 | 30 | const mockRouterPush = jest.fn(); 31 | jest.mock("next/router", () => ({ 32 | useRouter: () => ({ 33 | push: mockRouterPush, 34 | }), 35 | })); 36 | 37 | const mockSearchParams = { 38 | has: jest.fn(), 39 | }; 40 | jest.mock("next/navigation", () => ({ 41 | useSearchParams: () => mockSearchParams, 42 | })); 43 | 44 | const renderComponent = (isLoggedIn: boolean, hasQueryParam: boolean) => { 45 | mockSearchParams.has.mockReturnValue(hasQueryParam); 46 | return render( 47 | 55 | 56 | 57 | ); 58 | }; 59 | 60 | describe("MyListingsFeed", () => { 61 | beforeEach(() => { 62 | jest.clearAllMocks(); 63 | }); 64 | 65 | test("renders child components and modal is initially closed", () => { 66 | renderComponent(false, false); 67 | 68 | expect(screen.getByTestId("my-listings-page-mock")).toBeInTheDocument(); 69 | expect(screen.getByTestId("product-form-mock")).toBeInTheDocument(); 70 | 71 | expect(screen.queryByTestId("modal-content")).not.toBeInTheDocument(); 72 | }); 73 | 74 | test("shows modal on load if user is logged in and 'addNewListing' param is present", () => { 75 | renderComponent(true, true); 76 | 77 | expect(screen.getByTestId("modal-content")).toBeInTheDocument(); 78 | expect(screen.getByText("Modal is Open")).toBeInTheDocument(); 79 | }); 80 | 81 | test("does not show modal if user is not logged in, even with param", () => { 82 | renderComponent(false, true); 83 | 84 | expect(screen.queryByTestId("modal-content")).not.toBeInTheDocument(); 85 | }); 86 | 87 | test("does not show modal if user is logged in but param is absent", () => { 88 | renderComponent(true, false); 89 | 90 | expect(screen.queryByTestId("modal-content")).not.toBeInTheDocument(); 91 | }); 92 | 93 | test("hides modal and calls router.push when toggle handler is invoked", () => { 94 | renderComponent(true, true); 95 | expect(screen.getByTestId("modal-content")).toBeInTheDocument(); 96 | 97 | const closeButton = screen.getByText("Close Modal"); 98 | 99 | act(() => { 100 | fireEvent.click(closeButton); 101 | }); 102 | 103 | expect(mockRouterPush).toHaveBeenCalledWith(""); 104 | expect(mockRouterPush).toHaveBeenCalledTimes(1); 105 | 106 | expect(screen.queryByTestId("modal-content")).not.toBeInTheDocument(); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Report a bug for us to fix 3 | title: "[BUG] " 4 | labels: ["bug"] 5 | assignees: [] 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thanks for taking the time to fill out this bug report! Please provide as much detail as possible to help us understand and fix the issue. 11 | 12 | - type: textarea 13 | id: description 14 | attributes: 15 | label: Description 16 | description: A clear and concise description of what the bug is. 17 | placeholder: Describe the bug... 18 | validations: 19 | required: true 20 | 21 | - type: textarea 22 | id: current-behavior 23 | attributes: 24 | label: Current Behavior 25 | description: What is currently happening? 26 | placeholder: Describe what's happening now... 27 | validations: 28 | required: true 29 | 30 | - type: textarea 31 | id: expected-behavior 32 | attributes: 33 | label: Expected Behavior 34 | description: What should be happening? 35 | placeholder: Describe what should happen instead... 36 | validations: 37 | required: true 38 | 39 | - type: textarea 40 | id: reproduction-steps 41 | attributes: 42 | label: Steps to Reproduce 43 | description: Clear steps to reproduce the bug 44 | placeholder: | 45 | 1. Go to '...' 46 | 2. Click on '....' 47 | 3. Scroll down to '....' 48 | 4. See error 49 | validations: 50 | required: true 51 | 52 | - type: input 53 | id: nextjs-version 54 | attributes: 55 | label: Next.js Version 56 | description: What version of Next.js are you using? 57 | placeholder: "e.g. 14.2.26" 58 | validations: 59 | required: true 60 | 61 | - type: dropdown 62 | id: operating-system 63 | attributes: 64 | label: Operating System 65 | description: Which operating system are you using? 66 | options: 67 | - Windows 10 68 | - Windows 11 69 | - macOS 70 | - Linux (Ubuntu) 71 | - Linux (Other) 72 | - Other 73 | validations: 74 | required: true 75 | 76 | - type: input 77 | id: nodejs-version 78 | attributes: 79 | label: Node.js Version 80 | description: What version of Node.js are you using? 81 | placeholder: "e.g. 18.17.0" 82 | validations: 83 | required: true 84 | 85 | - type: dropdown 86 | id: browser 87 | attributes: 88 | label: Browser 89 | description: Which browser are you using? 90 | options: 91 | - Chrome 92 | - Firefox 93 | - Safari 94 | - Edge 95 | - Opera 96 | - Other 97 | validations: 98 | required: true 99 | 100 | - type: textarea 101 | id: screenshots 102 | attributes: 103 | label: Screenshots 104 | description: If applicable, add screenshots to help explain your problem. You can drag and drop images here. 105 | placeholder: Drag and drop your screenshots here or click to upload... 106 | 107 | - type: textarea 108 | id: additional-context 109 | attributes: 110 | label: Additional Context 111 | description: Add any other context about the problem here. 112 | placeholder: Any additional information that might be helpful... 113 | 114 | - type: checkboxes 115 | id: terms 116 | attributes: 117 | label: Code of Conduct 118 | description: By submitting this issue, you agree to follow our Code of Conduct 119 | options: 120 | - label: I agree to follow this project's Code of Conduct 121 | required: true -------------------------------------------------------------------------------- /components/wallet/transactions.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { 3 | ArrowDownTrayIcon, 4 | ArrowUpTrayIcon, 5 | BanknotesIcon, 6 | BoltIcon, 7 | ShoppingBagIcon, 8 | } from "@heroicons/react/24/outline"; 9 | import { getLocalStorageData } from "@/utils/nostr/nostr-helper-functions"; 10 | import { Transaction } from "@/utils/types/types"; 11 | 12 | // add found proofs as nutsack deposit with different icon 13 | 14 | const Transactions = () => { 15 | const [history, setHistory] = useState([]); 16 | 17 | useEffect(() => { 18 | // Function to fetch and update transactions 19 | const fetchAndUpdateTransactions = () => { 20 | const localData = getLocalStorageData(); 21 | if (localData && localData.history) { 22 | setHistory(localData.history); 23 | } 24 | }; 25 | // Initial fetch 26 | fetchAndUpdateTransactions(); 27 | // Set up polling with setInterval 28 | const interval = setInterval(() => { 29 | fetchAndUpdateTransactions(); 30 | }, 2100); // Polling every 2100 milliseconds (2.1 seconds) 31 | // Clean up on component unmount 32 | return () => clearInterval(interval); 33 | }, []); 34 | 35 | const formatDate = (timestamp: number) => { 36 | const date = new Date(timestamp * 1000); 37 | const options: Intl.DateTimeFormatOptions = { 38 | year: "numeric", 39 | month: "short", 40 | day: "numeric", 41 | hour: "2-digit", 42 | minute: "2-digit", 43 | second: "2-digit", 44 | hour12: true, 45 | }; 46 | return `${date.toLocaleDateString("en-US", options)}`; 47 | }; 48 | 49 | return ( 50 |
51 |
52 | 53 | 54 | 55 | 58 | 61 | 64 | 65 | 66 | 67 | {history.map((transaction: Transaction, index) => ( 68 | 72 | 85 | 86 | 87 | 88 | ))} 89 | 90 |
56 | Type 57 | 59 | Amount 60 | 62 | Date 63 |
73 | {transaction.type === 1 ? ( 74 | 75 | ) : transaction.type === 2 ? ( 76 | 77 | ) : transaction.type === 3 ? ( 78 | 79 | ) : transaction.type === 4 ? ( 80 | 81 | ) : transaction.type === 5 ? ( 82 | 83 | ) : null} 84 | {transaction.amount} sats{formatDate(transaction.date)}
91 |
92 |
93 | ); 94 | }; 95 | 96 | export default Transactions; 97 | -------------------------------------------------------------------------------- /utils/types/types.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "nostr-tools"; 2 | 3 | export type ItemType = "products" | "profiles" | "chats" | "communities"; 4 | 5 | type ProductFormValue = [key: string, ...values: string[]]; 6 | export type ProductFormValues = ProductFormValue[]; 7 | 8 | export interface NostrEvent extends Event {} 9 | 10 | export interface NostrMessageEvent extends NostrEvent { 11 | read: boolean; 12 | } 13 | 14 | export interface ChatObject { 15 | unreadCount: number; 16 | decryptedChat: NostrMessageEvent[]; 17 | } 18 | 19 | export interface CommunityRelays { 20 | approvals: string[]; // relays to publish/fetch approvals 21 | requests: string[]; // relays to publish/fetch post requests 22 | metadata: string[]; // relays for community author metadata (profile) 23 | all: string[]; // flattened list of all relays declared 24 | } 25 | 26 | export interface Community { 27 | id: string; // community definition event id 28 | kind: number; 29 | pubkey: string; // author pubkey 30 | createdAt: number; 31 | d: string; // identifier (a-tag identifier) 32 | name: string; 33 | description: string; 34 | image: string; 35 | moderators: string[]; 36 | relays: CommunityRelays; 37 | // backward compatibility: keep a simple relays array optional 38 | relaysList?: string[]; 39 | } 40 | 41 | export interface CommunityPost extends NostrEvent { 42 | // Augmented by fetchCommunityPosts: optional approval metadata 43 | approved?: boolean; 44 | approvalEventId?: string; 45 | approvedBy?: string; 46 | } 47 | 48 | export interface ShopProfile { 49 | pubkey: string; 50 | content: { 51 | name: string; 52 | about: string; 53 | ui: { 54 | picture: string; 55 | banner: string; 56 | theme: string; 57 | darkMode: boolean; 58 | }; 59 | merchants: string[]; 60 | }; 61 | created_at: number; 62 | } 63 | 64 | export interface ProfileData { 65 | pubkey: string; 66 | content: { 67 | name?: string; 68 | picture?: string; 69 | about?: string; 70 | banner?: string; 71 | lud16?: string; 72 | nip05?: string; 73 | payment_preference?: string; 74 | fiat_options?: string[]; 75 | shopstr_donation?: number; 76 | }; 77 | created_at: number; 78 | } 79 | 80 | export interface Transaction { 81 | type: number; 82 | amount: number; 83 | date: number; 84 | } 85 | 86 | export type FiatOptionsType = { 87 | [key: string]: string; 88 | }; 89 | 90 | export interface ShippingFormData { 91 | Name: string; 92 | Address: string; 93 | Unit?: string; 94 | City: string; 95 | "Postal Code": string; 96 | "State/Province": string; 97 | Country: string; 98 | Required?: string; 99 | } 100 | 101 | export interface ContactFormData { 102 | Contact: string; 103 | "Contact Type": string; 104 | Instructions: string; 105 | Required?: string; 106 | } 107 | 108 | export interface CombinedFormData { 109 | Name: string; 110 | Address: string; 111 | Unit?: string; 112 | City: string; 113 | "Postal Code": string; 114 | "State/Province": string; 115 | Country: string; 116 | Contact: string; 117 | "Contact Type": string; 118 | Instructions: string; 119 | Required?: string; 120 | } 121 | 122 | declare global { 123 | interface Window { 124 | // For NIP-07 browser extensions 125 | nostr: { 126 | getPublicKey: () => Promise; 127 | signEvent: (event: any) => Promise; 128 | nip44: { 129 | encrypt: (pubkey: string, plainText: string) => Promise; 130 | decrypt: (pubkey: string, cipherText: string) => Promise; 131 | }; 132 | }; 133 | // For WebLN (which Alby SDK also polyfills) 134 | webln: any; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /components/communities/CreateCommunityForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { Button, Input, Textarea, Image } from "@nextui-org/react"; 3 | import { Community } from "@/utils/types/types"; 4 | import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES"; 5 | import { v4 as uuidv4 } from "uuid"; 6 | import { useForm, Controller } from "react-hook-form"; 7 | import { FileUploaderButton } from "@/components/utility-components/file-uploader"; 8 | 9 | interface CommunityFormData { 10 | name: string; 11 | description: string; 12 | image: string; 13 | d: string; 14 | } 15 | 16 | interface CreateCommunityFormProps { 17 | existingCommunity: Community | null; 18 | onSave: (data: CommunityFormData) => void; 19 | onCancel?: () => void; 20 | } 21 | 22 | const CreateCommunityForm: React.FC = ({ 23 | existingCommunity, 24 | onSave, 25 | onCancel, 26 | }) => { 27 | const { control, handleSubmit, setValue, watch } = useForm( 28 | { 29 | defaultValues: { 30 | name: "", 31 | description: "", 32 | image: "", 33 | d: uuidv4(), 34 | }, 35 | } 36 | ); 37 | 38 | const watchImage = watch("image"); 39 | 40 | useEffect(() => { 41 | if (existingCommunity) { 42 | setValue("name", existingCommunity.name); 43 | setValue("description", existingCommunity.description); 44 | setValue("image", existingCommunity.image); 45 | setValue("d", existingCommunity.d); 46 | } 47 | }, [existingCommunity, setValue]); 48 | 49 | return ( 50 | // disable native browser validation so react-hook-form controls errors consistently 51 |
52 | ( 57 | 64 | )} 65 | /> 66 | ( 71 |