Convenience Store Pickup
Three-step convenience-store pickup picker: chain → city/district → store list with search, hours and a selected-store summary card; brand-neutral, data supplied by props
This is a WebberUI Pro component
Free during the launch campaign: sign up or sign in, then hit “Copy install command” in the preview above and it installs straight away — no payment, no credit card. The command below returns 401 while you are signed out.
The "pick up at a convenience store" flow that shows up in almost every Taiwanese e-commerce checkout, packaged as a self-contained stepper panel: step one picks the chain with color-swatch chips, step two derives city and district dropdowns from the store data, and step three lets you choose from a searchable list — each row shows the address, opening hours, a 24-hour badge and (optionally) a distance label. Once a store is chosen the panel collapses into a summary card (chain swatch + store name + address + a "Change" button) and calls onSelect(store). Steps slide horizontally, list items stagger in and the selected item gets a check mark; it supports controlled value and uncontrolled defaultValue, an isLoading skeleton and fully overridable copy. The component ships no real brand logos or names — chains and stores always come in through props.
npx shadcn@latest add "https://webberui.com/r/convenience-store-pickup.json?t=<install token>"Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<ConvenienceStorePickup />
Installation
npx shadcn@latest add "https://webberui.com/r/convenience-store-pickup.json?t=<install token>"Or, once registries are configured in components.json, install it as @webberui/convenience-store-pickup.
Usage
import {
ConvenienceStorePickup,
type PickupChain,
type PickupStore,
} from "@/components/ui/convenience-store-pickup";
// Chains and stores come from your API; the values below are fictional
const chains: PickupChain[] = [
{ id: "sudah", name: "速達超商", color: "#e8541e" },
{ id: "neighbor", name: "好鄰居便利店", color: "#2e7d32" },
];
const stores: PickupStore[] = [
{
id: "sd-001",
chainId: "sudah",
name: "大安樂活門市",
address: "臺北市大安區樂活路 12 號",
city: "臺北市",
district: "大安區",
open24h: true,
distanceText: "350 公尺",
},
{
id: "nb-001",
chainId: "neighbor",
name: "大安木棉店",
address: "臺北市大安區木棉巷 6 號",
city: "臺北市",
district: "大安區",
hours: "07:00–22:30",
},
];
// Controlled: keep the store id in your checkout form state
<ConvenienceStorePickup
chains={chains}
stores={stores}
value={form.pickupStoreId}
onChange={(id) => setForm({ ...form, pickupStoreId: id })}
onSelect={(store) => console.log("pickup store", store.name, store.address)}
/>
// Uncontrolled: you only care about the final pick
<ConvenienceStorePickup
chains={chains}
stores={stores}
defaultValue="sd-001"
onSelect={(store) => console.log(store.id)}
compact
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
chains | PickupChain[] | — | Chain list; sets the content and order of the step-one chips |
stores | PickupStore[] | — | Store list; the city/district options and the store list are all derived from it |
value | string | null | — | Controlled value: the currently selected store id (null means nothing selected); when omitted the component is uncontrolled |
defaultValue | string | null | null | Initial store id in uncontrolled mode |
onSelect | (store: PickupStore) => void | — | Called when the user picks a store, with the full store object |
onChange | (storeId: string) => void | — | Called when the selected store id changes; update value here in controlled mode |
title | string | — | Panel title; a shortcut for labels.title — when both are given, title wins |
labels | Partial<ConvenienceStorePickupLabels> | built-in Traditional Chinese | Override the UI copy (partial overrides are fine; the rest falls back to the defaults) |
isLoading | boolean | false | Show the loading skeleton (while store data is not ready yet) |
showDistance | boolean | true | Whether to show each store's distanceText |
compact | boolean | false | Compact mode: tighter spacing; hints, step sub-labels and the opening hours in the list are hidden — suits a checkout sidebar |
className | string | — | Forwarded to the outermost container |
PickupChain
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Unique chain id, matched by PickupStore.chainId |
name | string | — | Display name of the chain |
color | string | — | Brand color (any valid CSS color string) used for the swatch on chips and the summary card; neutral gray when omitted |
PickupStore
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Unique store id — this is what value holds |
chainId | string | — | Id of the chain the store belongs to |
name | string | — | Store name |
address | string | — | Full address (shown in the list and the summary card) |
city | string | — | City, used to derive the step-two city dropdown |
district | string | — | District, used to derive the step-two district dropdown |
hours | string | — | Opening-hours text, e.g. "07:00–23:00" |
open24h | boolean | — | Open 24 hours; when true a badge is shown and it takes precedence over hours |
distanceText | string | — | Distance label (e.g. "350 公尺" / "350 m") computed by the caller; hidden when showDistance is false |
ConvenienceStorePickupLabels
| Field | Type | Default | Description |
|---|---|---|---|
title | string | "超商取貨" (Convenience store pickup) | Panel title |
stepChain | string | "取貨通路" (Chain) | Step one name |
stepArea | string | "取貨地區" (Area) | Step two name |
stepStore | string | "取貨門市" (Store) | Step three name |
chainHint | string | "請選擇要取貨的超商通路" (Choose the chain to pick up from) | Step one hint |
areaHint | string | "請選擇縣市與鄉鎮市區" (Choose the city and district) | Step two hint |
city | string | "縣市" (City) | Label of the city dropdown |
district | string | "鄉鎮市區" (District) | Label of the district dropdown |
cityPlaceholder | string | "選擇縣市" (Select a city) | Placeholder of the city dropdown |
districtPlaceholder | string | "選擇鄉鎮市區" (Select a district) | Placeholder of the district dropdown |
next | string | "查看門市" (View stores) | Button that goes to the store list once an area is chosen on step two |
searchPlaceholder | string | "搜尋門市名稱或地址" (Search by store name or address) | Placeholder of the store search input |
storeUnit | string | "間門市" (stores) | Unit appended to the store count ("N 間門市") |
open24h | string | "24 小時" (24 hours) | Text of the 24-hour badge |
emptyChains | string | "目前沒有可選的通路" (No chains available) | Empty state when there are no chains |
emptyAreas | string | "此通路目前沒有可取貨的門市" (This chain has no pickup stores) | Empty state when the chain has no stores |
emptyStores | string | "找不到符合的門市" (No matching stores) | Empty state when the search/filter finds nothing |
selected | string | "已選門市" (Selected store) | Summary card heading |
change | string | "更換" (Change) | Text of the re-select button on the summary card |
loading | string | "門市資料載入中…" (Loading stores…) | Screen-reader text for the loading skeleton |
filterPickupStores(stores, { chainId, city, district, query }) is also a named export — the same filter the component uses internally (the query matches store name and address, case-insensitively), handy for counting stores at the form layer or pre-filtering on the server.
How it works
- Three-step navigation: the step bar lets you jump back to any completed step; steps whose prerequisites are not met yet (e.g. picking a store before a chain) are disabled. Switching chains clears the chosen city/district, staying on the same chain keeps them. Choosing a district advances to the store list automatically; when you return to step two a "View stores" button takes you forward again.
- Options derived from data: cities and districts are de-duplicated from that chain's
stores, preserving the order you passed in (north to south, say) — no separate administrative-region table to maintain. - Collapses into a summary card: after a store is picked the stepper collapses and only the summary card remains; "Change" re-opens it straight at that store's list with the store pre-set as the active row. In controlled mode, setting
valueback tonullfrom outside re-opens the panel too. - Distance text is up to you: the component does no geolocation or distance math, it only renders
distanceText;showDistance={false}hides it entirely. - Motion: direction-aware horizontal slide between steps (forward slides left, back slides right), the first 8 list rows stagger in, the check mark uses a spring scale and the summary card expands by height.
Accessibility
- The step bar is an
ollist; the current step carriesaria-current="step"and steps whose prerequisites are unmet aredisabled - Chain chips are
aria-pressedbuttons inside arole="group"; city/district use nativeselectelements (withlabel), so mobile devices open the system picker - The store list is a focusable
role="listbox"withrole="option"rows andaria-selected, pointing at the current row viaaria-activedescendant; ↑/↓/Home/End move, Enter/Space selects, ↓ in the search input jumps straight into the list and Enter selects the current row (never misfires while a CJK IME is composing) - After a store is chosen focus moves to the summary card's "Change" button; after "Change" focus returns to the search input, so the keyboard flow never breaks; the store count is announced via
aria-live="polite" - The
isLoadingskeleton announces itself withrole="status",aria-busyand sr-only text - When the user has "reduce motion" enabled at the system level, step changes become a plain cross-fade, list rows no longer stagger, and the check mark and summary card drop their scale/height animations, leaving instant switches only
Recently Viewed Rail
A horizontally scrolling rail of recently viewed products, with fading edges and snap scrolling.
Installment Calculator (React)
An inline installment calculator React component for product pages: 3/6/12/24-month segmented switch, annual rate and fees, a rolling monthly amount, total and interest, effective annual rate, a 0% badge and an expandable per-period schedule.