@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/src/i18n/zh.ts ADDED
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Chinese copy. Ported verbatim from wechat/src/i18n/zh.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 zh = {
9
+ 'sms.kicker': '免费加入',
10
+ 'sms.title': '你的手机号',
11
+ 'sms.sub': '我们会发送 6 位验证码,无需记住密码。',
12
+ 'sms.phonePlaceholder': '手机号',
13
+ 'sms.codePlaceholder': '验证码',
14
+ 'sms.sendCode': '获取验证码',
15
+ 'sms.verify': '继续',
16
+ 'sms.codeSent': '验证码已发送',
17
+ 'sms.sendFailed': '验证码发送失败,请重试。',
18
+ 'sms.wrongCode': '验证码不正确。',
19
+ 'sms.invalidPhone': '请输入有效的手机号',
20
+ 'sms.enterCode': '请输入收到的验证码',
21
+ 'sms.legal': '继续即表示同意我们的条款与隐私政策。',
22
+
23
+ 'brand.suffix': '社交饭局',
24
+
25
+ 'nav.apply': '申请',
26
+ 'nav.dining': '社交饭局',
27
+ 'nav.six': '六人局',
28
+ 'nav.profile': '我的',
29
+
30
+ 'common.loading': '加载中…',
31
+ 'common.optional': '选填',
32
+ 'common.back': '返回',
33
+ 'common.empty': '这里还没有内容。',
34
+ 'common.continue': '继续',
35
+ 'common.retry': '重试',
36
+ 'common.yes': '是',
37
+ 'common.no': '否',
38
+
39
+ 'choose.kicker': '入座之前',
40
+ 'choose.title': '你为什么而来?',
41
+ 'choose.sub': '选一个最贴近你的。它决定我们问你的几个问题,也决定谁坐在你身边。',
42
+ 'choose.foot': '之后可以随时更改——很多人为一件事而来,却因另一件事留下。',
43
+
44
+ 'apply.reviewed': '真人逐一审核 · 多数人 48 小时内收到回复',
45
+ 'apply.submit': '提交申请',
46
+ 'apply.save': '保存修改',
47
+ 'apply.back': '← 换一个理由',
48
+ 'apply.editnote': '你已提交申请——在下方修改任意答案并保存,排队位置不变。',
49
+ 'apply.progress': '完成度 {n}%',
50
+ 'apply.fillRequired': '请先填写必填的问题。',
51
+ 'apply.saved': '已保存',
52
+ 'apply.submitted': '申请已提交',
53
+ 'apply.completeFirst': '请先提交申请以解锁此功能。',
54
+ 'apply.reserveNotice': '请先完成注册,再预订这一桌的座位。',
55
+
56
+ 'onboarding.stageCore': '你的资料',
57
+ 'onboarding.stageMatching': '更好地匹配',
58
+ 'onboarding.progressStart': '选择后开始',
59
+ 'onboarding.progressComplete': '核心资料已完成',
60
+ 'onboarding.progressOne': '还剩 1 个页面',
61
+ 'onboarding.progressMany': '还剩 {n} 个页面',
62
+ 'onboarding.motivationTitle': '你为什么而来?',
63
+ 'onboarding.motivationSub': '选择今天最符合你的一个。它会决定我们问你的问题,也会影响我们为你安排的同桌。',
64
+ 'onboarding.coreDone': '你的资料已准备好',
65
+ 'onboarding.matchTitle': '想让我们更好地为你匹配吗?',
66
+ 'onboarding.matchSub': '再回答几个问题,帮助我们为你找到更合拍的人和餐桌。',
67
+ 'onboarding.matchNow': '现在完善匹配',
68
+ 'onboarding.matchLater': '稍后再填',
69
+ 'onboarding.finish': '完成我的资料',
70
+ 'onboarding.skipHint': '可以留空并继续。',
71
+ 'onboarding.answerRequired': '请选择或填写答案后继续。',
72
+ 'onboarding.saveFailed': '暂时无法保存,请重试。',
73
+ 'onboarding.loadFailed': '暂时无法加载资料页面。',
74
+ 'onboarding.loadFailedSub': '请检查网络后重试。',
75
+ 'onboarding.done': '资料已完成',
76
+
77
+ 'restaurantFinder.pickFromList': '请从下方列表中选择。',
78
+ 'restaurantFinder.searchPlaceholder': '搜索餐厅、商圈、地址、口味…',
79
+ 'restaurantFinder.loading': '正在加载餐厅…',
80
+ 'restaurantFinder.defaultResults': '先显示 20 家餐厅',
81
+ 'restaurantFinder.results': '最接近的结果',
82
+ 'restaurantFinder.empty': '没有找到餐厅。',
83
+ 'restaurantFinder.error': '暂时无法加载餐厅,请重试。',
84
+ 'restaurantFinder.rating': '评分',
85
+
86
+ 'gate.kicker': '开始前的一步',
87
+ 'gate.title': '关联你的微信',
88
+ 'gate.sub': 'Top Chopsticks 会把你安排到真实的餐桌上,因此进入前需要先关联你的微信账号。',
89
+ 'gate.contract': '查看隐私保护指引',
90
+ 'gate.agree': '同意并继续',
91
+ 'gate.decline': '暂不',
92
+ 'gate.declined': '需要关联微信才能使用本小程序。',
93
+ 'gate.step2': '就快好了',
94
+ 'gate.authFailed': '微信登录失败',
95
+ 'gate.authFailedSub': '使用 Top Chopsticks 前需要先登录。分享的活动页面仍可直接查看。',
96
+
97
+ 'wxp.title': '使用你的微信资料?',
98
+ 'wxp.sub': '点击头像可使用你的微信头像,点击名字栏可填入你的微信昵称。完全可选——不填也不影响使用。',
99
+ 'wxp.titleReq': '你的头像和昵称',
100
+ 'wxp.subReq': '点击输入框选择你的微信昵称。头像可选,不上传我们会为你分配一个。',
101
+ 'wxp.continue': '继续',
102
+ 'wxp.needName': '请填写你的名字',
103
+ 'wxp.needBoth': '请填写头像和昵称。',
104
+ 'wxp.avatarFailed': '昵称已保存——头像上传失败。',
105
+ 'wxp.avatarFailedTitle': '头像上传失败',
106
+ 'wxp.avatarFailedDetail': '昵称已经保存,但头像没有上传成功。请到「我的」页面再试一次。',
107
+ 'wxp.avatarHint': '点击选择头像',
108
+ 'wxp.namePlaceholder': '你的名字',
109
+ 'wxp.save': '就用这个',
110
+ 'wxp.skip': '暂不',
111
+ 'wxp.saved': '已更新你的资料',
112
+ 'wxp.failed': '保存失败,可稍后在「我的」页面再试。',
113
+ 'wxp.failedTitle': '资料更新失败',
114
+ 'wxp.failedDetail': '暂时无法保存你的资料,请稍后再试。',
115
+
116
+ 'ev.curated': '官方精选',
117
+ 'ev.community': '社区自组',
118
+ 'ev.info': '目前所有人都能看到全部活动。等社区壮大后,你就能按来意筛选。',
119
+ 'ev.commInfo': '社区餐桌由会员发起,而非官方。初期我们全部展示——之后可按来意、区域等筛选。',
120
+ 'ev.invited': '为你而设',
121
+ 'ev.more': '更多精选餐桌',
122
+ 'ev.details': '查看详情',
123
+ 'ev.aboutTitle': '关于这一桌',
124
+ 'ev.seatConfirmed': '你的座位已确认。',
125
+ 'ev.hide': '收起详情',
126
+ 'ev.reserve': '预订餐位',
127
+ 'ev.join': '报名',
128
+ 'ev.address': '地址',
129
+ 'ev.addressCopied': '地址已复制',
130
+ 'ev.copyFailed': '复制失败',
131
+ 'ev.languages': '桌上使用的语言',
132
+ 'ev.qrTitle': '加入群聊',
133
+ 'ev.qrHint': '扫码加入本桌的微信群。',
134
+ 'ev.reserveNoPay': '预订座位',
135
+ 'ev.payLaterNote': '现在无需付款——到场后与我们结算。',
136
+ 'ev.reserved': '预订成功——到时见',
137
+ 'ev.bookedLabel': '已预订 ✓',
138
+ 'ev.cancelReservation': '取消我的预订',
139
+ 'ev.cancelConfirmTitle': '取消座位?',
140
+ 'ev.cancelConfirm': '你的座位将释放给其他人。如果还有空位,之后仍可再次预订。',
141
+ 'ev.cancelConfirmYes': '确定',
142
+ 'ev.cancelConfirmNo': '保留',
143
+ 'ev.cancelled': '预订已取消',
144
+ 'ev.cancelFailed': '取消失败,请重试。',
145
+ 'ev.share': '分享',
146
+ 'ev.joined': '报名成功——座位已锁定',
147
+ 'ev.joinFailed': '报名失败,请再试一次。',
148
+ 'ev.free': '免费',
149
+ 'ev.seat': '餐位(全包)',
150
+ 'ev.seatsRemaining': '个席位剩余',
151
+ 'ev.people': '人数',
152
+ 'ev.curatedBy': '策划人',
153
+ 'ev.format': '形式',
154
+ 'ev.menuPreview': '菜单预览',
155
+ 'ev.spark': '今晚的破冰题',
156
+ 'ev.fullMenu': '完整菜单——已翻译并按你的忌口筛选——开席前 24 小时揭晓。',
157
+ 'ev.swipePhotos': '滑动查看更多照片 ›',
158
+ 'ev.vipNote': '🔒 六人局——我们亲手匹配的 VIP 餐桌——从不在此列出。与我们吃过饭后,会在「六人局」中出现。',
159
+ 'ev.finishedLabel': '活动已结束',
160
+ 'ev.attendedLabel': '你已参加',
161
+ 'guest.incompleteTitle': '就差一步',
162
+ 'guest.incompleteSub': '你已用微信登录,回答几个问题即可订座并参与匹配。',
163
+ 'guest.ctaFinish': '完成注册',
164
+ 'guest.cta': '免费加入',
165
+ 'guest.eventsTitle': '免费加入即可订座',
166
+ 'guest.eventsSub': '简单介绍一下你自己,我们会为你匹配合适的餐桌。',
167
+ 'guest.sixTitle': '为你挑选的餐桌',
168
+ 'guest.sixSub': '回答几个问题,我们的匹配会让你遇到真正聊得来的人。',
169
+ 'guest.profileTitle': '这里还是空的',
170
+ 'guest.profileSub': '免费加入即可保存资料、预订座位并获得匹配。',
171
+ 'guest.reserveLocked': '免费加入即可订座',
172
+ 'ev.seePast': '查看往期活动',
173
+ 'ev.hidePast': '收起往期活动',
174
+ 'ev.hostCta': '想自己办一场?点「组织你的餐桌」。',
175
+ 'ev.curatedHeading': '我们的精选',
176
+ 'ev.communityHeading': '社区餐桌',
177
+ 'ev.emptyCurated': '暂无精选餐桌——请稍后再来。',
178
+ 'ev.emptyCommunity': '暂无社区餐桌。',
179
+ 'ev.empty': '暂无活动——请稍后再来。',
180
+
181
+ 'fab.label': '组织你的餐桌',
182
+ 'org.kicker': '与我们合办',
183
+ 'org.title': '组织你的餐桌',
184
+ 'org.sub': '想办一场饭局、品鉴或主题夜?我们帮你把位子填满。现在,先在微信上跟我们打个招呼,剩下的交给我们。',
185
+ 'org.can': '你可以办什么',
186
+ 'org.1t': '与我们共同策划的餐桌',
187
+ 'org.1d': '我们共同设计、筛选嘉宾、安排座位——你出点子。',
188
+ 'org.2t': '自助式社区活动',
189
+ 'org.2d': '你的饭局,你说了算。发布出来,我们帮它找到对的人。',
190
+ 'org.3t': '线上之夜',
191
+ 'org.3d': '食谱交换、云做饭、视频品鉴。(稍后推出)',
192
+ 'org.scan': '扫码加我们微信',
193
+ 'org.got': '知道了',
194
+ 'orgFlow.title': '组织你的餐桌',
195
+ 'orgFlow.requiredPrefix': '必填:',
196
+ 'orgFlow.priceRequired': '选择到店支付时需要填写每人价格。',
197
+ 'orgFlow.typeCommunity': '社区活动',
198
+ 'orgFlow.type1to1': '一对一',
199
+ 'orgFlow.typeKicker': '第一步',
200
+ 'orgFlow.typeTitle': '你想组织什么?',
201
+ 'orgFlow.typeSub': '先选择形式,接下来只问几个关键问题。',
202
+ 'orgFlow.stageType': '活动类型',
203
+ 'orgFlow.panelPrompt': '告诉我们有用的信息。先大概写也可以。',
204
+ 'orgFlow.step': '第 {n}/{total} 步',
205
+ 'orgFlow.panelsLeft': '还剩 {n} 个',
206
+ 'orgFlow.save': '保存',
207
+ 'orgFlow.saved': '草稿已保存',
208
+ 'orgFlow.cancel': '取消',
209
+ 'orgFlow.discardTitle': '放弃这个草稿?',
210
+ 'orgFlow.discardBody': '未保存的修改会丢失。如果想稍后继续,请先保存。',
211
+ 'orgFlow.keepEditing': '继续编辑',
212
+ 'orgFlow.discard': '放弃',
213
+ 'orgFlow.pickType': '请先选择活动类型。',
214
+ 'orgFlow.submit': '提交',
215
+ 'orgFlow.submitCommunity': '提交活动',
216
+ 'orgFlow.submitOneToOne': '提交一对一餐桌',
217
+ 'orgFlow.updateCommunity': '更新活动',
218
+ 'orgFlow.updateOneToOne': '更新一对一餐桌',
219
+ 'orgFlow.slotDuplicate': '该时间已在列表中。',
220
+ 'orgFlow.draftKept': '草稿已保存。',
221
+ 'orgFlow.pendingTag': '等待审核',
222
+ 'orgFlow.slotsHint': '请至少添加一个时间选项。',
223
+ 'orgFlow.photoAdd': '选择照片',
224
+ 'orgFlow.photoReplace': '更换照片',
225
+ 'orgFlow.photoHint': '选填 · JPG 或 PNG · 最大 5 MB',
226
+ 'orgFlow.photoTooBig': '图片超过 5 MB。',
227
+ 'orgFlow.photoFailed': '上传失败,请重试。',
228
+ 'orgFlow.pendingHint': '我们的团队会在 24-48 小时内审核。',
229
+ 'orgFlow.submitFailed': '暂时无法提交,请重试。',
230
+ 'orgFlow.loadFailed': '暂时无法加载组织活动页面。',
231
+ 'orgFlow.loadFailedSub': '请检查网络后重试。',
232
+ 'orgFlow.doneTitle': '已提交审核',
233
+ 'orgFlow.doneSub': '我们的团队会在 24-48 小时内审核。结果会在这里通知你。',
234
+ 'orgFlow.updatedTitle': '你的活动想法已更新',
235
+ 'orgFlow.updatedSub': '我们的团队会在 24-48 小时内审核你的修改。',
236
+ 'orgFlow.backToEvents': '返回活动',
237
+ 'orgFlow.myRequests': '你的组织申请',
238
+ 'orgFlow.waitingApproval': '等待审核',
239
+ 'orgFlow.editRequest': '修改',
240
+ 'orgFlow.untitledRequest': '未命名餐桌想法',
241
+ 'orgFlow.people': '{n} 人',
242
+
243
+ 'six.badge': '仅限邀请',
244
+ 'six.exTitle': '六人局 — 为你挑选的餐桌',
245
+ 'six.exSub': '我们的 VIP 餐桌只坐六人,全部由我们亲手匹配。从不公开列出,也不会出现在「社交饭局」中。',
246
+ 'six.exHow': '我们如何匹配你',
247
+ 'six.s1t': '先来参加活动',
248
+ 'six.s1p': '来几场精选饭局,让我们看到你与一桌人的相处。',
249
+ 'six.s2t': '我们亲自审核',
250
+ 'six.s2p': '团队会看你的资料、忌口与兴趣的契合度,以及你和谁吃得来。没有算法替你决定座位。',
251
+ 'six.s3t': '你收到邀请',
252
+ 'six.s3p': '当我们确信这六人会合拍时,便发出私密邀请。你事先只会知道日期。',
253
+ 'six.exFoot': '与我们吃过第一顿饭后即可解锁。先从「社交饭局」开始。',
254
+ 'six.exCta': '查看社交饭局',
255
+ 'six.inTitle': '为你挑选的餐桌',
256
+ 'six.inSub': '根据你与我们的几次饭局,我们亲手为你匹配了五位你会喜欢的人。这些餐桌从不公开。再信我们一次。',
257
+ 'six.inWhereImg': '你可能用餐的地方',
258
+ 'six.inWhen': '时间',
259
+ 'six.inWhere': '地点',
260
+ 'six.inWho': '同桌',
261
+ 'six.inMenu': '菜单',
262
+ 'six.inAccept': '接受邀请',
263
+ 'six.inSeats': '席仅余(共6席)',
264
+ 'six.inLater': '下次再说',
265
+ 'six.inExpires': '邀请将在以下时间后失效',
266
+
267
+ 'pay.kicker': '确认餐位',
268
+ 'pay.date': '日期',
269
+ 'pay.seat': '餐位',
270
+ 'pay.includes': '包含',
271
+ 'pay.includesv': '全套菜单 · 服务',
272
+ 'pay.cancel': '取消政策',
273
+ 'pay.cancelv': '提前72h免费 · 24h内退50%',
274
+ 'pay.warn': '餐位为你量身匹配——一个空位就破坏了整桌。缺席不退款。',
275
+ 'pay.pay': '微信支付',
276
+ 'pay.paid': '支付成功',
277
+ 'pay.back': '返回',
278
+
279
+ 'profile.member': '会员',
280
+ 'profile.myApplication': '我的申请',
281
+ 'profile.title': '我的资料',
282
+ 'profile.app': '我的申请',
283
+ 'profile.herefor': '来意',
284
+ 'profile.status': '状态',
285
+ 'profile.submitted': '提交时间',
286
+ 'profile.completion': '资料完成度',
287
+ 'profile.update': '修改我的申请',
288
+ 'profile.membership': '会籍',
289
+ 'profile.invited': '推荐人',
290
+ 'profile.attended': '已参加饭局',
291
+ 'profile.meetAgain': '获再约意愿',
292
+ 'profile.referrals': '推荐名额',
293
+ 'profile.gift': '可赠 1 席',
294
+ 'profile.high': '高',
295
+ 'profile.lang': '语言',
296
+ 'profile.session': '账户',
297
+ 'profile.refreshWechatLogin': '刷新微信登录',
298
+ 'profile.wechatLoginRefreshed': '微信登录已刷新',
299
+ 'profile.wechatLoginRefreshFailedTitle': '刷新微信登录失败',
300
+ 'profile.wechatLoginRefreshFailedDetail': '请稍后再试。',
301
+ 'profile.logout': '退出登录',
302
+ 'profile.login': '微信登录',
303
+ 'profile.loginHint': '你已退出。登录以同步资料与匹配。',
304
+ 'profile.loggedOut': '已退出',
305
+ 'profile.devMode': 'DEV',
306
+ 'profile.buildVersion': '小程序版本',
307
+ 'profile.forceSignOut': '强制退出(清除会话)',
308
+ 'profile.sessionCleared': '会话已清除',
309
+ 'profile.seeEventsAsPublic': '以访客身份查看活动',
310
+ 'profile.seeEventsAsSelf': '以本人身份查看活动',
311
+ 'profile.viewPublic': '访客',
312
+ 'profile.viewSelf': '本人',
313
+ 'profile.seeProfilePanel': '查看资料页面',
314
+
315
+ 'intent.gastronomy': '美食',
316
+ 'intent.professional_networking': '社交人脉',
317
+ 'intent.local_friendships': '本地朋友',
318
+
319
+ 'tone.food': '为美食',
320
+ 'tone.network': '值得结识',
321
+ 'tone.local': '本地朋友'
322
+ } as const
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from './tokens'
2
+ export * from './i18n'
3
+ export * from './api/types'
4
+ export * from './api/client'
5
+ export * from './utils/applicationStatus'
6
+ export * from './utils/booking'
7
+ export * from './utils/eventMeta'
8
+ export * from './utils/humanize'
9
+ export * from './utils/questionRules'
10
+ export * from './tokens/icons'
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Icons, as base64 SVG data URIs.
3
+ *
4
+ * Ported from wechat/src/constants/icons.js so the tab bar is literally the
5
+ * same artwork in both apps — the web had emoji placeholders, which is exactly
6
+ * the kind of "close enough" difference that makes the two feel like different
7
+ * products. Data URIs rather than files because the mini program needs them
8
+ * inline anyway, and it keeps this package free of asset-bundler assumptions.
9
+ */
10
+
11
+ export const catFood = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2Ij48cGF0aCBmaWxsPSIjYzlhOTZlIiBkPSJNODggNDhWMTZhOCA4IDAgMCAxIDE2IDB2MzJhOCA4IDAgMCAxLTE2IDBabTQwIDhhOCA4IDAgMCAwIDgtOFYxNmE4IDggMCAwIDAtMTYgMHYzMmE4IDggMCAwIDAgOCA4Wm0zMiAwYTggOCAwIDAgMCA4LThWMTZhOCA4IDAgMCAwLTE2IDB2MzJhOCA4IDAgMCAwIDggOFptOTIuOCA0Ni40TDIyNCAxMjR2NjBhMzIgMzIgMCAwIDEtMzIgMzJINjRhMzIgMzIgMCAwIDEtMzItMzJWMTI0TDMuMiAxMDIuNGE4IDggMCAwIDEgOS42LTEyLjhMMzIgMTA0VjgwYTggOCAwIDAgMSA4LThoMTc2YTggOCAwIDAgMSA4IDh2MjRsMTkuMi0xNC40YTggOCAwIDAgMSA5LjYgMTIuOFpNMjA4IDg4SDQ4djk2YTE2IDE2IDAgMCAwIDE2IDE2aDEyOGExNiAxNiAwIDAgMCAxNi0xNloiLz48L3N2Zz4=';
12
+ export const catNetwork = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2Ij48cGF0aCBmaWxsPSIjYzlhOTZlIiBkPSJNMjE2IDU2aC00MHYtOGEyNCAyNCAwIDAgMC0yNC0yNGgtNDhhMjQgMjQgMCAwIDAtMjQgMjR2OEg0MGExNiAxNiAwIDAgMC0xNiAxNnYxMjhhMTYgMTYgMCAwIDAgMTYgMTZoMTc2YTE2IDE2IDAgMCAwIDE2LTE2VjcyYTE2IDE2IDAgMCAwLTE2LTE2Wk05NiA0OGE4IDggMCAwIDEgOC04aDQ4YTggOCAwIDAgMSA4IDh2OEg5NlptMTIwIDI0djQxLjYxQTE4NCAxODQgMCAwIDEgMTI4IDEzNmExODQuMDcgMTg0LjA3IDAgMCAxLTg4LTIyLjM4VjcyWm0wIDEyOEg0MHYtNjguMzZBMjAwLjE5IDIwMC4xOSAwIDAgMCAxMjggMTUyYTIwMC4yNSAyMDAuMjUgMCAwIDAgODgtMjAuMzdWMjAwWk0xMDQgMTEyYTggOCAwIDAgMSA4LThoMzJhOCA4IDAgMCAxIDAgMTZoLTMyYTggOCAwIDAgMS04LThaIi8+PC9zdmc+';
13
+ export const catLocal = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2Ij48cGF0aCBmaWxsPSIjYzlhOTZlIiBkPSJNMjQ0LjggMTUwLjRhOCA4IDAgMCAxLTExLjItMS42QTUxLjYgNTEuNiAwIDAgMCAxOTIgMTI4YTggOCAwIDAgMS03LjM3LTQuODkgOCA4IDAgMCAxIDAtNi4yMkE4IDggMCAwIDEgMTkyIDExMmEyNCAyNCAwIDEgMC0yMy4yNC0zMCA4IDggMCAxIDEtMTUuNS00QTQwIDQwIDAgMSAxIDIxOSAxMTcuNTFhNjcuOTQgNjcuOTQgMCAwIDEgMjcuNDMgMjEuNjhBOCA4IDAgMCAxIDI0NC44IDE1MC40Wk0xOTAuOTIgMjEyYTggOCAwIDEgMS0xMy44NCA4IDU3IDU3IDAgMCAwLTk4LjE2IDAgOCA4IDAgMSAxLTEzLjg0LTggNzIuMDYgNzIuMDYgMCAwIDEgMzMuNzQtMjkuOTIgNDggNDggMCAxIDEgNTguMzYgMEE3Mi4wNiA3Mi4wNiAwIDAgMSAxOTAuOTIgMjEyWk0xMjggMTc2YTMyIDMyIDAgMSAwLTMyLTMyIDMyIDMyIDAgMCAwIDMyIDMyWk03MiAxMjBhOCA4IDAgMCAwLTgtOEEyNCAyNCAwIDEgMSA4Ny4yNCA4MmE4IDggMCAxIDAgMTUuNS00QTQwIDQwIDAgMSAwIDM3IDExNy41MSA2Ny45NCA2Ny45NCAwIDAgMCA5LjYgMTM5LjE5YTggOCAwIDEgMCAxMi44IDkuNjFBNTEuNiA1MS42IDAgMCAxIDY0IDEyOGE4IDggMCAwIDAgOC04WiIvPjwvc3ZnPg==';
14
+ export const tabApply = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjYzlhOTZlIiBzdHJva2Utd2lkdGg9IjEuNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTIgMjBoOU0xNi41IDMuNWEyLjEgMi4xIDAgMCAxIDMgM0w3IDE5bC00IDEgMS00WiIvPjwvc3ZnPg==';
15
+ export const tabDining = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjYzlhOTZlIiBzdHJva2Utd2lkdGg9IjEuNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNiAzdjhhMiAyIDAgMCAwIDQgMFYzTTggMTF2MTBNMTcgM2MtMS43IDAtMyAyLTMgNXMxIDQgMyA0djkiLz48L3N2Zz4=';
16
+ export const tabSix = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjYzlhOTZlIiBzdHJva2Utd2lkdGg9IjEuNiIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTEyIDJsMi45IDYuMyA2LjYuNi01IDQuNSAxLjUgNi42TDEyIDE2LjggNiAyMGwxLjUtNi42LTUtNC41IDYuNi0uNloiLz48L3N2Zz4=';
17
+ export const tabProfile = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjYzlhOTZlIiBzdHJva2Utd2lkdGg9IjEuNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjgiIHI9IjQiLz48cGF0aCBkPSJNNCAyMWMwLTQgMy42LTcgOC03czggMyA4IDciLz48L3N2Zz4=';
18
+ export const iconShare = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2M5YTk2ZSIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0yMjkuNjYsMTA5LjY2bC00OCw0OGE4LDgsMCwwLDEtMTEuMzItMTEuMzJMMjA0LjY5LDExMkgxNjVhODgsODgsMCwwLDAtODUuMjMsNjYsOCw4LDAsMCwxLTE1LjUtNEExMDMuOTQsMTAzLjk0LDAsMCwxLDE2NSw5NmgzOS43MUwxNzAuMzQsNjEuNjZhOCw4LDAsMCwxLDExLjMyLTExLjMybDQ4LDQ4QTgsOCwwLDAsMSwyMjkuNjYsMTA5LjY2Wk0xOTIsMjA4SDQwVjg4YTgsOCwwLDAsMC0xNiwwVjIxNmE4LDgsMCwwLDAsOCw4SDE5MmE4LDgsMCwwLDAsMC0xNloiPjwvcGF0aD48L3N2Zz4=';
19
+ export const iconCopy = 'data:image/svg+xml;base64,PHN2ZyBmaWxsPSIjYzlhOTZlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0yMTYsMzJIODhhOCw4LDAsMCwwLTgsOFY4MEg0MGE4LDgsMCwwLDAtOCw4VjIxNmE4LDgsMCwwLDAsOCw4SDE2OGE4LDgsMCwwLDAsOC04VjE3Nmg0MGE4LDgsMCwwLDAsOC04VjQwQTgsOCwwLDAsMCwyMTYsMzJaTTE2MCwyMDhINDhWOTZIMTYwWm00OC00OEgxNzZWODhhOCw4LDAsMCwwLTgtOEg5NlY0OEgyMDhaIj48L3BhdGg+PC9zdmc+';
20
+ export const communityEventQrCodeUrl = 'https://topchopstick.oss-cn-shanghai.aliyuncs.com/community-event/our-qr-code.png';
21
+ export const qrPlaceholder ='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiBmaWxsPSIjMGMwYTA2Ij48cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0wIDBoMzB2MzBIMHpNOCA4djE0aDE0Vjh6Ii8+PHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNNzAgMGgzMHYzMEg3MHpNNzggOHYxNGgxNFY4eiIvPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTAgNzBoMzB2MzBIMHpNOCA3OHYxNGgxNFY3OHoiLz48cGF0aCBkPSJNNDAgNGg2djZoLTZ6TTUwIDRoNnY2aC02ek00MCAxNGg2djZoLTZ6TTU0IDE0aDZ2NmgtNnpNNDQgMjRoNnY2aC02ek01OCA4aDZ2NmgtNnpNNDAgNDBoNnY2aC02ek01MCA0NGg2djZoLTZ6TTYwIDQwaDZ2NmgtNnpNNzAgNDRoNnY2aC02ek04NCA0MGg2djZoLTZ6TTk0IDQ4aDZ2NmgtNnpNNDAgNTRoNnY2aC02ek01NCA1NGg2djZoLTZ6TTY0IDU4aDZ2NmgtNnpNNzggNTRoNnY2aC02ek05MCA1OGg2djZoLTZ6TTQwIDcwaDZ2NmgtNnpNNTAgNzRoNnY2aC02ek02MCA3MGg2djZoLTZ6TTc0IDc0aDZ2NmgtNnpNODQgNzhoNnY2aC02ek05NCA3NGg2djZoLTZ6TTQ0IDg0aDZ2NmgtNnpNNTQgODhoNnY2aC02ek02OCA4NGg2djZoLTZ6TTc4IDg4aDZ2NmgtNnpNOTAgODRoNnY2aC02eiIvPjwvc3ZnPg==';
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Design tokens — the single source of truth, ported verbatim from
3
+ * wechat/src/app.css so the two apps cannot drift apart visually.
4
+ *
5
+ * Plain objects, not CSS. The web app feeds them into the Tailwind theme; a
6
+ * React Native app can read the same values directly. Change a value here and
7
+ * every client changes together, which is the entire point.
8
+ */
9
+
10
+ export const colors = {
11
+ bg: '#0c0a06',
12
+ bg2: '#14100a',
13
+ card: '#1a150d',
14
+ card2: '#211a10',
15
+ gold: '#c9a96e',
16
+ goldDim: 'rgba(201, 169, 110, 0.25)',
17
+ goldFaint: 'rgba(201, 169, 110, 0.08)',
18
+ ivory: '#f3ede2',
19
+ muted: '#a5a5a5',
20
+ muted2: '#6f6a60',
21
+ green: '#2f9e6e',
22
+ amber: '#e8bd6d'
23
+ } as const
24
+
25
+ /**
26
+ * REM, not px — and that is the whole trick to matching the mini program.
27
+ *
28
+ * Taro runs with designWidth 375 / deviceRatio 375:2, so every px it declares
29
+ * becomes rpx and is then scaled by the real screen:
30
+ *
31
+ * rendered = declared * (screenWidth / 375)
32
+ *
33
+ * A 14px body on a 393pt iPhone therefore draws at 14.67px. The web was
34
+ * rendering a literal 14px — which is exactly why everything looked one size
35
+ * too small.
36
+ *
37
+ * Expressing the scale in rem and making the root font-size fluid (see
38
+ * web/src/styles/index.css) reproduces that scaling: 1rem = 16px at 375 wide,
39
+ * growing with the viewport, capped at the app column width.
40
+ */
41
+ const rem = (px: number) => `${px / 16}rem`
42
+
43
+ export const fontSize = {
44
+ '2xs': rem(10),
45
+ xs: rem(11),
46
+ sm: rem(12),
47
+ '13': rem(13),
48
+ base: rem(14),
49
+ md: rem(15),
50
+ lg: rem(17),
51
+ brand: rem(15),
52
+ h2: rem(20),
53
+ hero: rem(26),
54
+ h1: rem(30)
55
+ } as const
56
+
57
+ export const lineHeight = {
58
+ tight: '1.15',
59
+ snug: '1.3',
60
+ normal: '1.5',
61
+ relaxed: '1.6'
62
+ } as const
63
+
64
+ export const letterSpacing = {
65
+ wide: '0.18em',
66
+ kicker: '0.32em',
67
+ btn: '0.1em'
68
+ } as const
69
+
70
+ /** Same scaling reason as fontSize — padding must grow with the type. */
71
+ export const spacing = {
72
+ 1: rem(4),
73
+ 2: rem(8),
74
+ 3: rem(12),
75
+ 4: rem(16),
76
+ 5: rem(20),
77
+ 6: rem(24)
78
+ } as const
79
+
80
+ export const radius = {
81
+ sm: rem(12),
82
+ md: rem(16),
83
+ lg: rem(20),
84
+ pill: '999px' // a pill is a pill at any scale
85
+ } as const
86
+
87
+ export const fontFamily = {
88
+ display: ['Playfair Display', 'Georgia', 'Songti SC', 'serif'],
89
+ body: ['DM Sans', '-apple-system', 'PingFang SC', 'Helvetica Neue', 'Arial', 'sans-serif']
90
+ } as const
91
+
92
+ /** Height of the fixed tab bar. Content must clear it — see the web layout. */
93
+ export const TAB_BAR_HEIGHT = 58
94
+
95
+ export const tokens = {
96
+ colors, fontSize, lineHeight, letterSpacing, spacing, radius, fontFamily, TAB_BAR_HEIGHT
97
+ }
@@ -0,0 +1,65 @@
1
+ import type { ApplicationRecord, ProfileRecord } from '../api/types'
2
+
3
+ /** Has this person actually applied? */
4
+ export const isSubmitted = (app?: ApplicationRecord | null): boolean =>
5
+ Boolean(app && (app.status === 'submitted' || app.status === 'reviewed'))
6
+
7
+ /**
8
+ * Does this profile still owe onboarding?
9
+ *
10
+ * Note the null case. Anything that is not explicitly 'completed' means
11
+ * onboarding is still owed — a brand-new profile is created without a phase,
12
+ * and treating null as "done" silently skipped onboarding for every new member.
13
+ */
14
+ export const needsOnboarding = (profile?: ProfileRecord | null): boolean =>
15
+ Boolean(profile) && profile!.onboarding_phase !== 'completed'
16
+
17
+ /**
18
+ * IS THIS PERSON ACTUALLY REGISTERED?
19
+ *
20
+ * "Has a profile row" is NOT the answer: on the mini program a row is created
21
+ * silently on first launch, and on the web an SMS code creates one before the
22
+ * questionnaire is touched. Registered means the two things the person chose
23
+ * to do: proved who they are, and finished onboarding.
24
+ *
25
+ * `identityDone` is the per-platform half — WeChat profile granted in the mini
26
+ * program, phone verified on the web — passed in so this stays platform-free.
27
+ */
28
+ export const isRegisteredMember = (
29
+ profile?: ProfileRecord | null,
30
+ application?: ApplicationRecord | null,
31
+ identityDone: boolean = Boolean(profile?.wechat_profile_granted_at || profile?.phone_number)
32
+ ): boolean =>
33
+ Boolean(profile) && identityDone
34
+ && (profile!.onboarding_phase === 'completed' || isSubmitted(application))
35
+
36
+ /** Tabs that only open once someone has applied. */
37
+ export const GATED_TABS = ['dining', 'six'] as const
38
+
39
+ export const canOpenTab = (tab: string, application?: ApplicationRecord | null): boolean =>
40
+ !(GATED_TABS as readonly string[]).includes(tab) || isSubmitted(application)
41
+
42
+ /**
43
+ * THE landing tab. Social Dining is home for a member; anyone who has not
44
+ * applied has nothing to see there, so Apply is home for them.
45
+ */
46
+ export const homeTab = (application?: ApplicationRecord | null): 'dining' | 'apply' =>
47
+ isSubmitted(application) ? 'dining' : 'apply'
48
+
49
+ export type MemberPhase = 'guest' | 'incomplete' | 'member'
50
+
51
+ /**
52
+ * Three honest answers, not two.
53
+ *
54
+ * guest no session — never signed in, or signed out. Identical states.
55
+ * incomplete signed in, onboarding unfinished. Real and reachable.
56
+ * member signed in and onboarded.
57
+ */
58
+ export const memberPhase = (
59
+ hasSession: boolean,
60
+ profile?: ProfileRecord | null,
61
+ application?: ApplicationRecord | null
62
+ ): MemberPhase => {
63
+ if (!hasSession) return 'guest'
64
+ return isRegisteredMember(profile, application) ? 'member' : 'incomplete'
65
+ }
@@ -0,0 +1,49 @@
1
+ import { isPastEvent } from './eventMeta'
2
+ import type { EventRecord } from '../api/types'
3
+
4
+ /**
5
+ * What the booking button says and whether it does anything.
6
+ *
7
+ * Ported from wechat/src/utils/booking.js unchanged. Both clients import THIS,
8
+ * so the mini program and the web page can never disagree about whether a seat
9
+ * is bookable — which is the kind of difference nobody notices until a member
10
+ * books a dinner that already happened.
11
+ */
12
+
13
+ /** Match statuses that mean the user actually holds a seat. */
14
+ export const HOLDS_SEAT = ['accepted', 'booked', 'attended'] as const
15
+
16
+ export type BookingMode = 'free' | 'pay_later' | 'prepaid'
17
+
18
+ /** Legacy rows predate booking_mode, so fall back to the price. */
19
+ export function bookingModeOf(event?: Partial<EventRecord> | null): BookingMode {
20
+ if (!event) return 'free'
21
+ return (event.booking_mode as BookingMode) || (event.price ? 'prepaid' : 'free')
22
+ }
23
+
24
+ /**
25
+ * Can the user undo this themselves?
26
+ * free + pay_later: yes, nothing has been charged.
27
+ * prepaid: no — money changed hands, so it goes through us for the refund.
28
+ */
29
+ export function canSelfCancel(mode: BookingMode): boolean {
30
+ return mode === 'free' || mode === 'pay_later'
31
+ }
32
+
33
+ /**
34
+ * Is the booking button live at all? A finished dinner can be neither joined
35
+ * nor cancelled — there is no seat left to release, and the server returns 410
36
+ * for both.
37
+ */
38
+ export function canBook(event?: Partial<EventRecord> | null): boolean {
39
+ return !isPastEvent(event)
40
+ }
41
+
42
+ export interface SeatMatch { event_id: string; status: string }
43
+
44
+ /** Picks out the user's active match for an event. */
45
+ export function findSeat<T extends SeatMatch>(matches: T[] | null | undefined, eventId: string): T | null {
46
+ return (matches || []).find(
47
+ (m) => m.event_id === eventId && (HOLDS_SEAT as readonly string[]).includes(m.status)
48
+ ) || null
49
+ }
@@ -0,0 +1,83 @@
1
+ import type { EventRecord } from '../api/types'
2
+
3
+ /**
4
+ * Event dates, in Shanghai time.
5
+ *
6
+ * Fixed UTC+8 rather than Intl: the mini program's Intl support is unreliable,
7
+ * and every table is in Shanghai anyway. Keeping the web on the same maths
8
+ * means a dinner never shows a different hour in the two apps.
9
+ */
10
+
11
+ const pad = (n: number) => String(n).padStart(2, '0')
12
+
13
+ export interface ShanghaiParts {
14
+ year: number; month: number; day: number; hour: number; minute: number; weekday: number
15
+ }
16
+
17
+ export function shanghaiParts(raw?: string | null): ShanghaiParts | null {
18
+ if (!raw) return null
19
+ const ms = Date.parse(String(raw).replace(' ', 'T'))
20
+ if (!Number.isFinite(ms)) return null
21
+ const shifted = new Date(ms + 8 * 60 * 60 * 1000)
22
+ return {
23
+ year: shifted.getUTCFullYear(),
24
+ month: shifted.getUTCMonth() + 1,
25
+ day: shifted.getUTCDate(),
26
+ hour: shifted.getUTCHours(),
27
+ minute: shifted.getUTCMinutes(),
28
+ weekday: shifted.getUTCDay()
29
+ }
30
+ }
31
+
32
+ const EN_MONTH = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
33
+ const EN_DAY = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
34
+ const ZH_DAY = ['周日','周一','周二','周三','周四','周五','周六']
35
+
36
+ const dateOf = (p: ShanghaiParts, lang: string) =>
37
+ lang === 'zh'
38
+ ? `${p.month}月${p.day}日 ${ZH_DAY[p.weekday]}`
39
+ : `${EN_DAY[p.weekday]}, ${EN_MONTH[p.month - 1]} ${p.day}`
40
+
41
+ const timeOf = (p: ShanghaiParts) => `${pad(p.hour)}:${pad(p.minute)}`
42
+ const sameDay = (a: ShanghaiParts, b: ShanghaiParts) =>
43
+ a.year === b.year && a.month === b.month && a.day === b.day
44
+
45
+ export function formatEventWhen(startAt?: string | null, endAt?: string | null, lang = 'en'): string {
46
+ const start = shanghaiParts(startAt)
47
+ if (!start) return ''
48
+ const end = endAt ? shanghaiParts(endAt) : null
49
+ const base = `${dateOf(start, lang)} · ${timeOf(start)}`
50
+ if (!end) return base
51
+ return sameDay(start, end) ? `${base}–${timeOf(end)}` : `${base} – ${dateOf(end, lang)} ${timeOf(end)}`
52
+ }
53
+
54
+ export function formatEventMeta(event?: Partial<EventRecord> | null, lang = 'en'): string {
55
+ if (!event) return ''
56
+ const bits: string[] = []
57
+ const when = formatEventWhen(event.start_at, event.end_at, lang)
58
+ if (when) bits.push(when)
59
+ const capacity = Number(event.capacity)
60
+ if (Number.isFinite(capacity) && capacity > 0) {
61
+ bits.push(lang === 'zh' ? `${capacity}人桌` : `table of ${capacity}`)
62
+ }
63
+ return bits.join(' · ')
64
+ }
65
+
66
+ /**
67
+ * Has this table already happened?
68
+ *
69
+ * `end_at` when we have it — a table is not "past" halfway through dinner —
70
+ * otherwise `start_at`. An event with no usable date counts as upcoming:
71
+ * better to show something unbooked than to silently hide it.
72
+ */
73
+ export function isPastEvent(event?: Partial<EventRecord> | null, now: number = Date.now()): boolean {
74
+ const raw = event?.end_at || event?.start_at
75
+ if (!raw) return false
76
+ const at = Date.parse(String(raw).replace(' ', 'T'))
77
+ return Number.isFinite(at) && at < now
78
+ }
79
+
80
+ /** Newest first — for past events the most recent one is the interesting one. */
81
+ export function sortByStartDesc<T extends { start_at?: string | null }>(events: T[] = []): T[] {
82
+ return [...events].sort((a, b) => Date.parse(b.start_at || '0') - Date.parse(a.start_at || '0'))
83
+ }