@romain130492/topchopsticks 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # @topchopsticks/core
2
+
3
+
4
+ ## Publish
5
+
6
+ ```
7
+ cd packages/@romain130492/topchopsticks
8
+ npm login
9
+ npm version patch
10
+ npm publish --access public
11
+ ```
12
+
13
+
14
+ Everything the clients agree on: **copy, business rules and design tokens**.
15
+
16
+ ## The one rule
17
+
18
+ **No DOM. No Taro. No React.** Pure TypeScript and plain data.
19
+
20
+ That is not fussiness — it is what lets the same package serve:
21
+
22
+ - `web/` today (React + Tailwind),
23
+ - `wechat/` once the mini program migrates to it,
24
+ - a React Native app later, which was the reason to keep it platform-free.
25
+
26
+ So: no `document`, no `window`, no `wx.*`, no `Taro.*`, no JSX. Anything that
27
+ needs a platform (storage, toast, navigation, HTTP) is passed IN as a function.
28
+ See `api/client.ts` for the pattern.
29
+
30
+ ## What lives here
31
+
32
+ | Folder | Contents |
33
+ | --- | --- |
34
+ | `tokens/` | Colours, type scale, spacing, radii — as plain objects. Web turns them into Tailwind theme values; RN can read them directly. |
35
+ | `i18n/` | Every string, EN + ZH. |
36
+ | `utils/` | Booking rules, event dates, registration state, question rules. |
37
+ | `api/` | Endpoint definitions and response shapes, transport-agnostic. |
38
+
39
+ ## What does NOT live here
40
+
41
+ Anything you can see. Components stay in each app, because a `<View>` and a
42
+ `<div>` are not the same thing and pretending otherwise produces a wrapper
43
+ layer nobody enjoys.
44
+
45
+ ## Publishing to npm
46
+
47
+ The package is scoped to `@romain130492`, so the first publish must be public —
48
+ scoped packages default to private and npm rejects the publish without this.
49
+
50
+ ```bash
51
+ cd packages/core
52
+
53
+ npm login # once per machine
54
+ npm version patch # 0.1.0 -> 0.1.1 (also creates a git tag)
55
+ npm publish --access public
56
+ ```
57
+
58
+ After the first release, `--access public` is remembered via the
59
+ `publishConfig` block in package.json, so `npm publish` alone is enough.
60
+
61
+ ### Consuming it
62
+
63
+ `web/package.json` currently points at the folder:
64
+
65
+ ```json
66
+ "@romain130492/topchopsticks": "file:../packages/core"
67
+ ```
68
+
69
+ That is deliberate while the two move together — edits show up instantly with
70
+ no publish step, and `vite.config.ts` aliases the import straight to
71
+ `src/index.ts`. Once a third app exists (React Native), switch to the version:
72
+
73
+ ```json
74
+ "@romain130492/topchopsticks": "^0.1.0"
75
+ ```
76
+
77
+ and drop the alias from `vite.config.ts`.
78
+
79
+ ### Before you publish
80
+
81
+ - `npm run typecheck` — the package ships raw TypeScript, so a type error
82
+ becomes the consumer's problem otherwise.
83
+ - Nothing platform-specific crept in: no `document`, `window`, `wx.*`,
84
+ `Taro.*` or JSX. That constraint is the whole reason this package can serve
85
+ React Native later.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@romain130492/topchopsticks",
3
+ "version": "0.1.2",
4
+ "private": false,
5
+ "description": "Logic, copy and design tokens shared by every client. NO DOM, NO Taro, NO React — so a React Native app can consume it unchanged.",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./tokens": "./src/tokens/index.ts",
12
+ "./i18n": "./src/i18n/index.ts"
13
+ },
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "~5.8.3"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "README.md"
26
+ ]
27
+ }
@@ -0,0 +1,79 @@
1
+ import type {
2
+ ApplicationRecord, ApplicationTemplate, EventRecord, ProfileRecord
3
+ } from './types'
4
+
5
+ /**
6
+ * The API surface, transport-agnostic.
7
+ *
8
+ * The package never calls fetch, wx.request or Taro.request — the platform
9
+ * passes a `Transport` in. That is what keeps this file usable from React
10
+ * Native later without a rewrite, and it makes the endpoint list one shared
11
+ * definition instead of two that drift.
12
+ */
13
+
14
+ export interface RequestOptions {
15
+ method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
16
+ query?: Record<string, string | number | boolean | undefined>
17
+ body?: unknown
18
+ }
19
+
20
+ export type Transport = <T>(path: string, options?: RequestOptions) => Promise<T>
21
+
22
+ export function createApi(request: Transport) {
23
+ return {
24
+ // ---- auth ----
25
+ /** Web: ask Aliyun to text a code. */
26
+ sendSmsCode: (phone: string, country = '86') =>
27
+ request<{ sent: boolean; expires_in: number }>('/auth/sms/send', {
28
+ method: 'POST', body: { phone, country }
29
+ }),
30
+ /** Web: exchange the code for the SAME app JWT the mini program gets. */
31
+ verifySmsCode: (phone: string, code: string, country = '86') =>
32
+ request<{ token: string; profile: ProfileRecord }>('/auth/sms/verify', {
33
+ method: 'POST', body: { phone, code, country, language: 'zh' }
34
+ }),
35
+
36
+ // ---- me ----
37
+ getMe: () => request<ProfileRecord>('/me'),
38
+ updateMe: (body: Partial<ProfileRecord>) =>
39
+ request<ProfileRecord>('/me', { method: 'PATCH', body }),
40
+ updateOnboarding: (phase: string) =>
41
+ request<ProfileRecord>('/me/onboarding', { method: 'PATCH', body: { onboarding_phase: phase } }),
42
+
43
+ // ---- application ----
44
+ getApplicationTemplate: () => request<ApplicationTemplate>('/application-template'),
45
+ getMyApplication: () => request<ApplicationRecord | null>('/me/application'),
46
+ createMyApplication: (body: ApplicationRecord) =>
47
+ request<ApplicationRecord>('/me/application', { method: 'POST', body }),
48
+ updateMyApplication: (body: ApplicationRecord) =>
49
+ request<ApplicationRecord>('/me/application', { method: 'PATCH', body }),
50
+
51
+ // ---- events ----
52
+ getEvents: (query?: { source?: string; type?: string }) =>
53
+ request<EventRecord[]>('/events', { query }),
54
+ getEvent: (id: string) => request<EventRecord>(`/events/${id}`),
55
+ getEventAttendees: (id: string) =>
56
+ request<{ total: number; attendees: Array<{ id: string; avatar_url: string | null }> }>(
57
+ `/events/${id}/attendees`
58
+ ),
59
+
60
+ // ---- my seats ----
61
+ getMyEventMatches: () => request<{ matches: Array<{ event_id: string; status: string }> }>('/me/event-matches'),
62
+ joinEvent: (eventId: string) =>
63
+ request<unknown>('/me/event-matches', { method: 'POST', body: { event_id: eventId } }),
64
+ cancelSeat: (matchId: string) =>
65
+ request<unknown>(`/me/event-matches/${matchId}`, { method: 'PATCH', body: { status: 'cancelled' } }),
66
+
67
+ // ---- organize ----
68
+ getOrganizeEventTemplate: () => request<ApplicationTemplate>('/organize-event-template'),
69
+ listMyCommunityEventRequests: () => request<{ requests: unknown[] }>('/community-event-requests/me'),
70
+ submitCommunityEventRequest: (body: unknown) =>
71
+ request<unknown>('/community-event-requests', { method: 'POST', body }),
72
+
73
+ // ---- restaurants ----
74
+ getRestaurants: (query: { search?: string; page?: number; page_size?: number }) =>
75
+ request<{ restaurants: unknown[]; total: number; page_count: number }>('/restaurants', { query })
76
+ }
77
+ }
78
+
79
+ export type Api = ReturnType<typeof createApi>
@@ -0,0 +1,86 @@
1
+ /** Shapes the backend returns. Kept in one place so both clients agree. */
2
+
3
+ export interface ProfileRecord {
4
+ id: string
5
+ username?: string | null
6
+ display_name?: string | null
7
+ avatar_url?: string | null
8
+ email?: string | null
9
+ /** Web/SMS identity. E.164 without the +, e.g. 8613800138000. */
10
+ phone_number?: string | null
11
+ phone_country?: string | null
12
+ wechat_openid?: string | null
13
+ wechat_id?: string | null
14
+ wechat_profile_granted_at?: string | null
15
+ wechat_profile_prompted?: boolean
16
+ onboarding_phase?: string | null
17
+ language_preference?: string | null
18
+ gender?: string | null
19
+ date_of_birth?: string | null
20
+ [key: string]: unknown
21
+ }
22
+
23
+ export interface ApplicationRecord {
24
+ id?: string
25
+ application_template_id?: string
26
+ answers?: Record<string, unknown>
27
+ completion_percentage?: number
28
+ status?: string
29
+ [key: string]: unknown
30
+ }
31
+
32
+ export interface EventPicture { sizes: { original: string; [k: string]: string | undefined } }
33
+
34
+ export interface EventRecord {
35
+ id: string
36
+ title?: unknown
37
+ description?: unknown
38
+ address?: unknown
39
+ cover_image_url?: string | null
40
+ pictures?: EventPicture[]
41
+ video?: unknown
42
+ event_type: string
43
+ source: string
44
+ visibility?: string
45
+ status?: string
46
+ booking_mode?: string
47
+ price?: number
48
+ price_label?: unknown
49
+ currency?: string
50
+ languages?: string[]
51
+ start_at?: string | null
52
+ end_at?: string | null
53
+ capacity?: number
54
+ seats_remaining?: number | null
55
+ wechat_group_qr_url?: string | null
56
+ /** Set only on rows projected from a pending community request. */
57
+ is_request?: boolean
58
+ [key: string]: unknown
59
+ }
60
+
61
+ export interface TemplateQuestion {
62
+ key: string
63
+ label: unknown
64
+ help_text?: unknown
65
+ type?: string
66
+ question_type?: string
67
+ options?: Array<{ value: string; label: unknown; emoji?: string }>
68
+ is_required?: boolean
69
+ validation_rules?: {
70
+ min?: number
71
+ max?: number
72
+ min_selected?: number
73
+ visible_when?: { key: string; equals?: unknown; in?: unknown[] }
74
+ [k: string]: unknown
75
+ }
76
+ }
77
+
78
+ export interface TemplateSection { key: string; title: unknown; questions: TemplateQuestion[] }
79
+
80
+ export interface ApplicationTemplate {
81
+ id: string
82
+ template_kind?: string
83
+ name?: string
84
+ intro?: unknown
85
+ sections: TemplateSection[]
86
+ }
package/src/i18n/en.ts ADDED
@@ -0,0 +1,322 @@
1
+ /**
2
+ * English copy. Ported verbatim from wechat/src/i18n/en.js.
3
+ *
4
+ * Shared so a wording change lands in both apps at once. If a key exists
5
+ * here it must exist in the other language file too — see i18n/index.ts,
6
+ * which fails loudly on a missing key rather than rendering the key name.
7
+ */
8
+ export const en = {
9
+ 'sms.kicker': 'Join free',
10
+ 'sms.title': 'Your phone number',
11
+ 'sms.sub': 'We text you a 6-digit code. No password to remember.',
12
+ 'sms.phonePlaceholder': 'Mobile number',
13
+ 'sms.codePlaceholder': 'Code',
14
+ 'sms.sendCode': 'Send code',
15
+ 'sms.verify': 'Continue',
16
+ 'sms.codeSent': 'Code sent',
17
+ 'sms.sendFailed': "We couldn't send the code. Try again.",
18
+ 'sms.wrongCode': 'That code is not right.',
19
+ 'sms.invalidPhone': 'Enter a valid mobile number',
20
+ 'sms.enterCode': 'Enter the code we texted you',
21
+ 'sms.legal': 'By continuing you agree to our terms and privacy policy.',
22
+
23
+ 'brand.suffix': 'Social Dining',
24
+
25
+ 'nav.apply': 'Apply',
26
+ 'nav.dining': 'Social Dining',
27
+ 'nav.six': 'The Six',
28
+ 'nav.profile': 'Profile',
29
+
30
+ 'common.loading': 'Loading…',
31
+ 'common.optional': 'optional',
32
+ 'common.back': 'Back',
33
+ 'common.empty': 'Nothing here yet.',
34
+ 'common.continue': 'Continue',
35
+ 'common.retry': 'Try again',
36
+ 'common.yes': 'Yes',
37
+ 'common.no': 'No',
38
+
39
+ 'choose.kicker': 'Before we seat you',
40
+ 'choose.title': 'What brings you to the table?',
41
+ 'choose.sub': 'Pick the one that fits you best today. It shapes the few questions we ask — and who we sit you next to.',
42
+ 'choose.foot': 'You can change this later — some people come for one thing and stay for another.',
43
+
44
+ 'apply.reviewed': 'Reviewed personally · most hear back within 48h',
45
+ 'apply.submit': 'Submit application',
46
+ 'apply.save': 'Save changes',
47
+ 'apply.back': '← Pick a different reason',
48
+ 'apply.editnote': "You've already applied — update any answer below and save. Your place in the queue stays.",
49
+ 'apply.progress': '{n}% complete',
50
+ 'apply.fillRequired': 'Please fill the required questions first.',
51
+ 'apply.saved': 'Saved',
52
+ 'apply.submitted': 'Application submitted',
53
+ 'apply.completeFirst': 'Submit an application first to unlock this.',
54
+ 'apply.reserveNotice': 'Please register yourself first before reserving a seat at the table.',
55
+
56
+ 'onboarding.stageCore': 'Your profile',
57
+ 'onboarding.stageMatching': 'Better matches',
58
+ 'onboarding.progressStart': 'Choose to begin',
59
+ 'onboarding.progressComplete': 'Core complete',
60
+ 'onboarding.progressOne': '1 panel left',
61
+ 'onboarding.progressMany': '{n} panels left',
62
+ 'onboarding.motivationTitle': 'What brings you to the table?',
63
+ 'onboarding.motivationSub': 'Choose the one that fits you best today. It shapes the questions we ask and who we seat you with.',
64
+ 'onboarding.coreDone': 'Your profile is ready',
65
+ 'onboarding.matchTitle': 'Help us match you better?',
66
+ 'onboarding.matchSub': 'A few more answers help us find the people and tables that feel right for you.',
67
+ 'onboarding.matchNow': 'Yes, improve my matches',
68
+ 'onboarding.matchLater': "I'll finish this later",
69
+ 'onboarding.finish': 'Finish my profile',
70
+ 'onboarding.skipHint': 'You can leave this blank and continue.',
71
+ 'onboarding.answerRequired': 'Choose or enter an answer to continue.',
72
+ 'onboarding.saveFailed': "We couldn't save that. Please try again.",
73
+ 'onboarding.loadFailed': "We couldn't load your profile panels.",
74
+ 'onboarding.loadFailedSub': 'Check your connection and try again.',
75
+ 'onboarding.done': 'Profile ready',
76
+
77
+ 'restaurantFinder.pickFromList': 'Pick one from the list below.',
78
+ 'restaurantFinder.searchPlaceholder': 'Search restaurant, area, address, flavour...',
79
+ 'restaurantFinder.loading': 'Loading restaurants...',
80
+ 'restaurantFinder.defaultResults': 'Showing 20 restaurants to start',
81
+ 'restaurantFinder.results': 'Closest matches',
82
+ 'restaurantFinder.empty': 'No restaurants found.',
83
+ 'restaurantFinder.error': "Couldn't load restaurants. Please try again.",
84
+ 'restaurantFinder.rating': 'Rating',
85
+
86
+ 'gate.kicker': 'One step to begin',
87
+ 'gate.title': 'Link your WeChat',
88
+ 'gate.sub': 'Top Chopsticks seats you at tables with real people, so we link your WeChat account before you come in.',
89
+ 'gate.contract': 'Read the privacy guideline',
90
+ 'gate.agree': 'Agree and continue',
91
+ 'gate.decline': 'Not now',
92
+ 'gate.declined': 'You need to link WeChat to use the app.',
93
+ 'gate.step2': 'Almost there',
94
+ 'gate.authFailed': 'WeChat login failed',
95
+ 'gate.authFailedSub': 'You need to be signed in to use Top Chopsticks. Shared event links remain public.',
96
+
97
+ 'wxp.title': 'Use your WeChat profile?',
98
+ 'wxp.sub': 'Tap the avatar to use your WeChat picture, and the name field to fill in your WeChat name. Entirely optional — the app works the same either way.',
99
+ 'wxp.titleReq': 'Your picture and name',
100
+ 'wxp.subReq': 'Tap the field and pick your WeChat name. Adding your WeChat picture is optional — we will give you one if you skip it.',
101
+ 'wxp.continue': 'Continue',
102
+ 'wxp.needName': 'Please add your name',
103
+ 'wxp.needBoth': 'Please add both your picture and your name.',
104
+ 'wxp.avatarFailed': "Name saved — the picture couldn't be uploaded.",
105
+ 'wxp.avatarFailedTitle': "Picture couldn't be uploaded",
106
+ 'wxp.avatarFailedDetail': "Your name was saved, but your picture was not. Please try again from your profile.",
107
+ 'wxp.avatarHint': 'Tap to choose your picture',
108
+ 'wxp.namePlaceholder': 'Your name',
109
+ 'wxp.save': 'Use this',
110
+ 'wxp.skip': 'Not now',
111
+ 'wxp.saved': 'Thanks — profile updated',
112
+ 'wxp.failed': "Couldn't save that. You can try again from your profile.",
113
+ 'wxp.failedTitle': "Couldn't update your profile",
114
+ 'wxp.failedDetail': "We couldn't save your profile right now. Please try again.",
115
+
116
+ 'ev.curated': 'Curated by us',
117
+ 'ev.community': 'Community',
118
+ 'ev.info': "Everyone can see every event for now. Once the community grows, you'll filter these by what brings you here.",
119
+ 'ev.commInfo': "Community tables are hosted by members, not us. We show all of them while we're small — later you'll filter them by motivation, neighborhood and more.",
120
+ 'ev.invited': "You're invited",
121
+ 'ev.more': 'More curated tables',
122
+ 'ev.details': 'See details',
123
+ 'ev.aboutTitle': 'About this table',
124
+ 'ev.seatConfirmed': 'Your seat is confirmed.',
125
+ 'ev.hide': 'Hide details',
126
+ 'ev.reserve': 'Reserve my seat',
127
+ 'ev.join': 'Join',
128
+ 'ev.address': 'Address',
129
+ 'ev.addressCopied': 'Address copied',
130
+ 'ev.copyFailed': 'Could not copy',
131
+ 'ev.languages': 'Spoken at the table',
132
+ 'ev.qrTitle': 'Join the group chat',
133
+ 'ev.qrHint': 'Scan to join the WeChat group for this table.',
134
+ 'ev.reserveNoPay': 'Reserve my seat',
135
+ 'ev.payLaterNote': 'No payment now — settle with us at the table.',
136
+ 'ev.reserved': "You're in — we'll see you there",
137
+ 'ev.bookedLabel': "You're in ✓",
138
+ 'ev.cancelReservation': 'Cancel my reservation',
139
+ 'ev.cancelConfirmTitle': 'Cancel your seat?',
140
+ 'ev.cancelConfirm': 'Your seat goes back to the table and someone else can take it. You can book again if it is still open.',
141
+ 'ev.cancelConfirmYes': 'Yes',
142
+ 'ev.cancelConfirmNo': 'Keep',
143
+ 'ev.cancelled': 'Reservation cancelled',
144
+ 'ev.cancelFailed': 'Could not cancel — please try again.',
145
+ 'ev.share': 'Share',
146
+ 'ev.joined': "You're in — seat booked",
147
+ 'ev.joinFailed': "Couldn't book your seat. Please try again.",
148
+ 'ev.free': 'free',
149
+ 'ev.seat': 'Seat, all-in',
150
+ 'ev.seatsRemaining': 'seats remaining',
151
+ 'ev.people': 'People',
152
+ 'ev.curatedBy': 'Curated by',
153
+ 'ev.format': 'Format',
154
+ 'ev.menuPreview': 'Menu preview',
155
+ 'ev.spark': "Tonight's spark",
156
+ 'ev.fullMenu': 'Full menu — translated and filtered to your diet — revealed 24h before.',
157
+ 'ev.swipePhotos': 'Swipe for photos ›',
158
+ 'ev.vipNote': "🔒 The Six — our hand-matched VIP tables — are never listed here. They appear under The Six once you've dined with us.",
159
+ 'ev.finishedLabel': 'This event has ended',
160
+ 'ev.attendedLabel': 'You attended',
161
+ 'guest.incompleteTitle': 'Almost there',
162
+ 'guest.incompleteSub': 'You are signed in with WeChat. Finish a few questions and you can book and be matched.',
163
+ 'guest.ctaFinish': 'Finish setup',
164
+ 'guest.cta': 'Join free',
165
+ 'guest.eventsTitle': 'Join free to book a seat',
166
+ 'guest.eventsSub': 'Tell us a little about you and we will match you with the table that fits.',
167
+ 'guest.sixTitle': 'Your table, chosen for you',
168
+ 'guest.sixSub': 'Answer a few questions and our matching puts you with people you will actually want to eat with.',
169
+ 'guest.profileTitle': 'Nothing here yet',
170
+ 'guest.profileSub': 'Join free to save your profile, book seats and get matched to the right table.',
171
+ 'guest.reserveLocked': 'Join free to book a seat',
172
+ 'ev.seePast': 'See past events',
173
+ 'ev.hidePast': 'Hide past events',
174
+ 'ev.hostCta': 'Want to host your own? Tap “Organize your table”.',
175
+ 'ev.curatedHeading': 'Curated By Us',
176
+ 'ev.communityHeading': 'Community',
177
+ 'ev.emptyCurated': 'No curated tables for now — check back soon.',
178
+ 'ev.emptyCommunity': 'No community tables for now.',
179
+ 'ev.empty': 'No events yet — check back soon.',
180
+
181
+ 'fab.label': 'Organize your table',
182
+ 'org.kicker': 'Host with us',
183
+ 'org.title': 'Organize your table',
184
+ 'org.sub': "Got an idea for a dinner, a tasting, a themed night? We'll help you fill the seats. For now, just say hi on WeChat — we'll take it from there.",
185
+ 'org.can': 'What you can host',
186
+ 'org.1t': 'A curated table with us',
187
+ 'org.1d': 'We co-design it, vet the guests and handle the seating — you bring the concept.',
188
+ 'org.2t': 'A self-service community event',
189
+ 'org.2d': 'Your dinner, your rules. Post it, we help it find the right people.',
190
+ 'org.3t': 'A virtual night',
191
+ 'org.3d': 'Recipe swaps, cook-alongs, tastings over video. (coming later)',
192
+ 'org.scan': 'Scan to reach us on WeChat',
193
+ 'org.got': 'Got it',
194
+ 'orgFlow.title': 'Organize your table',
195
+ 'orgFlow.requiredPrefix': 'Required: ',
196
+ 'orgFlow.priceRequired': 'Add a price per guest for pay later.',
197
+ 'orgFlow.typeCommunity': 'Community event',
198
+ 'orgFlow.type1to1': '1 to 1',
199
+ 'orgFlow.typeKicker': 'First',
200
+ 'orgFlow.typeTitle': 'What do you want to organize?',
201
+ 'orgFlow.typeSub': 'Choose the format. We will ask a few details next.',
202
+ 'orgFlow.stageType': 'Event type',
203
+ 'orgFlow.panelPrompt': 'Tell us the useful details. You can keep it rough.',
204
+ 'orgFlow.step': 'Step {n}/{total}',
205
+ 'orgFlow.panelsLeft': '{n} left',
206
+ 'orgFlow.save': 'Save',
207
+ 'orgFlow.saved': 'Draft saved',
208
+ 'orgFlow.cancel': 'Cancel',
209
+ 'orgFlow.discardTitle': 'Discard this draft?',
210
+ 'orgFlow.discardBody': 'Unsaved changes will be lost. Save first if you want to continue later.',
211
+ 'orgFlow.keepEditing': 'Keep editing',
212
+ 'orgFlow.discard': 'Discard',
213
+ 'orgFlow.pickType': 'Choose an event type first.',
214
+ 'orgFlow.submit': 'Submit',
215
+ 'orgFlow.submitCommunity': 'Submit event',
216
+ 'orgFlow.submitOneToOne': 'Submit 1-to-1 table',
217
+ 'orgFlow.updateCommunity': 'Update event',
218
+ 'orgFlow.updateOneToOne': 'Update 1-to-1 table',
219
+ 'orgFlow.slotDuplicate': 'That time is already in your list.',
220
+ 'orgFlow.draftKept': 'Draft saved.',
221
+ 'orgFlow.pendingTag': 'Awaiting approval',
222
+ 'orgFlow.slotsHint': 'Add at least one time option.',
223
+ 'orgFlow.photoAdd': 'Choose a photo',
224
+ 'orgFlow.photoReplace': 'Replace photo',
225
+ 'orgFlow.photoHint': 'Optional · JPG or PNG · up to 5 MB',
226
+ 'orgFlow.photoTooBig': 'That image is over 5 MB.',
227
+ 'orgFlow.photoFailed': 'Upload failed. Please try again.',
228
+ 'orgFlow.pendingHint': 'Our team reviews within 24-48h.',
229
+ 'orgFlow.submitFailed': "We couldn't submit that. Please try again.",
230
+ 'orgFlow.loadFailed': "We couldn't load the organizer panels.",
231
+ 'orgFlow.loadFailedSub': 'Check your connection and try again.',
232
+ 'orgFlow.doneTitle': 'Sent for approval',
233
+ 'orgFlow.doneSub': 'Our team reviews new tables within 24-48 hours. We will get back to you here.',
234
+ 'orgFlow.updatedTitle': 'Your event idea was updated',
235
+ 'orgFlow.updatedSub': 'Our team reviews changes within 24-48 hours.',
236
+ 'orgFlow.backToEvents': 'Back to events',
237
+ 'orgFlow.myRequests': 'Your organizer requests',
238
+ 'orgFlow.waitingApproval': 'Waiting for approval',
239
+ 'orgFlow.editRequest': 'Edit',
240
+ 'orgFlow.untitledRequest': 'Untitled table idea',
241
+ 'orgFlow.people': '{n} people',
242
+
243
+ 'six.badge': 'Invitation only',
244
+ 'six.exTitle': 'The Six — a table chosen for you',
245
+ 'six.exSub': "Our VIP tables seat exactly six people we match by hand. They're never listed publicly and never appear in Social Dining.",
246
+ 'six.exHow': 'How we match you',
247
+ 'six.s1t': 'Join our events',
248
+ 'six.s1p': 'Come to a couple of curated dinners so we can see how you connect with a table.',
249
+ 'six.s2t': 'We review, personally',
250
+ 'six.s2p': "Our team looks at your profile, your dietary and interest fit, and who you've dined well with. No algorithm decides your seat.",
251
+ 'six.s3t': 'You get invited',
252
+ 'six.s3p': "When we're confident the six of you will click, we send a private invite. The only thing you'll know in advance is the date.",
253
+ 'six.exFoot': 'Access opens after your first dinner with us. Start with Social Dining.',
254
+ 'six.exCta': 'See Social Dining',
255
+ 'six.inTitle': 'A table chosen for you',
256
+ 'six.inSub': "Based on your dinners with us, we've hand-matched you with five people we believe you'll like. These tables are never listed publicly. Trust us once more.",
257
+ 'six.inWhereImg': 'Where you might be dining',
258
+ 'six.inWhen': 'When',
259
+ 'six.inWhere': 'Where',
260
+ 'six.inWho': 'Who',
261
+ 'six.inMenu': 'Menu',
262
+ 'six.inAccept': 'Accept my seat',
263
+ 'six.inSeats': 'of 6 seats remaining',
264
+ 'six.inLater': 'Maybe next time',
265
+ 'six.inExpires': 'Invite expires in',
266
+
267
+ 'pay.kicker': 'Confirm your seat',
268
+ 'pay.date': 'Date',
269
+ 'pay.seat': 'Seat',
270
+ 'pay.includes': 'Includes',
271
+ 'pay.includesv': 'Full menu · service',
272
+ 'pay.cancel': 'Cancellation',
273
+ 'pay.cancelv': 'Free >72h · 50% >24h',
274
+ 'pay.warn': 'Seats are matched to you personally — an empty chair breaks the table. No-shows forfeit the full amount.',
275
+ 'pay.pay': 'Pay with WeChat Pay',
276
+ 'pay.paid': 'Payment received',
277
+ 'pay.back': 'Back',
278
+
279
+ 'profile.member': 'Member',
280
+ 'profile.myApplication': 'My application',
281
+ 'profile.title': 'My profile',
282
+ 'profile.app': 'My application',
283
+ 'profile.herefor': 'Here for',
284
+ 'profile.status': 'Status',
285
+ 'profile.submitted': 'Submitted',
286
+ 'profile.completion': 'Profile completion',
287
+ 'profile.update': 'Update my application',
288
+ 'profile.membership': 'Membership',
289
+ 'profile.invited': 'Invited by',
290
+ 'profile.attended': 'Dinners attended',
291
+ 'profile.meetAgain': 'Meet-again received',
292
+ 'profile.referrals': 'Referrals',
293
+ 'profile.gift': '1 seat to gift',
294
+ 'profile.high': 'High',
295
+ 'profile.lang': 'Language',
296
+ 'profile.session': 'Account',
297
+ 'profile.refreshWechatLogin': 'Refresh WeChat login',
298
+ 'profile.wechatLoginRefreshed': 'WeChat login refreshed',
299
+ 'profile.wechatLoginRefreshFailedTitle': "Couldn't refresh WeChat login",
300
+ 'profile.wechatLoginRefreshFailedDetail': 'Please try again in a moment.',
301
+ 'profile.logout': 'Log out',
302
+ 'profile.login': 'Log in with WeChat',
303
+ 'profile.loginHint': "You're signed out. Log in to sync your profile and matches.",
304
+ 'profile.loggedOut': 'Signed out',
305
+ 'profile.devMode': 'DEV',
306
+ 'profile.buildVersion': 'Mini program build',
307
+ 'profile.forceSignOut': 'Force sign out (clear session)',
308
+ 'profile.sessionCleared': 'Session cleared',
309
+ 'profile.seeEventsAsPublic': 'See events as public',
310
+ 'profile.seeEventsAsSelf': 'See events as myself',
311
+ 'profile.viewPublic': 'public',
312
+ 'profile.viewSelf': 'me',
313
+ 'profile.seeProfilePanel': 'See profile panel',
314
+
315
+ 'intent.gastronomy': 'Food',
316
+ 'intent.professional_networking': 'Networking',
317
+ 'intent.local_friendships': 'Local friends',
318
+
319
+ 'tone.food': 'For the food',
320
+ 'tone.network': 'Worth knowing',
321
+ 'tone.local': 'Local friends'
322
+ } as const
@@ -0,0 +1,41 @@
1
+ import { en } from './en'
2
+ import { zh } from './zh'
3
+
4
+ export type Lang = 'en' | 'zh'
5
+ export type TranslationKey = keyof typeof en
6
+
7
+ const DICTS = { en, zh } as const
8
+
9
+ /**
10
+ * Both languages must define the same keys. Missing copy should be caught in
11
+ * development, not discovered by a member seeing `guest.eventsTitle` on screen.
12
+ */
13
+ export function missingKeys(): { inZh: string[]; inEn: string[] } {
14
+ const enKeys = Object.keys(en)
15
+ const zhKeys = Object.keys(zh)
16
+ return {
17
+ inZh: enKeys.filter((k) => !zhKeys.includes(k)),
18
+ inEn: zhKeys.filter((k) => !enKeys.includes(k))
19
+ }
20
+ }
21
+
22
+ /** A bilingual value from the API: a plain string, or { en, zh }. */
23
+ export type Localised = string | { en?: string; zh?: string } | null | undefined
24
+
25
+ export function localise(value: Localised, lang: Lang): string {
26
+ if (value === null || value === undefined) return ''
27
+ if (typeof value === 'string') return value
28
+ return value[lang] || value.en || value.zh || ''
29
+ }
30
+
31
+ export function createTranslator(lang: Lang) {
32
+ const dict = DICTS[lang] as Record<string, string>
33
+ const fallback = en as Record<string, string>
34
+ return {
35
+ t: (key: string): string => dict[key] ?? fallback[key] ?? key,
36
+ L: (value: Localised) => localise(value, lang),
37
+ lang
38
+ }
39
+ }
40
+
41
+ export { en, zh }