From 9dd42d5dc6e5b2d05b2ab08f7bc902d1e5acde6e Mon Sep 17 00:00:00 2001 From: jayavardhan attaluri Date: Thu, 2 Jul 2026 22:09:57 +0530 Subject: [PATCH] feat(settings): implement public settings route and UI updates for issue #15 --- app/blocks/__global/app-sidebar.tsx | 9 +++-- app/blocks/__global/navigation-bar.tsx | 2 +- app/blocks/ai-insights/plan-gate-message.tsx | 2 +- .../dashboard/expense-categories-chart.tsx | 2 +- app/blocks/price-alerts/alert-history.tsx | 31 ++++++++++------ .../price-alerts/plan-limit-warning.tsx | 2 +- .../settings/account-settings.module.css | 7 ++-- app/blocks/settings/account-settings.tsx | 19 ++++++++-- app/blocks/settings/billing-section.tsx | 2 +- app/blocks/settings/preferences.tsx | 13 +++---- app/blocks/settings/team-section.tsx | 2 +- app/routes.ts | 10 +++--- app/routes/+types/settings.ts | 6 ++++ app/routes/api.stripe.ts | 2 +- app/routes/app-layout.tsx | 2 +- app/routes/price-alerts.tsx | 17 ++++++++- app/routes/settings-public.tsx | 1 + app/routes/settings.tsx | 35 ++++++++++++++++--- app/utils/supabase.server.ts | 9 +++-- package-lock.json | 13 ------- 20 files changed, 126 insertions(+), 60 deletions(-) create mode 100644 app/routes/+types/settings.ts create mode 100644 app/routes/settings-public.tsx diff --git a/app/blocks/__global/app-sidebar.tsx b/app/blocks/__global/app-sidebar.tsx index b707289..87ecea7 100644 --- a/app/blocks/__global/app-sidebar.tsx +++ b/app/blocks/__global/app-sidebar.tsx @@ -16,6 +16,7 @@ interface Props { name?: string | null; email: string; plan?: string | null; + avatarUrl?: string | null; }; } @@ -33,7 +34,7 @@ export function AppSidebar({ user }: Props) { { to: "/app/sales", label: "Sales Log", icon: }, { to: "/app/expenses", label: "Expenses", icon: }, { to: "/app/ai-insights", label: "AI Insights", icon: }, - { to: "/app/settings", label: "Settings", icon: }, + { to: "/settings", label: "Settings", icon: }, ]; return ( @@ -62,7 +63,11 @@ export function AppSidebar({ user }: Props) {
- {user.name ? user.name.charAt(0).toUpperCase() : user.email.charAt(0).toUpperCase()} + {user.avatarUrl ? ( + {user.name + ) : ( + user.name ? user.name.charAt(0).toUpperCase() : user.email.charAt(0).toUpperCase() + )}
{user.name || "User"} diff --git a/app/blocks/__global/navigation-bar.tsx b/app/blocks/__global/navigation-bar.tsx index 6da2718..d477593 100644 --- a/app/blocks/__global/navigation-bar.tsx +++ b/app/blocks/__global/navigation-bar.tsx @@ -68,7 +68,7 @@ export function NavigationBar({ className, appMode }: Props) {
- +
diff --git a/app/blocks/ai-insights/plan-gate-message.tsx b/app/blocks/ai-insights/plan-gate-message.tsx index 7955619..8b569c3 100644 --- a/app/blocks/ai-insights/plan-gate-message.tsx +++ b/app/blocks/ai-insights/plan-gate-message.tsx @@ -12,7 +12,7 @@ export function PlanGateMessage({ className }: Props) { Upgrade to Pro to get AI-powered price analysis and buy/sell recommendations for every item in your inventory. Powered by GPT-4o mini with confidence scores and target prices.

- Upgrade to Pro — $12/mo + Upgrade to Pro — $12/mo
); } diff --git a/app/blocks/dashboard/expense-categories-chart.tsx b/app/blocks/dashboard/expense-categories-chart.tsx index 55df35f..fb794a3 100644 --- a/app/blocks/dashboard/expense-categories-chart.tsx +++ b/app/blocks/dashboard/expense-categories-chart.tsx @@ -65,7 +65,7 @@ export function ExpenseCategoriesChart({ expenses }: Props) { ))} [`$${value.toFixed(2)}`, "Amount"]} + formatter={(value: unknown) => [`$${Number(value ?? 0).toFixed(2)}`, "Amount"]} contentStyle={{ backgroundColor: "var(--color-surface)", borderColor: "var(--color-surface-border)", diff --git a/app/blocks/price-alerts/alert-history.tsx b/app/blocks/price-alerts/alert-history.tsx index 77845bb..f38cc76 100644 --- a/app/blocks/price-alerts/alert-history.tsx +++ b/app/blocks/price-alerts/alert-history.tsx @@ -1,23 +1,34 @@ import styles from "./alert-history.module.css"; -interface Props { className?: string; } +interface AlertItem { + id: string; + sku: string; + size: string; + productName: string; + marketplace: string; + targetPrice: number; + direction: string; + notificationChannel: string; + isActive: boolean; + triggeredAt: Date | null; + createdAt: Date; + userId: string; +} -const history = [ - { desc: "Yeezy 350 Zebra (GOAT) dropped below $200 — alert triggered at $195", time: "May 10, 2024 2:14 PM" }, -]; +interface Props { className?: string; alerts?: AlertItem[]; } -export function AlertHistory({ className }: Props) { +export function AlertHistory({ className, alerts = [] }: Props) { return (
Alert History
- {history.length === 0 ? ( + {alerts.length === 0 ? (

No alerts have been triggered yet.

) : ( - history.map((h, i) => ( -
+ alerts.map((alert) => ( +
-
{h.desc}
-
{h.time}
+
{`${alert.productName} (${alert.marketplace}) triggered at $${alert.targetPrice.toFixed(2)}`}
+
{alert.triggeredAt?.toLocaleString() || "Recently triggered"}
)) )} diff --git a/app/blocks/price-alerts/plan-limit-warning.tsx b/app/blocks/price-alerts/plan-limit-warning.tsx index 920f48e..53fff25 100644 --- a/app/blocks/price-alerts/plan-limit-warning.tsx +++ b/app/blocks/price-alerts/plan-limit-warning.tsx @@ -8,7 +8,7 @@ export function PlanLimitWarning({ className, show = false }: Props) { return (
⚠️ You've reached your 5-alert limit on the Free plan. Upgrade to Pro for 25 alerts. - Upgrade to Pro + Upgrade to Pro
); } diff --git a/app/blocks/settings/account-settings.module.css b/app/blocks/settings/account-settings.module.css index 99e6427..afe3fa0 100644 --- a/app/blocks/settings/account-settings.module.css +++ b/app/blocks/settings/account-settings.module.css @@ -1,8 +1,11 @@ .section { } .title { font-size: 1.2rem; font-weight: 700; color: var(--color-text); margin-bottom: var(--space-6); } .avatarRow { display: flex; align-items: center; gap: var(--space-5); margin-bottom: var(--space-6); } -.avatar { width: 72px; height: 72px; border-radius: 50%; background: linear-gradient(135deg, var(--color-primary), var(--color-accent)); display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 700; color: var(--color-text-inverse); flex-shrink: 0; } -.uploadBtn { padding: 8px 16px; border-radius: var(--radius-sm); border: 1px solid var(--color-border-strong); background: none; color: var(--color-text-muted); font-size: 13px; cursor: pointer; } +.avatar { width: 72px; height: 72px; border-radius: 50%; background: linear-gradient(135deg, var(--color-primary), var(--color-accent)); display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 700; color: var(--color-text-inverse); flex-shrink: 0; overflow: hidden; } +.avatarImage { width: 100%; height: 100%; object-fit: cover; } +.uploadBtn { display: inline-flex; align-items: center; padding: 8px 16px; border-radius: var(--radius-sm); border: 1px solid var(--color-border-strong); background: none; color: var(--color-text-muted); font-size: 13px; cursor: pointer; } +.uploadInput { display: none; } +.helperText { margin-top: var(--space-2); font-size: 12px; color: var(--color-text-muted); } .field { margin-bottom: var(--space-4); } .label { display: block; font-size: 13px; font-weight: 600; color: var(--color-text-muted); margin-bottom: var(--space-2); } .input { width: 100%; padding: 10px 12px; border-radius: var(--radius-sm); background: var(--color-bg-card); border: 1px solid var(--color-border); color: var(--color-text); font-size: 14px; outline: none; } diff --git a/app/blocks/settings/account-settings.tsx b/app/blocks/settings/account-settings.tsx index 83adfb7..71bc2d7 100644 --- a/app/blocks/settings/account-settings.tsx +++ b/app/blocks/settings/account-settings.tsx @@ -10,11 +10,24 @@ export function AccountSettings({ className, user }: Props) {

Account Settings

-
{user.name ? user.name.substring(0, 2).toUpperCase() : user.email.substring(0, 2).toUpperCase()}
+
+ {user.avatarUrl ? ( + {user.name + ) : ( + user.name ? user.name.substring(0, 2).toUpperCase() : user.email.substring(0, 2).toUpperCase() + )} +
+
+ +
PNG, JPG, or WEBP up to 5MB.
+
-
+ -
+
diff --git a/app/blocks/settings/billing-section.tsx b/app/blocks/settings/billing-section.tsx index c63ab2b..1800002 100644 --- a/app/blocks/settings/billing-section.tsx +++ b/app/blocks/settings/billing-section.tsx @@ -20,7 +20,7 @@ export function BillingSection({ className, user }: Props) {
{user.plan === 'FREE' && ( - Upgrade to Pro + Upgrade to Pro )}
diff --git a/app/blocks/settings/preferences.tsx b/app/blocks/settings/preferences.tsx index 21898b9..f979150 100644 --- a/app/blocks/settings/preferences.tsx +++ b/app/blocks/settings/preferences.tsx @@ -12,19 +12,16 @@ export function Preferences({ className, user }: Props) {
- + + +
diff --git a/app/blocks/settings/team-section.tsx b/app/blocks/settings/team-section.tsx index 8c1bfa3..86ab793 100644 --- a/app/blocks/settings/team-section.tsx +++ b/app/blocks/settings/team-section.tsx @@ -13,7 +13,7 @@ export function TeamSection({ className, user }: Props) {
Team collaboration is a Business plan feature. Upgrade to invite up to 5 team members and share your inventory.
- Upgrade to Business + Upgrade to Business
); diff --git a/app/routes.ts b/app/routes.ts index c552fea..9a43375 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -12,9 +12,11 @@ export default [ route("/changelog", "routes/changelog-page.tsx"), route("/privacy", "routes/privacy-policy.tsx"), route("/terms", "routes/terms-of-service.tsx"), - - ...prefix("/app", [ - layout("routes/app-layout.tsx", [ + + layout("routes/app-layout.tsx", [ + route("/settings", "routes/settings-public.tsx"), + route("/settings/billing", "routes/billing-management.tsx"), + ...prefix("/app", [ route("dashboard", "routes/dashboard.tsx"), route("inventory", "routes/inventory-management.tsx"), route("inventory/:id", "routes/inventory-item-detail.tsx"), @@ -24,8 +26,6 @@ export default [ route("income-statement", "routes/income-statement.tsx"), route("alerts", "routes/price-alerts.tsx"), route("ai-insights", "routes/ai-insights.tsx"), - route("settings", "routes/settings.tsx"), - route("settings/billing", "routes/billing-management.tsx"), route("tax-report", "routes/tax-report-export.tsx"), ]), ]), diff --git a/app/routes/+types/settings.ts b/app/routes/+types/settings.ts new file mode 100644 index 0000000..3b0570d --- /dev/null +++ b/app/routes/+types/settings.ts @@ -0,0 +1,6 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; + +export namespace Route { + export type LoaderArgs = LoaderFunctionArgs; + export type ActionArgs = ActionFunctionArgs; +} diff --git a/app/routes/api.stripe.ts b/app/routes/api.stripe.ts index 3d09a5d..4844016 100644 --- a/app/routes/api.stripe.ts +++ b/app/routes/api.stripe.ts @@ -4,7 +4,7 @@ import Stripe from "stripe"; const prisma = new PrismaClient(); const stripeKey = process.env.STRIPE_SECRET_KEY || "sk_test_placeholder"; -const stripe = new Stripe(stripeKey, { apiVersion: "2024-06-20" }); +const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as any }); export async function action({ request }: Route.ActionArgs) { const payload = await request.text(); diff --git a/app/routes/app-layout.tsx b/app/routes/app-layout.tsx index bc217ea..cc2fbf7 100644 --- a/app/routes/app-layout.tsx +++ b/app/routes/app-layout.tsx @@ -19,7 +19,7 @@ export async function loader({ request }: Route.LoaderArgs) { // Fetch full user details from public.User const dbUser = await prisma.user.findUnique({ where: { id: user.id }, - select: { name: true, email: true, plan: true } + select: { name: true, email: true, plan: true, avatarUrl: true } }); return { user: dbUser || { email: user.email!, plan: "FREE" } }; diff --git a/app/routes/price-alerts.tsx b/app/routes/price-alerts.tsx index 07612c9..4e1b351 100644 --- a/app/routes/price-alerts.tsx +++ b/app/routes/price-alerts.tsx @@ -10,6 +10,21 @@ import { CreateAlertForm } from "~/blocks/price-alerts/create-alert-form"; import { ActiveAlertsTable } from "~/blocks/price-alerts/active-alerts-table"; import { AlertHistory } from "~/blocks/price-alerts/alert-history"; +interface AlertHistoryItem { + id: string; + sku: string; + size: string; + productName: string; + marketplace: Marketplace; + targetPrice: number; + direction: AlertDirection; + notificationChannel: NotificationChannel; + isActive: boolean; + triggeredAt: Date | null; + createdAt: Date; + userId: string; +} + const prisma = new PrismaClient(); export async function loader({ request }: Route.LoaderArgs) { @@ -74,7 +89,7 @@ export default function PriceAlertsPage() { {showCreate && setShowCreate(false)} />} - a.triggeredAt)} /> + Boolean(a.triggeredAt))} />
); } diff --git a/app/routes/settings-public.tsx b/app/routes/settings-public.tsx new file mode 100644 index 0000000..6e69912 --- /dev/null +++ b/app/routes/settings-public.tsx @@ -0,0 +1 @@ +export { default } from "./settings"; diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index 8b91182..734cd1c 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useLoaderData } from "react-router"; import type { Route } from "./+types/settings"; import { getSupabaseServerClient } from "~/utils/supabase.server"; -import { PrismaClient } from "@prisma/client"; +import { PrismaClient, Currency, Theme } from "@prisma/client"; import styles from "./settings.module.css"; import { SettingsNavigation } from "~/blocks/settings/settings-navigation"; import { AccountSettings } from "~/blocks/settings/account-settings"; @@ -39,16 +39,41 @@ export async function action({ request }: Route.ActionArgs) { if (intent === "update-profile") { const name = formData.get("name") as string; const phone = formData.get("phone") as string; + const avatarFile = formData.get("avatar"); + + let avatarUrl: string | undefined; + if (avatarFile && avatarFile instanceof File && avatarFile.size > 0) { + const extension = avatarFile.name.split(".").pop()?.toLowerCase() || "bin"; + const filePath = `${authUser.id}/${Date.now()}.${extension}`; + const { data, error } = await supabase.storage.from("avatars").upload(filePath, avatarFile, { + upsert: true, + contentType: avatarFile.type || "application/octet-stream" + }); + + if (error) { + return { ok: false, message: error.message }; + } + + avatarUrl = supabase.storage.from("avatars").getPublicUrl(data?.path ?? filePath).data.publicUrl; + } + await prisma.user.update({ where: { id: authUser.id }, - data: { name, phone } + data: { + name, + phone, + ...(avatarUrl ? { avatarUrl } : {}) + } }); } else if (intent === "update-preferences") { - const currency = formData.get("currency") as string; - const theme = formData.get("theme") as string; + const currency = (formData.get("currency") as string) || "USD"; + const normalizedCurrency = ["USD", "EUR", "GBP"].includes(currency) ? (currency as Currency) : Currency.USD; + const themeValue = (formData.get("theme") as string) || "LIGHT"; + const normalizedTheme = ["LIGHT", "DARK", "UNICORN"].includes(themeValue) ? (themeValue as Theme) : Theme.LIGHT; + await prisma.user.update({ where: { id: authUser.id }, - data: { currency, theme } + data: { currency: normalizedCurrency, theme: normalizedTheme } }); } else if (intent === "create-team") { const teamName = formData.get("teamName") as string; diff --git a/app/utils/supabase.server.ts b/app/utils/supabase.server.ts index ba9a8cf..ba3ca64 100644 --- a/app/utils/supabase.server.ts +++ b/app/utils/supabase.server.ts @@ -9,11 +9,14 @@ export function getSupabaseServerClient(request: Request) { const supabase = createServerClient(supabaseUrl, supabaseKey, { cookies: { getAll() { - return parseCookieHeader(request.headers.get("Cookie") ?? ""); + return parseCookieHeader(request.headers.get("Cookie") ?? "").map((cookie) => ({ + name: cookie.name, + value: cookie.value ?? "" + })); }, - setAll(cookiesToSet) { + setAll(cookiesToSet: Array<{ name: string; value: string; options?: Record }>) { cookiesToSet.forEach(({ name, value, options }) => - headers.append("Set-Cookie", serializeCookieHeader(name, value, options)) + headers.append("Set-Cookie", serializeCookieHeader(name, value, options ?? {})) ); }, }, diff --git a/package-lock.json b/package-lock.json index 3170294..8bb8c69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3894,7 +3894,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4102,18 +4101,6 @@ "node": ">=18" } }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",