├── .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 |
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 | [](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 |
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 |
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 |
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 |
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 |
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 |
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/pages/onboarding/shop-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 { ArrowLeftEndOnRectangleIcon } from "@heroicons/react/24/outline";
5 | import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES";
6 | import ShopProfileForm from "@/components/settings/shop-profile-form";
7 |
8 | const OnboardingShopProfile = () => {
9 | const router = useRouter();
10 |
11 | const handleFinish = () => {
12 | router.push("/marketplace");
13 | };
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
28 |
29 | Shopstr
30 |
31 |
32 |
33 |
34 | Step 4: Setup Your Shop
35 |
36 |
37 | Set up your shop details or, if you're not a seller, skip
38 | this step to finish onboarding.
39 |
40 |
41 |
42 |
43 |
44 |
45 |
51 |
52 |
53 |
54 |
55 |
56 | );
57 | };
58 |
59 | export default OnboardingShopProfile;
60 |
--------------------------------------------------------------------------------
/components/utility-components/__tests__/shopstr-switch.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render, screen, fireEvent } from "@testing-library/react";
3 | import "@testing-library/jest-dom";
4 | import ShopstrSwitch from "../shopstr-switch";
5 |
6 | const mockUseTheme = { theme: "light" };
7 | jest.mock("next-themes", () => ({
8 | useTheme: () => mockUseTheme,
9 | }));
10 |
11 | const mockRouterPush = jest.fn();
12 | jest.mock("next/router", () => ({
13 | useRouter: () => ({
14 | push: mockRouterPush,
15 | }),
16 | }));
17 |
18 | jest.mock("@nextui-org/react", () => ({
19 | Switch: (props: { onClick: () => void; color: string }) => (
20 |
21 | ),
22 | }));
23 |
24 | describe("ShopstrSwitch", () => {
25 | const mockSetWotFilter = jest.fn();
26 |
27 | beforeEach(() => {
28 | jest.clearAllMocks();
29 | mockUseTheme.theme = "light";
30 | });
31 |
32 | it("should call setWotFilter with the inverted value when clicked", () => {
33 | render();
34 | const switchControl = screen.getByRole("switch");
35 |
36 | fireEvent.click(switchControl);
37 |
38 | expect(mockSetWotFilter).toHaveBeenCalledWith(true);
39 | });
40 |
41 | it("should call router.push when the 'Trust' label is clicked", () => {
42 | render();
43 | const trustLabel = screen.getByText("Trust");
44 |
45 | fireEvent.click(trustLabel);
46 |
47 | expect(mockRouterPush).toHaveBeenCalledWith("/settings/preferences");
48 | });
49 |
50 | it('should have the "secondary" color in light mode', () => {
51 | render();
52 |
53 | const switchControl = screen.getByRole("switch");
54 |
55 | expect(switchControl).toHaveAttribute("data-color", "secondary");
56 | });
57 |
58 | it('should have the "warning" color in dark mode', () => {
59 | mockUseTheme.theme = "dark";
60 | render();
61 |
62 | const switchControl = screen.getByRole("switch");
63 |
64 | expect(switchControl).toHaveAttribute("data-color", "warning");
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/pages/api/db/discount-codes.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from "next";
2 | import {
3 | addDiscountCode,
4 | getDiscountCodesByPubkey,
5 | validateDiscountCode,
6 | deleteDiscountCode,
7 | } from "@/utils/db/db-service";
8 |
9 | export default async function handler(
10 | req: NextApiRequest,
11 | res: NextApiResponse
12 | ) {
13 | if (req.method === "POST") {
14 | try {
15 | const { code, pubkey, discountPercentage, expiration } = req.body;
16 |
17 | if (!code || !pubkey || !discountPercentage) {
18 | return res.status(400).json({ error: "Missing required fields" });
19 | }
20 |
21 | await addDiscountCode(code, pubkey, discountPercentage, expiration);
22 | res.status(200).json({ success: true });
23 | } catch (error) {
24 | console.error("Failed to add discount code:", error);
25 | res.status(500).json({ error: "Failed to add discount code" });
26 | }
27 | } else if (req.method === "GET") {
28 | try {
29 | const { pubkey, code, validate } = req.query;
30 |
31 | if (validate && code && pubkey) {
32 | const result = await validateDiscountCode(
33 | code as string,
34 | pubkey as string
35 | );
36 | return res.status(200).json(result);
37 | }
38 |
39 | if (!pubkey) {
40 | return res.status(400).json({ error: "Pubkey required" });
41 | }
42 |
43 | const codes = await getDiscountCodesByPubkey(pubkey as string);
44 | res.status(200).json(codes);
45 | } catch (error) {
46 | console.error("Failed to fetch discount codes:", error);
47 | res.status(500).json({ error: "Failed to fetch discount codes" });
48 | }
49 | } else if (req.method === "DELETE") {
50 | try {
51 | const { code, pubkey } = req.body;
52 |
53 | if (!code || !pubkey) {
54 | return res.status(400).json({ error: "Missing required fields" });
55 | }
56 |
57 | await deleteDiscountCode(code, pubkey);
58 | res.status(200).json({ success: true });
59 | } catch (error) {
60 | console.error("Failed to delete discount code:", error);
61 | res.status(500).json({ error: "Failed to delete discount code" });
62 | }
63 | } else {
64 | res.status(405).json({ error: "Method not allowed" });
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/utils/nostr/__tests__/zap-validator.test.ts:
--------------------------------------------------------------------------------
1 | import { validateZapReceipt } from "@/utils/nostr/zap-validator";
2 | import { NostrManager } from "@/utils/nostr/nostr-manager";
3 |
4 | const mockFetch = jest.fn();
5 | const mockNostrManager = {
6 | fetch: mockFetch,
7 | } as unknown as NostrManager;
8 |
9 | describe("validateZapReceipt", () => {
10 | beforeEach(() => {
11 | jest.clearAllMocks();
12 | jest.useFakeTimers();
13 | });
14 |
15 | afterEach(() => {
16 | jest.useRealTimers();
17 | });
18 |
19 | it("returns true immediately if receipt is found on first try", async () => {
20 | mockFetch.mockResolvedValue([{ id: "zap-receipt" }]);
21 |
22 | const promise = validateZapReceipt(mockNostrManager, "item-123", 1000);
23 |
24 | const result = await promise;
25 |
26 | expect(result).toBe(true);
27 | expect(mockFetch).toHaveBeenCalledTimes(1);
28 | });
29 |
30 | it("retries and returns true if receipt is found on the 3rd try", async () => {
31 | mockFetch
32 | .mockResolvedValueOnce([])
33 | .mockResolvedValueOnce([])
34 | .mockResolvedValueOnce([{ id: "zap-receipt" }]);
35 |
36 | const promise = validateZapReceipt(mockNostrManager, "item-123", 1000);
37 |
38 | await jest.advanceTimersByTimeAsync(3000);
39 |
40 | const result = await promise;
41 |
42 | expect(result).toBe(true);
43 | expect(mockFetch).toHaveBeenCalledTimes(3);
44 | });
45 |
46 | it("returns false after max retries (5) if no receipt is found", async () => {
47 | mockFetch.mockResolvedValue([]);
48 |
49 | const promise = validateZapReceipt(mockNostrManager, "item-123", 1000);
50 |
51 | await jest.advanceTimersByTimeAsync(6000);
52 |
53 | const result = await promise;
54 |
55 | expect(result).toBe(false);
56 | expect(mockFetch).toHaveBeenCalledTimes(5);
57 | });
58 |
59 | it("uses the correct filter parameters", async () => {
60 | mockFetch.mockResolvedValue([{ id: "zap" }]);
61 | const minTimestamp = 162000;
62 | const productId = "item-xyz";
63 |
64 | await validateZapReceipt(mockNostrManager, productId, minTimestamp);
65 |
66 | expect(mockFetch).toHaveBeenCalledWith([
67 | expect.objectContaining({
68 | kinds: [9735],
69 | "#e": [productId],
70 | since: minTimestamp,
71 | }),
72 | ]);
73 | });
74 | });
--------------------------------------------------------------------------------
/components/utility-components/compact-categories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { CATEGORIES } from "@/utils/STATIC-VARIABLES";
3 | import { Chip, Tooltip } from "@nextui-org/react";
4 |
5 | const CompactCategories = ({ categories }: { categories: string[] }) => {
6 | const [isOpen, setIsOpen] = React.useState(false);
7 |
8 | const validCategories = categories
9 | ?.filter((category) => CATEGORIES.includes(category))
10 | .sort((a, b) => b.length - a.length); // sort by longest to shortest to avoid styling bugs of categories jumping around
11 |
12 | const categoryChips = validCategories?.map((category, index) => {
13 | return {category};
14 | });
15 |
16 | if (validCategories?.length === 0) return null;
17 |
18 | return (
19 | <>
20 | {categoryChips && (
21 | {categoryChips}
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 |
})
56 |
57 | )}
58 |
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 |
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 |
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 |
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 | |
56 | Type
57 | |
58 |
59 | Amount
60 | |
61 |
62 | Date
63 | |
64 |
65 |
66 |
67 | {history.map((transaction: Transaction, index) => (
68 |
72 | |
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 | |
85 | {transaction.amount} sats |
86 | {formatDate(transaction.date)} |
87 |
88 | ))}
89 |
90 |
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 |
109 | );
110 | };
111 |
112 | export default CreateCommunityForm;
113 |
--------------------------------------------------------------------------------