Skip to content

Commit 00a372e

Browse files
committed
Merge fix/mobile-add-ux: robust link extraction + easier add-product nav
2 parents 4e74d7d + 743bf79 commit 00a372e

6 files changed

Lines changed: 113 additions & 42 deletions

File tree

web/app/(public)/mobile/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const androidSteps = [
1919
const iosSteps = [
2020
{ t: "Add to Home Screen", b: "Open shoplit.in in Safari → tap Share → “Add to Home Screen”. (iPhone doesn’t let apps share into shoplit — Apple limitation — so adding is by paste.)" },
2121
{ t: "Copy the product link", b: "On the shopping app or site, tap Share → Copy (or copy the page address)." },
22-
{ t: "Paste into shoplit", b: "Open shoplit, go to Add a product, and paste. shoplit pulls the title from the link automatically." },
22+
{ t: "Paste into shoplit", b: "Open shoplit, tap the + Add button, and paste the link. shoplit fills in what it can from the link — add or tweak the title." },
2323
{ t: "Pick a cart & add", b: "Adjust the price/image if needed, choose the cart, and add." },
2424
];
2525

web/app/dashboard/page.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,22 @@ export default async function DashboardPage() {
9494
</div>
9595

9696
{/* HEADER ROW */}
97-
<div className="flex items-center justify-between mb-6">
97+
<div className="flex items-center justify-between gap-3 mb-6">
9898
<h2 className="font-serif text-2xl">All carts</h2>
99-
<Link
100-
href="/dashboard/carts/new"
101-
className="inline-flex items-center gap-2 rounded-full bg-ink text-cream px-5 py-2.5 font-medium hover:opacity-90 transition-opacity"
102-
>
103-
<Plus size={16} /> New cart
104-
</Link>
99+
<div className="flex items-center gap-2 shrink-0">
100+
<Link
101+
href="/add"
102+
className="inline-flex items-center gap-2 rounded-full bg-ink text-cream px-5 py-2.5 font-medium hover:opacity-90 transition-opacity"
103+
>
104+
<Plus size={16} /> Add a product
105+
</Link>
106+
<Link
107+
href="/dashboard/carts/new"
108+
className="inline-flex items-center gap-2 rounded-full border border-ink px-4 py-2.5 font-medium hover:bg-paper transition-colors"
109+
>
110+
New cart
111+
</Link>
112+
</div>
105113
</div>
106114

107115
{/* GRID */}

web/components/mobile-bottom-nav.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { toast } from "sonner";
77
import { logout } from "@/lib/api-client";
88

99
// Sticky bottom nav for /dashboard* on mobile only. Hidden at sm+ (the top
10-
// NavBar covers desktop). 3 actions: home / new cart (prominent) / sign out.
10+
// NavBar covers desktop). 3 actions: home / add a product (prominent) / sign out.
1111
export function MobileBottomNav() {
1212
const pathname = usePathname();
1313
const router = useRouter();
@@ -32,7 +32,7 @@ export function MobileBottomNav() {
3232
>
3333
<div className="flex items-stretch justify-around h-16 px-2">
3434
<NavItem href="/dashboard" label="Carts" icon={<Home size={20} />} active={isHome} />
35-
<CenterAction href="/dashboard/carts/new" />
35+
<CenterAction href="/add" />
3636
<NavButton label="Sign out" icon={<LogOut size={20} />} onClick={handleLogout} />
3737
</div>
3838
</nav>
@@ -88,7 +88,7 @@ function CenterAction({ href }: { href: string }) {
8888
return (
8989
<Link
9090
href={href}
91-
aria-label="New cart"
91+
aria-label="Add a product"
9292
className="-translate-y-3 flex items-center justify-center w-14 h-14 rounded-full bg-ink text-cream shadow-lg hover:opacity-90 transition-opacity"
9393
>
9494
<Plus size={22} />

web/components/paste-url-preview.tsx

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState, useTransition } from "react";
44
import { Loader2, Link2, Plus } from "lucide-react";
55
import { fetchOG } from "@/lib/api-client";
6+
import { parseShare } from "@/lib/parse-share";
67
import type { Product, Retailer } from "@/lib/types";
78
import { RetailerIcon, retailerLabel } from "./retailer-icon";
89

@@ -15,6 +16,9 @@ interface PasteUrlPreviewProps {
1516
// (some retailers do), the fields are blank and the creator fills them in
1617
// manually — so adding a product never dead-ends.
1718
export function PasteUrlPreview({ onResolved }: PasteUrlPreviewProps) {
19+
// What the creator pasted (kept intact so large "share text" stays visible).
20+
const [rawInput, setRawInput] = useState("");
21+
// The clean product URL extracted from it (editable in the resolved form).
1822
const [url, setUrl] = useState("");
1923
const [pending, startTransition] = useTransition();
2024
const [showForm, setShowForm] = useState(false);
@@ -31,6 +35,7 @@ export function PasteUrlPreview({ onResolved }: PasteUrlPreviewProps) {
3135
const [canonicalUrl, setCanonicalUrl] = useState("");
3236

3337
const reset = () => {
38+
setRawInput("");
3439
setUrl("");
3540
setShowForm(false);
3641
setAutofilled(false);
@@ -43,41 +48,37 @@ export function PasteUrlPreview({ onResolved }: PasteUrlPreviewProps) {
4348
};
4449

4550
const handlePaste = (raw: string) => {
46-
setUrl(raw);
47-
// Accept full retailer "share text", not just a bare URL. Apps share
48-
// things like "Check out this product I found on Nykaa: <name> <url>" —
49-
// pull the URL out of anywhere in the text.
50-
const urlMatch = raw.match(/https?:\/\/[^\s]+/);
51-
if (!urlMatch) {
51+
setRawInput(raw);
52+
// Accept full retailer "share text", not just a bare URL — parseShare pulls
53+
// out a clean http(s) link (upgrading scheme-less ones, trimming trailing
54+
// punctuation) plus a best-effort title and price.
55+
const parsed = parseShare({ text: raw, url: raw });
56+
if (!parsed.productUrl) {
57+
setUrl("");
5258
setShowForm(false);
5359
return;
5460
}
55-
const targetUrl = urlMatch[0];
56-
// The text around the URL is usually the product name. Strip a
57-
// "Check out … on <retailer>:" style lead-in if present.
58-
let shareTitle = raw.replace(targetUrl, "").trim();
59-
const seg = shareTitle.match(/^(.*?:)\s*([\s\S]+)$/);
60-
if (seg && /check out|found on|shared|recommend|buy this|loving/i.test(seg[1])) {
61-
shareTitle = seg[2].trim();
62-
}
63-
setUrl(targetUrl); // normalize the field to just the URL
61+
setUrl(parsed.productUrl);
6462

6563
startTransition(async () => {
6664
try {
67-
const og = await fetchOG(targetUrl);
65+
const og = await fetchOG(parsed.productUrl);
6866
setRetailer(og.retailer);
6967
setCanonicalUrl(og.canonicalUrl ?? "");
7068
// Prefer the clean title from the share text; fall back to OG. This
7169
// gives a usable title even when the server fetch is blocked.
72-
setTitle(shareTitle || og.title || "");
70+
setTitle(parsed.title || og.title || "");
7371
if (og.ok) {
7472
setImageUrl(og.imageUrl ?? "");
75-
setPriceText(og.priceText ?? "");
73+
setPriceText(og.priceText || parsed.priceText || "");
74+
} else {
75+
setPriceText(parsed.priceText || "");
7676
}
77-
setAutofilled(Boolean(shareTitle || (og.ok && (og.title || og.imageUrl))));
77+
setAutofilled(Boolean(parsed.title || (og.ok && (og.title || og.imageUrl))));
7878
} catch {
79-
setTitle(shareTitle);
80-
setAutofilled(Boolean(shareTitle));
79+
setTitle(parsed.title);
80+
setPriceText(parsed.priceText || "");
81+
setAutofilled(Boolean(parsed.title));
8182
} finally {
8283
setShowForm(true);
8384
}
@@ -103,15 +104,20 @@ export function PasteUrlPreview({ onResolved }: PasteUrlPreviewProps) {
103104
<label className="block">
104105
<span className="block text-sm font-medium mb-2">Paste a product link — or the whole &ldquo;share&rdquo; text from a shopping app</span>
105106
<div className="relative">
106-
<Link2 size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" aria-hidden />
107-
<input
108-
type="url"
109-
value={url}
107+
<Link2 size={16} className="absolute left-3 top-4 text-muted" aria-hidden />
108+
<textarea
109+
rows={2}
110+
value={rawInput}
110111
onChange={(e) => handlePaste(e.target.value)}
111-
placeholder="https://www.myntra.com/…"
112-
className="w-full rounded-lg border border-rule bg-cream py-4 pl-10 pr-4 text-lg focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
112+
placeholder="Paste here — e.g. “Check out this product… https://www.myntra.com/…"
113+
className="w-full resize-y rounded-lg border border-rule bg-cream py-3 pl-10 pr-4 text-base leading-relaxed focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent"
113114
/>
114115
</div>
116+
{url && (
117+
<p className="mt-1 text-xs text-muted truncate">
118+
Link detected: <span className="text-ink">{url}</span>
119+
</p>
120+
)}
115121
</label>
116122

117123
{pending && (
@@ -172,6 +178,17 @@ export function PasteUrlPreview({ onResolved }: PasteUrlPreviewProps) {
172178
</div>
173179
</div>
174180

181+
<input
182+
type="url"
183+
value={url}
184+
onChange={(e) => {
185+
setUrl(e.target.value);
186+
setCanonicalUrl(""); // a manual edit wins over the resolved link
187+
}}
188+
placeholder="Product link"
189+
className="w-full rounded-md border border-rule bg-cream px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
190+
/>
191+
175192
<input
176193
type="text"
177194
value={note}

web/lib/parse-share.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,27 @@ describe("parseShare", () => {
5151
expect(r.productUrl).toBe("");
5252
expect(r.title).toBe("just some text");
5353
});
54+
55+
it("trims trailing punctuation wrapped around the link", () => {
56+
const r = parseShare({ text: "Buy now (https://www.flipkart.com/boat/p/itm123)." });
57+
expect(r.productUrl).toBe("https://www.flipkart.com/boat/p/itm123");
58+
});
59+
60+
it("upgrades a scheme-less url to https", () => {
61+
const r = parseShare({ text: "Check this out www.nykaa.com/lakme/p/12345" });
62+
expect(r.productUrl).toBe("https://www.nykaa.com/lakme/p/12345");
63+
});
64+
65+
it("strips boilerplate even without a colon", () => {
66+
const r = parseShare({
67+
text: "Check out this product I found on Nykaa https://www.nykaa.com/maybelline-mascara/p/9999",
68+
});
69+
expect(r.productUrl).toBe("https://www.nykaa.com/maybelline-mascara/p/9999");
70+
expect(r.title).toBe("maybelline mascara");
71+
});
72+
73+
it("does not mistake a plain sentence for a url", () => {
74+
const r = parseShare({ text: "loving it. so good." });
75+
expect(r.productUrl).toBe("");
76+
});
5477
});

web/lib/parse-share.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,31 @@ export interface ParsedShare {
1212

1313
const URL_RE = /https?:\/\/[^\s"'<>]+/i;
1414
const URL_RE_G = /https?:\/\/[^\s"'<>]+/gi;
15+
// Bare (scheme-less) URL with a path — many apps share "www.nykaa.com/x/123"
16+
// or "nykaa.com/x/123". Requires a dotted TLD followed by a "/path" so we don't
17+
// match plain sentences like "loving it." A scheme is prepended on extraction.
18+
const BARE_URL_RE = /(?:www\.)?[a-z0-9-]+(?:\.[a-z]{2,})+\/[^\s"'<>]+/i;
19+
const BARE_URL_RE_G = /(?:www\.)?[a-z0-9-]+(?:\.[a-z]{2,})+\/[^\s"'<>]+/gi;
20+
// Punctuation a share message commonly appends right after the link, e.g.
21+
// "(https://…/p/123)." — must not become part of the stored URL.
22+
const TRAILING_PUNCT = /[)\].,;:!?'"»>]+$/;
1523
const PRICE_RE = /(?:|rs\.?|inr)\s?[\d,]+(?:\.\d{1,2})?/i;
1624

25+
// Pull the first usable product URL out of the given sources, preferring a real
26+
// http(s) link, then a scheme-less one (which we upgrade to https). Trailing
27+
// share-message punctuation is trimmed off either way.
28+
function extractUrl(...sources: string[]): string {
29+
for (const s of sources) {
30+
const m = s.match(URL_RE)?.[0];
31+
if (m) return m.replace(TRAILING_PUNCT, "");
32+
}
33+
for (const s of sources) {
34+
const m = s.match(BARE_URL_RE)?.[0];
35+
if (m) return "https://" + m.replace(TRAILING_PUNCT, "");
36+
}
37+
return "";
38+
}
39+
1740
// Boilerplate that retailer apps prepend to shared text.
1841
const BOILERPLATE: RegExp[] = [
1942
/check out this product i found on[^:]*:?/i,
@@ -49,15 +72,15 @@ export function parseShare(input: SharedInput): ParsedShare {
4972
const text = (input.text ?? "").trim();
5073
const url = (input.url ?? "").trim();
5174

52-
// 1. Product URL: explicit url param first, else first URL in text.
53-
let productUrl = url.match(URL_RE)?.[0] ?? "";
54-
if (!productUrl) productUrl = text.match(URL_RE)?.[0] ?? "";
75+
// 1. Product URL: explicit url param first, else from the text — preferring a
76+
// real http(s) link, then a scheme-less one, with trailing punctuation trimmed.
77+
const productUrl = extractUrl(url, text);
5578

5679
// 2. Title: title param (unless it's a URL) → text minus URLs/boilerplate → slug.
5780
let title = (input.title ?? "").trim();
5881
if (URL_RE.test(title) && title.match(URL_RE)?.[0] === title) title = "";
5982
if (!title) {
60-
let t = text.replace(URL_RE_G, " ");
83+
let t = text.replace(URL_RE_G, " ").replace(BARE_URL_RE_G, " ");
6184
t = t.replace(PRICE_RE, " ");
6285
for (const re of BOILERPLATE) t = t.replace(re, " ");
6386
title = t.replace(/\s+/g, " ").trim();

0 commit comments

Comments
 (0)