fix: resolve lint errors from phase 32/33/34 execution
Auto-fixed formatting issues and removed unused imports introduced by background execution agents across currency, i18n, and sharing code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,8 @@ milestone: v2.3
|
|||||||
milestone_name: Global & Social Ready
|
milestone_name: Global & Social Ready
|
||||||
status: executing
|
status: executing
|
||||||
stopped_at: Phase 34 context gathered
|
stopped_at: Phase 34 context gathered
|
||||||
last_updated: "2026-04-13T16:24:18.387Z"
|
last_updated: "2026-04-13T16:27:56.612Z"
|
||||||
last_activity: 2026-04-13
|
last_activity: 2026-04-13 -- Phase 34 execution started
|
||||||
progress:
|
progress:
|
||||||
total_phases: 16
|
total_phases: 16
|
||||||
completed_phases: 6
|
completed_phases: 6
|
||||||
@@ -21,14 +21,14 @@ progress:
|
|||||||
See: .planning/PROJECT.md (updated 2026-04-09)
|
See: .planning/PROJECT.md (updated 2026-04-09)
|
||||||
|
|
||||||
**Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing.
|
**Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing.
|
||||||
**Current focus:** Phase 34 — i18n Foundation
|
**Current focus:** Phase 34 — i18n-foundation
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 999.1
|
Phase: 34 (i18n-foundation) — EXECUTING
|
||||||
Plan: Not started
|
Plan: 1 of 5
|
||||||
Status: Executing Phase 34
|
Status: Executing Phase 34
|
||||||
Last activity: 2026-04-13
|
Last activity: 2026-04-13 -- Phase 34 execution started
|
||||||
|
|
||||||
Progress: [░░░░░░░░░░] 0%
|
Progress: [░░░░░░░░░░] 0%
|
||||||
|
|
||||||
|
|||||||
69
.planning/phases/32-setup-sharing-system/32-UAT.md
Normal file
69
.planning/phases/32-setup-sharing-system/32-UAT.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
status: testing
|
||||||
|
phase: 32-setup-sharing-system
|
||||||
|
source: [32-01-SUMMARY.md, 32-02-SUMMARY.md, 32-03-SUMMARY.md, 32-04-SUMMARY.md]
|
||||||
|
started: 2026-04-13T18:00:00.000Z
|
||||||
|
updated: 2026-04-13T18:00:00.000Z
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Test
|
||||||
|
|
||||||
|
number: 1
|
||||||
|
name: Visibility badge on setup cards
|
||||||
|
expected: |
|
||||||
|
On the setups list page, each setup card shows a visibility indicator. Public setups show a green globe icon, link-shared show a blue link icon, and private show a gray lock icon.
|
||||||
|
awaiting: user response
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### 1. Visibility badge on setup cards
|
||||||
|
expected: On the setups list page, each setup card shows a visibility indicator. Public setups show a green globe icon, link-shared show a blue link icon, and private show a gray lock icon.
|
||||||
|
result: [pending]
|
||||||
|
|
||||||
|
### 2. Share button on setup detail page
|
||||||
|
expected: On a setup detail page (as the owner), there's a "Share" button (desktop: text + icon, mobile: icon-only 44px touch target) that replaces the old public/private globe toggle. The icon reflects the current visibility state.
|
||||||
|
result: [pending]
|
||||||
|
|
||||||
|
### 3. Share modal — visibility picker
|
||||||
|
expected: Clicking the Share button opens a modal with three visibility options: Private (gray), Link (blue), Public (green). Selecting one immediately updates the setup's visibility via API call. Current state is highlighted.
|
||||||
|
result: [pending]
|
||||||
|
|
||||||
|
### 4. Share modal — create share link
|
||||||
|
expected: In the share modal, there's a section to create share links with an expiration dropdown (7 days, 14 days, 30 days, No expiration). Creating a link generates a URL and shows it in the active links list.
|
||||||
|
result: [pending]
|
||||||
|
|
||||||
|
### 5. Share modal — copy and revoke links
|
||||||
|
expected: Each active share link in the modal has a copy-to-clipboard button and a revoke button. Copying puts the URL in the clipboard. Revoking removes the link from the active list.
|
||||||
|
result: [pending]
|
||||||
|
|
||||||
|
### 6. Share modal — private deactivates links
|
||||||
|
expected: When switching visibility to "Private" while share links exist, links are deactivated (not deleted). Switching back to "Link" reactivates them.
|
||||||
|
result: [pending]
|
||||||
|
|
||||||
|
### 7. Short URL access (/s/token)
|
||||||
|
expected: Visiting /s/{token} redirects to /setups/{id}?share={token}. The setup loads correctly showing its items and totals.
|
||||||
|
result: [pending]
|
||||||
|
|
||||||
|
### 8. Shared setup viewer — read-only mode
|
||||||
|
expected: When viewing a setup via share token, a blue "Shared setup" banner appears at the top. All owner controls are hidden: no Add Items, no Share button, no Delete, no item removal, no classification cycling.
|
||||||
|
result: [pending]
|
||||||
|
|
||||||
|
### 9. Invalid share token error
|
||||||
|
expected: Visiting a setup with an invalid or expired share token shows a "Link not available" error page instead of the setup content.
|
||||||
|
result: [pending]
|
||||||
|
|
||||||
|
### 10. Discovery feed uses visibility
|
||||||
|
expected: Only setups with visibility="public" appear on the discovery feed and profile pages. Link-shared and private setups do not appear.
|
||||||
|
result: [pending]
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
total: 10
|
||||||
|
passed: 0
|
||||||
|
issues: 0
|
||||||
|
pending: 10
|
||||||
|
skipped: 0
|
||||||
|
|
||||||
|
## Gaps
|
||||||
|
|
||||||
|
[none yet]
|
||||||
@@ -48,7 +48,11 @@ export function BottomTabBar() {
|
|||||||
<div className="flex justify-around">
|
<div className="flex justify-around">
|
||||||
{/* Home tab — always a Link */}
|
{/* Home tab — always a Link */}
|
||||||
<Link to="/">
|
<Link to="/">
|
||||||
<TabItemWrapper icon="house" label={t("nav.home")} isActive={isHome} />
|
<TabItemWrapper
|
||||||
|
icon="house"
|
||||||
|
label={t("nav.home")}
|
||||||
|
isActive={isHome}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Collection tab — Link if authenticated, button if anonymous */}
|
{/* Collection tab — Link if authenticated, button if anonymous */}
|
||||||
@@ -73,17 +77,29 @@ export function BottomTabBar() {
|
|||||||
{/* Setups tab — Link if authenticated, button if anonymous */}
|
{/* Setups tab — Link if authenticated, button if anonymous */}
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<Link to="/setups">
|
<Link to="/setups">
|
||||||
<TabItemWrapper icon="layers" label={t("nav.setups")} isActive={isSetups} />
|
<TabItemWrapper
|
||||||
|
icon="layers"
|
||||||
|
label={t("nav.setups")}
|
||||||
|
isActive={isSetups}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" onClick={openAuthPrompt}>
|
<button type="button" onClick={openAuthPrompt}>
|
||||||
<TabItemWrapper icon="layers" label={t("nav.setups")} isActive={isSetups} />
|
<TabItemWrapper
|
||||||
|
icon="layers"
|
||||||
|
label={t("nav.setups")}
|
||||||
|
isActive={isSetups}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search tab — always a button, opens CatalogSearchOverlay */}
|
{/* Search tab — always a button, opens CatalogSearchOverlay */}
|
||||||
<button type="button" onClick={() => openCatalogSearch("collection")}>
|
<button type="button" onClick={() => openCatalogSearch("collection")}>
|
||||||
<TabItemWrapper icon="search" label={t("nav.search")} isActive={false} />
|
<TabItemWrapper
|
||||||
|
icon="search"
|
||||||
|
label={t("nav.search")}
|
||||||
|
isActive={false}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export function ExternalLinkDialog() {
|
|||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
{t("externalLink.title")}
|
{t("externalLink.title")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 mb-1">{t("externalLink.redirectMessage")}</p>
|
<p className="text-sm text-gray-600 mb-1">
|
||||||
|
{t("externalLink.redirectMessage")}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-gray-600 break-all mb-6">
|
<p className="text-sm text-gray-600 break-all mb-6">
|
||||||
{externalLinkUrl}
|
{externalLinkUrl}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ export function OnboardingDone({
|
|||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
{t("done.title")}
|
{t("done.title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-base text-gray-500 mb-8">
|
<p className="text-base text-gray-500 mb-8">{t("done.subtitle")}</p>
|
||||||
{t("done.subtitle")}
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onFinish}
|
onClick={onFinish}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { HOBBIES } from "@/shared/hobbyConfig";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { HOBBIES } from "@/shared/hobbyConfig";
|
||||||
import { HobbyCard } from "./HobbyCard";
|
import { HobbyCard } from "./HobbyCard";
|
||||||
|
|
||||||
interface OnboardingHobbyPickerProps {
|
interface OnboardingHobbyPickerProps {
|
||||||
@@ -20,9 +20,7 @@ export function OnboardingHobbyPicker({
|
|||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
{t("hobby.title")}
|
{t("hobby.title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-base text-gray-500 mb-8">
|
<p className="text-base text-gray-500 mb-8">{t("hobby.subtitle")}</p>
|
||||||
{t("hobby.subtitle")}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap justify-center gap-4 mb-8">
|
<div className="flex flex-wrap justify-center gap-4 mb-8">
|
||||||
{HOBBIES.map((hobby) => (
|
{HOBBIES.map((hobby) => (
|
||||||
<HobbyCard
|
<HobbyCard
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getTagsForHobbies } from "@/shared/hobbyConfig";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getTagsForHobbies } from "@/shared/hobbyConfig";
|
||||||
import { usePopularItems } from "../../hooks/useOnboarding";
|
import { usePopularItems } from "../../hooks/useOnboarding";
|
||||||
import { SelectableItemCard } from "./SelectableItemCard";
|
import { SelectableItemCard } from "./SelectableItemCard";
|
||||||
|
|
||||||
@@ -54,9 +54,7 @@ export function OnboardingItemBrowser({
|
|||||||
? t("items.title", { hobby: selectedHobbies[0] })
|
? t("items.title", { hobby: selectedHobbies[0] })
|
||||||
: t("items.titleMultiple")}
|
: t("items.titleMultiple")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-base text-gray-500 mb-8">
|
<p className="text-base text-gray-500 mb-8">{t("items.subtitle")}</p>
|
||||||
{t("items.subtitle")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import type { Currency } from "../lib/formatters";
|
import type { Currency } from "../lib/formatters";
|
||||||
import { useSetting } from "./useSettings";
|
import { useSetting } from "./useSettings";
|
||||||
|
|
||||||
const VALID_CURRENCIES: Currency[] = [
|
const VALID_CURRENCIES: Currency[] = ["USD", "EUR", "GBP", "JPY", "CAD", "AUD"];
|
||||||
"USD",
|
|
||||||
"EUR",
|
|
||||||
"GBP",
|
|
||||||
"JPY",
|
|
||||||
"CAD",
|
|
||||||
"AUD",
|
|
||||||
];
|
|
||||||
|
|
||||||
const CURRENCY_MARKET_MAP: Record<string, string> = {
|
const CURRENCY_MARKET_MAP: Record<string, string> = {
|
||||||
EUR: "EU",
|
EUR: "EU",
|
||||||
|
|||||||
@@ -70,7 +70,11 @@ export function formatDualPrice(options: DualPriceOptions): {
|
|||||||
converted: string;
|
converted: string;
|
||||||
} {
|
} {
|
||||||
const locale = options.locale ?? "en";
|
const locale = options.locale ?? "en";
|
||||||
const source = formatPrice(options.sourceCents, options.sourceCurrency, locale);
|
const source = formatPrice(
|
||||||
|
options.sourceCents,
|
||||||
|
options.sourceCurrency,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
const converted = `~${formatPrice(options.convertedCents, options.targetCurrency, locale)}`;
|
const converted = `~${formatPrice(options.convertedCents, options.targetCurrency, locale)}`;
|
||||||
return { source, converted };
|
return { source, converted };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
import enCollection from "../locales/en/collection.json";
|
|
||||||
import enCommon from "../locales/en/common.json";
|
|
||||||
import enOnboarding from "../locales/en/onboarding.json";
|
|
||||||
import enSettings from "../locales/en/settings.json";
|
|
||||||
import enSetups from "../locales/en/setups.json";
|
|
||||||
import enThreads from "../locales/en/threads.json";
|
|
||||||
|
|
||||||
import deCollection from "../locales/de/collection.json";
|
import deCollection from "../locales/de/collection.json";
|
||||||
import deCommon from "../locales/de/common.json";
|
import deCommon from "../locales/de/common.json";
|
||||||
import deOnboarding from "../locales/de/onboarding.json";
|
import deOnboarding from "../locales/de/onboarding.json";
|
||||||
import deSettings from "../locales/de/settings.json";
|
import deSettings from "../locales/de/settings.json";
|
||||||
import deSetups from "../locales/de/setups.json";
|
import deSetups from "../locales/de/setups.json";
|
||||||
import deThreads from "../locales/de/threads.json";
|
import deThreads from "../locales/de/threads.json";
|
||||||
|
import enCollection from "../locales/en/collection.json";
|
||||||
|
import enCommon from "../locales/en/common.json";
|
||||||
|
import enOnboarding from "../locales/en/onboarding.json";
|
||||||
|
import enSettings from "../locales/en/settings.json";
|
||||||
|
import enSetups from "../locales/en/setups.json";
|
||||||
|
import enThreads from "../locales/en/threads.json";
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
|
|||||||
@@ -59,9 +59,7 @@ function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
|
|||||||
{t("errors.somethingWentWrong")}
|
{t("errors.somethingWentWrong")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 mb-6">
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
{error instanceof Error
|
{error instanceof Error ? error.message : t("errors.unexpectedError")}
|
||||||
? error.message
|
|
||||||
: t("errors.unexpectedError")}
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -259,7 +257,9 @@ function CandidateDeleteDialog({
|
|||||||
disabled={deleteCandidate.isPending}
|
disabled={deleteCandidate.isPending}
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
|
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{deleteCandidate.isPending ? t("actions.deleting") : t("actions.delete")}
|
{deleteCandidate.isPending
|
||||||
|
? t("actions.deleting")
|
||||||
|
: t("actions.delete")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -321,7 +321,9 @@ function ResolveDialog({
|
|||||||
disabled={resolveThread.isPending}
|
disabled={resolveThread.isPending}
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:opacity-50 rounded-lg transition-colors"
|
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:opacity-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{resolveThread.isPending ? t("actions.saving") : t("confirm.pickWinner")}
|
{resolveThread.isPending
|
||||||
|
? t("actions.saving")
|
||||||
|
: t("confirm.pickWinner")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import { useState } from "react";
|
|||||||
import { GearImage, imageContainerBg } from "../../components/GearImage";
|
import { GearImage, imageContainerBg } from "../../components/GearImage";
|
||||||
import { useAuth } from "../../hooks/useAuth";
|
import { useAuth } from "../../hooks/useAuth";
|
||||||
import { useCurrency } from "../../hooks/useCurrency";
|
import { useCurrency } from "../../hooks/useCurrency";
|
||||||
import { useExchangeRates } from "../../hooks/useExchangeRates";
|
|
||||||
import { useFormatters } from "../../hooks/useFormatters";
|
import { useFormatters } from "../../hooks/useFormatters";
|
||||||
import {
|
import {
|
||||||
useGlobalItem,
|
useGlobalItem,
|
||||||
useGlobalItemCommunityStats,
|
useGlobalItemCommunityStats,
|
||||||
useGlobalItemPrices,
|
useGlobalItemPrices,
|
||||||
} from "../../hooks/useGlobalItems";
|
} from "../../hooks/useGlobalItems";
|
||||||
import { formatPrice, type Currency } from "../../lib/formatters";
|
import { type Currency, formatPrice } from "../../lib/formatters";
|
||||||
import { LucideIcon } from "../../lib/iconData";
|
import { LucideIcon } from "../../lib/iconData";
|
||||||
import { useUIStore } from "../../stores/uiStore";
|
import { useUIStore } from "../../stores/uiStore";
|
||||||
|
|
||||||
@@ -263,11 +262,8 @@ function GlobalItemDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarketPricesSection({
|
function MarketPricesSection({ globalItemId }: { globalItemId: number }) {
|
||||||
globalItemId,
|
|
||||||
}: { globalItemId: number }) {
|
|
||||||
const { market: userMarket } = useCurrency();
|
const { market: userMarket } = useCurrency();
|
||||||
const { price } = useFormatters();
|
|
||||||
const { data: pricesData } = useGlobalItemPrices(globalItemId);
|
const { data: pricesData } = useGlobalItemPrices(globalItemId);
|
||||||
const { data: communityStats } = useGlobalItemCommunityStats(globalItemId);
|
const { data: communityStats } = useGlobalItemCommunityStats(globalItemId);
|
||||||
const [showOtherMarkets, setShowOtherMarkets] = useState(false);
|
const [showOtherMarkets, setShowOtherMarkets] = useState(false);
|
||||||
@@ -279,9 +275,7 @@ function MarketPricesSection({
|
|||||||
if (marketPrices.length === 0 && stats.length === 0) return null;
|
if (marketPrices.length === 0 && stats.length === 0) return null;
|
||||||
|
|
||||||
const userMarketPrice = marketPrices.find((p) => p.market === userMarket);
|
const userMarketPrice = marketPrices.find((p) => p.market === userMarket);
|
||||||
const otherMarketPrices = marketPrices.filter(
|
const otherMarketPrices = marketPrices.filter((p) => p.market !== userMarket);
|
||||||
(p) => p.market !== userMarket,
|
|
||||||
);
|
|
||||||
const userMarketStats = stats.filter((s) => s.market === userMarket);
|
const userMarketStats = stats.filter((s) => s.market === userMarket);
|
||||||
const otherMarketStats = stats.filter((s) => s.market !== userMarket);
|
const otherMarketStats = stats.filter((s) => s.market !== userMarket);
|
||||||
|
|
||||||
@@ -293,7 +287,10 @@ function MarketPricesSection({
|
|||||||
{userMarketPrice && (
|
{userMarketPrice && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<span className="text-lg font-semibold text-gray-900">
|
<span className="text-lg font-semibold text-gray-900">
|
||||||
{formatPrice(userMarketPrice.priceCents, userMarketPrice.currency as Currency)}
|
{formatPrice(
|
||||||
|
userMarketPrice.priceCents,
|
||||||
|
userMarketPrice.currency as Currency,
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500 ml-2">
|
<span className="text-xs text-gray-500 ml-2">
|
||||||
MSRP ({userMarketPrice.market})
|
MSRP ({userMarketPrice.market})
|
||||||
@@ -303,7 +300,10 @@ function MarketPricesSection({
|
|||||||
|
|
||||||
{/* Community stats for user's market */}
|
{/* Community stats for user's market */}
|
||||||
{userMarketStats.map((stat) => (
|
{userMarketStats.map((stat) => (
|
||||||
<p key={`${stat.market}-${stat.currency}`} className="text-sm text-gray-700 mb-1">
|
<p
|
||||||
|
key={`${stat.market}-${stat.currency}`}
|
||||||
|
className="text-sm text-gray-700 mb-1"
|
||||||
|
>
|
||||||
Community ({stat.market}):{" "}
|
Community ({stat.market}):{" "}
|
||||||
{formatPrice(stat.medianPrice, stat.currency as Currency)} median{" "}
|
{formatPrice(stat.medianPrice, stat.currency as Currency)} median{" "}
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ function ApiKeySection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium text-gray-900">{t("apiKeys.title")}</h3>
|
<h3 className="text-sm font-medium text-gray-900">
|
||||||
<p className="text-xs text-gray-500">
|
{t("apiKeys.title")}
|
||||||
{t("apiKeys.description")}
|
</h3>
|
||||||
</p>
|
<p className="text-xs text-gray-500">{t("apiKeys.description")}</p>
|
||||||
|
|
||||||
{newKey && (
|
{newKey && (
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||||
@@ -151,10 +151,10 @@ function ImportExportSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium text-gray-900">{t("importExport.title")}</h3>
|
<h3 className="text-sm font-medium text-gray-900">
|
||||||
<p className="text-xs text-gray-500">
|
{t("importExport.title")}
|
||||||
{t("importExport.description")}
|
</h3>
|
||||||
</p>
|
<p className="text-xs text-gray-500">{t("importExport.description")}</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -166,7 +166,9 @@ function ImportExportSection() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<label className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 hover:bg-gray-50 rounded-lg transition-colors cursor-pointer">
|
<label className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 hover:bg-gray-50 rounded-lg transition-colors cursor-pointer">
|
||||||
{importItems.isPending ? t("importExport.importing") : t("importExport.import")}
|
{importItems.isPending
|
||||||
|
? t("importExport.importing")
|
||||||
|
: t("importExport.import")}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -192,7 +194,11 @@ function ImportExportSection() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{importResult.createdCategories.length > 0 && (
|
{importResult.createdCategories.length > 0 && (
|
||||||
<p>{t("importExport.newCategories", { categories: importResult.createdCategories.join(", ") })}</p>
|
<p>
|
||||||
|
{t("importExport.newCategories", {
|
||||||
|
categories: importResult.createdCategories.join(", "),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{importResult.errors.map((err, i) => (
|
{importResult.errors.map((err, i) => (
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: static error list
|
// biome-ignore lint/suspicious/noArrayIndexKey: static error list
|
||||||
@@ -289,7 +295,9 @@ function SettingsPage() {
|
|||||||
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
|
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900">{t("language.title")}</h3>
|
<h3 className="text-sm font-medium text-gray-900">
|
||||||
|
{t("language.title")}
|
||||||
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
{t("language.description")}
|
{t("language.description")}
|
||||||
</p>
|
</p>
|
||||||
@@ -319,7 +327,9 @@ function SettingsPage() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900">{t("weightUnit.title")}</h3>
|
<h3 className="text-sm font-medium text-gray-900">
|
||||||
|
{t("weightUnit.title")}
|
||||||
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
{t("weightUnit.description")}
|
{t("weightUnit.description")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ const app = new Hono();
|
|||||||
app.get("/:globalItemId", async (c) => {
|
app.get("/:globalItemId", async (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const globalItemId = Number(c.req.param("globalItemId"));
|
const globalItemId = Number(c.req.param("globalItemId"));
|
||||||
if (Number.isNaN(globalItemId))
|
if (Number.isNaN(globalItemId)) return c.json({ error: "Invalid ID" }, 400);
|
||||||
return c.json({ error: "Invalid ID" }, 400);
|
|
||||||
|
|
||||||
const market = c.req.query("market");
|
const market = c.req.query("market");
|
||||||
const stats = await getCommunityPriceStats(db, globalItemId, market);
|
const stats = await getCommunityPriceStats(db, globalItemId, market);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ app.get("/", async (c) => {
|
|||||||
try {
|
try {
|
||||||
const rates = await getExchangeRates();
|
const rates = await getExchangeRates();
|
||||||
return c.json(rates);
|
return c.json(rates);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
return c.json({ error: "Exchange rates unavailable" }, 503);
|
return c.json({ error: "Exchange rates unavailable" }, 503);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ app.post(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const globalItemId = Number(c.req.param("id"));
|
const globalItemId = Number(c.req.param("id"));
|
||||||
if (Number.isNaN(globalItemId))
|
if (Number.isNaN(globalItemId)) return c.json({ error: "Invalid ID" }, 400);
|
||||||
return c.json({ error: "Invalid ID" }, 400);
|
|
||||||
|
|
||||||
const data = c.req.valid("json");
|
const data = c.req.valid("json");
|
||||||
const result = await upsertMarketPrice(db, {
|
const result = await upsertMarketPrice(db, {
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ export async function validateOwnership(
|
|||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({ count: sql<number>`COUNT(*)` })
|
.select({ count: sql<number>`COUNT(*)` })
|
||||||
.from(items)
|
.from(items)
|
||||||
.where(
|
.where(and(eq(items.userId, userId), eq(items.globalItemId, globalItemId)));
|
||||||
and(eq(items.userId, userId), eq(items.globalItemId, globalItemId)),
|
|
||||||
);
|
|
||||||
return (row?.count ?? 0) > 0;
|
return (row?.count ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
import { readdirSync, readFileSync } from "node:fs";
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
|
|
||||||
const LOCALES_DIR = join(import.meta.dir, "../../src/client/locales");
|
const LOCALES_DIR = join(import.meta.dir, "../../src/client/locales");
|
||||||
|
|
||||||
function flattenKeys(
|
function flattenKeys(obj: Record<string, unknown>, prefix = ""): string[] {
|
||||||
obj: Record<string, unknown>,
|
|
||||||
prefix = "",
|
|
||||||
): string[] {
|
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||||
keys.push(
|
keys.push(...flattenKeys(value as Record<string, unknown>, fullKey));
|
||||||
...flattenKeys(value as Record<string, unknown>, fullKey),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
keys.push(fullKey);
|
keys.push(fullKey);
|
||||||
}
|
}
|
||||||
@@ -22,9 +17,7 @@ function flattenKeys(
|
|||||||
return keys.sort();
|
return keys.sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadLocale(
|
function loadLocale(locale: string): Record<string, Record<string, unknown>> {
|
||||||
locale: string,
|
|
||||||
): Record<string, Record<string, unknown>> {
|
|
||||||
const dir = join(LOCALES_DIR, locale);
|
const dir = join(LOCALES_DIR, locale);
|
||||||
const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
||||||
const result: Record<string, Record<string, unknown>> = {};
|
const result: Record<string, Record<string, unknown>> = {};
|
||||||
@@ -67,9 +60,7 @@ describe("locale key parity", () => {
|
|||||||
(obj, k) => (obj as Record<string, unknown>)?.[k],
|
(obj, k) => (obj as Record<string, unknown>)?.[k],
|
||||||
de[ns] as unknown,
|
de[ns] as unknown,
|
||||||
);
|
);
|
||||||
expect(
|
expect(typeof value === "string" && value.length > 0).toBe(true);
|
||||||
typeof value === "string" && value.length > 0,
|
|
||||||
).toBe(true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import {
|
import {
|
||||||
CURRENCY_MARKET_MAP,
|
CURRENCY_MARKET_MAP,
|
||||||
type ExchangeRates,
|
|
||||||
convertPrice,
|
convertPrice,
|
||||||
|
type ExchangeRates,
|
||||||
getMarketForCurrency,
|
getMarketForCurrency,
|
||||||
} from "../../src/server/services/currency.service";
|
} from "../../src/server/services/currency.service";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user